iteracion 2
This commit is contained in:
+27
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ('celery_app',)
|
||||||
|
|||||||
@@ -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
@@ -11,21 +11,28 @@ https://docs.djangoproject.com/en/6.1/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import environ
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# 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!
|
# 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
|
# Application definition
|
||||||
@@ -41,6 +48,9 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.humanize',
|
'django.contrib.humanize',
|
||||||
'channels',
|
'channels',
|
||||||
'expenses',
|
'expenses',
|
||||||
|
'payroll',
|
||||||
|
'procurement',
|
||||||
|
'talent',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -74,10 +84,12 @@ WSGI_APPLICATION = 'config.wsgi.application'
|
|||||||
ASGI_APPLICATION = 'config.asgi.application'
|
ASGI_APPLICATION = 'config.asgi.application'
|
||||||
|
|
||||||
# Configuracion de WebSockets (Channels)
|
# Configuracion de WebSockets (Channels)
|
||||||
# Para produccion (VPS), cambiar a 'channels_redis.core.RedisChannelLayer'
|
|
||||||
CHANNEL_LAYERS = {
|
CHANNEL_LAYERS = {
|
||||||
"default": {
|
"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
|
# https://docs.djangoproject.com/en/6.1/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': env.db('DATABASE_URL', default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}")
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +126,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
LANGUAGE_CODE = 'es-cl'
|
LANGUAGE_CODE = 'es-cl'
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'America/Santiago'
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@@ -149,3 +158,11 @@ MAILERS = {
|
|||||||
'BACKEND': 'django.core.mail.backends.console.EmailBackend',
|
'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
@@ -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.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
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 = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/v1/', api.urls),
|
||||||
path('', include('expenses.urls')),
|
path('', include('expenses.urls')),
|
||||||
|
path('remuneraciones/', include('payroll.urls')),
|
||||||
|
path('compras/', include('procurement.urls')),
|
||||||
|
path('talento/', include('talent.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
@@ -1,9 +1,12 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User, Group
|
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 .chile_regions import REGION_CHOICES, COMMUNE_CHOICES
|
||||||
|
from .services.sii import SIIService
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -29,10 +32,23 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
label='Fecha del Gasto'
|
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(
|
amount_clp = forms.CharField(
|
||||||
label='Monto Neto (CLP)',
|
label='Monto Neto (CLP)',
|
||||||
required=True,
|
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(
|
receipt = MultipleFileField(
|
||||||
@@ -61,19 +77,19 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
tax_amount_clp = forms.CharField(
|
tax_amount_clp = forms.CharField(
|
||||||
label='Monto Impuesto (CLP)',
|
label='Monto Impuesto (CLP)',
|
||||||
required=False,
|
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(
|
total_amount_clp = forms.CharField(
|
||||||
label='Monto Total (CLP)',
|
label='Monto Total (CLP)',
|
||||||
required=False,
|
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(
|
new_vendor_rut = forms.CharField(
|
||||||
label="RUT Nuevo Proveedor",
|
label="RUT Nuevo Proveedor",
|
||||||
required=False,
|
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(
|
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'})
|
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:
|
class Meta:
|
||||||
model = Expense
|
model = Expense
|
||||||
fields = [
|
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'
|
'description', 'vendor', 'region', 'comuna', 'vehicle_ownership'
|
||||||
]
|
]
|
||||||
widgets = {
|
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):
|
def clean_amount_clp(self):
|
||||||
data = self.cleaned_data.get('amount_clp', '')
|
data = self.cleaned_data.get('amount_clp', '')
|
||||||
if not data:
|
if not data:
|
||||||
@@ -132,11 +181,60 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
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_name = cleaned_data.get('new_vendor_name')
|
||||||
new_rut = cleaned_data.get('new_vendor_rut')
|
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):
|
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.")
|
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
|
return cleaned_data
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
@@ -144,7 +242,6 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
new_name = self.cleaned_data.get('new_vendor_name')
|
new_name = self.cleaned_data.get('new_vendor_name')
|
||||||
new_rut = self.cleaned_data.get('new_vendor_rut')
|
new_rut = self.cleaned_data.get('new_vendor_rut')
|
||||||
if new_name and new_rut:
|
if new_name and new_rut:
|
||||||
from .models import Vendor
|
|
||||||
vendor, _ = Vendor.objects.get_or_create(
|
vendor, _ = Vendor.objects.get_or_create(
|
||||||
rut=new_rut.strip(),
|
rut=new_rut.strip(),
|
||||||
defaults={'name': new_name.strip()}
|
defaults={'name': new_name.strip()}
|
||||||
@@ -152,15 +249,114 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
instance.vendor = vendor
|
instance.vendor = vendor
|
||||||
if commit:
|
if commit:
|
||||||
instance.save()
|
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
|
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 ProjectForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
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 = {
|
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:
|
class Meta:
|
||||||
model = Vendor
|
model = Vendor
|
||||||
fields = ['rut', 'name', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms']
|
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 SystemSettingForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SystemSetting
|
model = SystemSetting
|
||||||
fields = ['global_budget', 'usd_exchange_rate']
|
fields = ['iva_rate', 'usd_exchange_rate']
|
||||||
|
|
||||||
|
|
||||||
class ResponsableForm(forms.Form):
|
class ResponsableForm(forms.Form):
|
||||||
@@ -208,11 +418,9 @@ class ResponsableForm(forms.Form):
|
|||||||
if self.instance:
|
if self.instance:
|
||||||
self.fields['username'].initial = self.instance.username
|
self.fields['username'].initial = self.instance.username
|
||||||
self.fields['email'].initial = self.instance.email
|
self.fields['email'].initial = self.instance.email
|
||||||
# Set role
|
|
||||||
groups = self.instance.groups.all()
|
groups = self.instance.groups.all()
|
||||||
if groups.exists():
|
if groups.exists():
|
||||||
self.fields['role'].initial = groups.first()
|
self.fields['role'].initial = groups.first()
|
||||||
# Set profile info
|
|
||||||
try:
|
try:
|
||||||
self.fields['description'].initial = self.instance.profile.description
|
self.fields['description'].initial = self.instance.profile.description
|
||||||
self.fields['rut'].initial = self.instance.profile.rut
|
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()
|
self.fields['specialties'].initial = self.instance.profile.specialties.all()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Password is optional for edit
|
|
||||||
self.fields['password'].required = False
|
self.fields['password'].required = False
|
||||||
self.fields['password'].help_text = "Dejar en blanco si no desea cambiarla."
|
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.is_superuser = False
|
||||||
|
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
user.groups.clear()
|
user.groups.clear()
|
||||||
user.groups.add(role)
|
user.groups.add(role)
|
||||||
|
|
||||||
@@ -269,7 +475,6 @@ class ResponsableForm(forms.Form):
|
|||||||
profile.job_title = job_title
|
profile.job_title = job_title
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
# Save specialties
|
|
||||||
specialties = self.cleaned_data.get('specialties')
|
specialties = self.cleaned_data.get('specialties')
|
||||||
if specialties is not None:
|
if specialties is not None:
|
||||||
profile.specialties.set(specialties)
|
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
@@ -1,5 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from decimal import Decimal
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
# Enums
|
# Enums
|
||||||
@@ -23,6 +24,7 @@ class ApprovalStatusEnum(models.TextChoices):
|
|||||||
OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado'
|
OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado'
|
||||||
|
|
||||||
class ProjectStatusEnum(models.TextChoices):
|
class ProjectStatusEnum(models.TextChoices):
|
||||||
|
BORRADOR = 'BORRADOR', 'Borrador / En Diseño'
|
||||||
ACTIVO = 'ACTIVO', 'Activo'
|
ACTIVO = 'ACTIVO', 'Activo'
|
||||||
PAUSADA = 'PAUSADA', 'En Pausa'
|
PAUSADA = 'PAUSADA', 'En Pausa'
|
||||||
TERMINADO = 'TERMINADO', 'Terminado'
|
TERMINADO = 'TERMINADO', 'Terminado'
|
||||||
@@ -33,38 +35,95 @@ class VehicleOwnershipEnum(models.TextChoices):
|
|||||||
BUS = 'BUS', 'Bus'
|
BUS = 'BUS', 'Bus'
|
||||||
UBER_TAXI = 'UBER_TAXI', 'Uber/Taxi'
|
UBER_TAXI = 'UBER_TAXI', 'Uber/Taxi'
|
||||||
|
|
||||||
class Project(models.Model):
|
class ExpenseReportStatusEnum(models.TextChoices):
|
||||||
# Added core project/costcenter fields mapped from Prisma
|
BORRADOR = 'BORRADOR', 'Borrador'
|
||||||
code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000')
|
ENVIADO = 'ENVIADO', 'Enviado para Aprobación'
|
||||||
name = models.CharField(max_length=200, verbose_name="Nombre del Proyecto/Centro")
|
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")
|
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")
|
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_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)")
|
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)")
|
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")
|
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")
|
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")
|
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")
|
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):
|
def __str__(self):
|
||||||
return f"{self.code} - {self.name}"
|
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:
|
class Meta:
|
||||||
verbose_name = "Proyecto / Centro de Costo"
|
verbose_name = "Proyecto / Centro de Costo"
|
||||||
verbose_name_plural = "Proyectos y Centros"
|
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")
|
class ProjectPhase(TimeStampedModel):
|
||||||
name = models.CharField(max_length=100, verbose_name="Nombre de Categoría")
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='phases', verbose_name="Proyecto")
|
||||||
description = models.TextField(blank=True, verbose_name="Descripción")
|
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")
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Categoría Padre")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -74,12 +133,12 @@ class Category(models.Model):
|
|||||||
verbose_name = "Categoría"
|
verbose_name = "Categoría"
|
||||||
verbose_name_plural = "Categorías"
|
verbose_name_plural = "Categorías"
|
||||||
|
|
||||||
class Vendor(models.Model):
|
|
||||||
|
class Vendor(TimeStampedModel):
|
||||||
rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor")
|
rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor")
|
||||||
name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre")
|
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")
|
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")
|
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_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")
|
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)")
|
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):
|
def __str__(self):
|
||||||
return f"{self.rut} - {self.name}"
|
return f"{self.rut} - {self.name}"
|
||||||
@@ -98,18 +155,61 @@ class Vendor(models.Model):
|
|||||||
verbose_name_plural = "Proveedores"
|
verbose_name_plural = "Proveedores"
|
||||||
ordering = ['name']
|
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")
|
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")
|
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")
|
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)")
|
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_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)")
|
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)")
|
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")
|
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")
|
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")
|
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)")
|
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")
|
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")
|
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")
|
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")
|
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)")
|
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)")
|
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")
|
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")
|
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):
|
def __str__(self):
|
||||||
return f"{self.expense_code} - {self.amount_clp} CLP ({self.date})"
|
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:
|
class Meta:
|
||||||
verbose_name = "Gasto"
|
verbose_name = "Gasto"
|
||||||
verbose_name_plural = "Gastos"
|
verbose_name_plural = "Gastos"
|
||||||
ordering = ['-date', '-created_at']
|
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):
|
class Document(models.Model):
|
||||||
expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='documents', verbose_name="Gasto Relacionado")
|
expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='documents', verbose_name="Gasto Relacionado")
|
||||||
file = models.FileField(upload_to='evidences/', verbose_name="Archivo Adjunto")
|
file = models.FileField(upload_to='evidences/', verbose_name="Archivo Adjunto")
|
||||||
@@ -156,10 +333,20 @@ class Document(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Evidencia para {self.expense.expense_code}"
|
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:
|
class Meta:
|
||||||
verbose_name = "Documento Evidencia"
|
verbose_name = "Documento Evidencia"
|
||||||
verbose_name_plural = "Documentos Evidencias"
|
verbose_name_plural = "Documentos Evidencias"
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
class UserProfile(models.Model):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name="Usuario")
|
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")
|
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 = "Perfil de Usuario"
|
||||||
verbose_name_plural = "Perfiles de Usuario"
|
verbose_name_plural = "Perfiles de Usuario"
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(models.Model):
|
class AuditLog(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Usuario")
|
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")
|
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 = "Log de Auditoría (SAP CDHDR/CDPOS)"
|
||||||
verbose_name_plural = "Logs de Auditoría"
|
verbose_name_plural = "Logs de Auditoría"
|
||||||
|
|
||||||
|
|
||||||
class SystemSetting(models.Model):
|
class SystemSetting(models.Model):
|
||||||
global_budget = models.DecimalField(max_digits=15, decimal_places=2, default=25000000.00, verbose_name="Presupuesto General I+D+i")
|
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")
|
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):
|
def __str__(self):
|
||||||
return "Configuración Global EFINTEL"
|
return "Configuración Global EFINTEL"
|
||||||
@@ -207,3 +397,12 @@ class SystemSetting(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Configuración del Sistema"
|
verbose_name = "Configuración del Sistema"
|
||||||
verbose_name_plural = "Configuraciones 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"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .sii import SIIService
|
||||||
|
|
||||||
|
__all__ = ['SIIService']
|
||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ urlpatterns = [
|
|||||||
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
|
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
|
||||||
path('gastos/documento/<int:pk>/eliminar/', views.delete_document, name='delete_document'),
|
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
|
# Flujo de aprobación y Cuentas por Pagar
|
||||||
path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'),
|
path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'),
|
||||||
path('gastos/<int:pk>/aprobar/<str:action>/', views.approve_expense, name='approve_expense'),
|
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('gastos/exportar/transferencias/', views.export_bank_transfer_csv, name='export_bank_transfer_csv'),
|
||||||
|
|
||||||
path('proyectos/', views.project_list, name='project_list'),
|
path('proyectos/', views.project_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/', views.gestion_panel, name='gestion_panel'),
|
||||||
path('gestion/configuracion/', views.settings_edit, name='settings_edit'),
|
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/nuevo/', views.responsable_create, name='responsable_create'),
|
||||||
path('gestion/responsables/<int:pk>/editar/', views.responsable_edit, name='responsable_edit'),
|
path('gestion/responsables/<int:pk>/editar/', views.responsable_edit, name='responsable_edit'),
|
||||||
path('gestion/responsables/<int:pk>/eliminar/', views.responsable_delete, name='responsable_delete'),
|
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
@@ -1,10 +1,13 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
from decimal import Decimal
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
from django.contrib.auth.decorators import login_required
|
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.auth import login, authenticate, logout
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db.models import Sum, Count, F
|
from django.db.models import Sum, Count, F
|
||||||
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from django.http import JsonResponse, HttpResponse
|
from django.http import JsonResponse, HttpResponse
|
||||||
@@ -37,6 +40,7 @@ def logout_view(request):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@cache_page(60 * 5) # Cache 5 minutos (Disponibilidad)
|
||||||
def dashboard(request):
|
def dashboard(request):
|
||||||
today = date.today()
|
today = date.today()
|
||||||
first_day_of_month = today.replace(day=1)
|
first_day_of_month = today.replace(day=1)
|
||||||
@@ -49,18 +53,21 @@ def dashboard(request):
|
|||||||
total=Sum('amount_clp')
|
total=Sum('amount_clp')
|
||||||
)['total'] or 0
|
)['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()
|
today_count = Expense.objects.filter(date=today).count()
|
||||||
|
|
||||||
recent_expenses = Expense.objects.select_related(
|
recent_expenses = Expense.objects.select_related(
|
||||||
'project', 'category', 'user', 'responsible_person'
|
'project', 'category', 'user', 'responsible_person'
|
||||||
).order_by('-date')[:10]
|
).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
|
# Chart Data: Gastos por Categoría
|
||||||
cat_expenses = Expense.objects.values('category__name').annotate(total=Sum('amount_clp')).order_by('-total')
|
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]
|
category_labels = [item['category__name'] or 'Sin Categoría' for item in cat_expenses]
|
||||||
@@ -74,11 +81,13 @@ def dashboard(request):
|
|||||||
context = {
|
context = {
|
||||||
'total_month': total_month_clp,
|
'total_month': total_month_clp,
|
||||||
'total_all': total_all_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,
|
'today_count': today_count,
|
||||||
'recent_expenses': recent_expenses,
|
'recent_expenses': recent_expenses,
|
||||||
'global_budget': global_budget,
|
|
||||||
'remaining_budget': remaining_budget,
|
|
||||||
'category_labels_json': json.dumps(category_labels),
|
'category_labels_json': json.dumps(category_labels),
|
||||||
'category_data_json': json.dumps(category_data),
|
'category_data_json': json.dumps(category_data),
|
||||||
'project_labels_json': json.dumps(project_labels),
|
'project_labels_json': json.dumps(project_labels),
|
||||||
@@ -111,6 +120,7 @@ def expense_list(request):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@transaction.atomic # Integridad de datos
|
||||||
def expense_create(request):
|
def expense_create(request):
|
||||||
# Only allow creation if user is admin or allowed to
|
# Only allow creation if user is admin or allowed to
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -419,13 +429,49 @@ def export_bank_transfer_csv(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def project_list(request):
|
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(
|
projects = Project.objects.annotate(
|
||||||
expense_count=Count('expenses'),
|
expense_count=Count('expenses'),
|
||||||
total_expenses=Sum('expenses__amount_clp')
|
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 = {
|
context = {
|
||||||
'projects': projects,
|
'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)
|
return render(request, 'expenses/project_list.html', context)
|
||||||
|
|
||||||
@@ -467,34 +513,125 @@ def settings_edit(request):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@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):
|
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':
|
if request.method == 'POST':
|
||||||
form = ProjectForm(request.POST)
|
form = ProjectForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
project = form.save(commit=False)
|
project = form.save(commit=False)
|
||||||
project.created_by = request.user
|
project.created_by = request.user
|
||||||
|
if not project.manager and is_pm and not is_admin:
|
||||||
|
project.manager = request.user
|
||||||
project.save()
|
project.save()
|
||||||
messages.success(request, 'Proyecto creado exitosamente.')
|
messages.success(request, f"Proyecto {project.code} - {project.name} creado exitosamente.")
|
||||||
return redirect('gestion_panel')
|
return redirect('project_detail', pk=project.pk)
|
||||||
else:
|
else:
|
||||||
form = ProjectForm()
|
# Default code generation
|
||||||
return render(request, 'expenses/project_form.html', {'form': form})
|
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
|
@login_required
|
||||||
@role_required('Administrador')
|
|
||||||
def project_edit(request, pk):
|
def project_edit(request, pk):
|
||||||
project = get_object_or_404(Project, pk=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':
|
if request.method == 'POST':
|
||||||
form = ProjectForm(request.POST, instance=project)
|
form = ProjectForm(request.POST, instance=project)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
messages.success(request, 'Proyecto actualizado exitosamente.')
|
messages.success(request, f"Proyecto {project.code} actualizado exitosamente.")
|
||||||
return redirect('gestion_panel')
|
return redirect('project_detail', pk=project.pk)
|
||||||
else:
|
else:
|
||||||
form = ProjectForm(instance=project)
|
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
|
@login_required
|
||||||
@@ -504,7 +641,7 @@ def project_delete(request, pk):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
project.delete()
|
project.delete()
|
||||||
messages.success(request, 'Proyecto eliminado.')
|
messages.success(request, 'Proyecto eliminado.')
|
||||||
return redirect('gestion_panel')
|
return redirect('project_list')
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -547,32 +684,59 @@ def category_delete(request, pk):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@role_required('Administrador')
|
|
||||||
def vendor_create(request):
|
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':
|
if request.method == 'POST':
|
||||||
form = VendorForm(request.POST)
|
form = VendorForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
vendor = form.save()
|
||||||
messages.success(request, 'Proveedor creado exitosamente.')
|
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')
|
return redirect('gestion_panel')
|
||||||
else:
|
else:
|
||||||
form = VendorForm()
|
# Save POST data into session as draft mitigation
|
||||||
return render(request, 'expenses/vendor_form.html', {'form': form})
|
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
|
@login_required
|
||||||
@role_required('Administrador')
|
|
||||||
def vendor_edit(request, pk):
|
def vendor_edit(request, pk):
|
||||||
vendor = get_object_or_404(Vendor, pk=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':
|
if request.method == 'POST':
|
||||||
form = VendorForm(request.POST, instance=vendor)
|
form = VendorForm(request.POST, instance=vendor)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
messages.success(request, 'Proveedor actualizado exitosamente.')
|
messages.success(request, f"Proveedor {vendor.rut} actualizado exitosamente.")
|
||||||
return redirect('gestion_panel')
|
return redirect('gestion_panel')
|
||||||
|
else:
|
||||||
|
messages.warning(request, "Error de validación al actualizar el proveedor. Se conservan los cambios parciales.")
|
||||||
else:
|
else:
|
||||||
form = VendorForm(instance=vendor)
|
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
|
@login_required
|
||||||
@@ -706,3 +870,137 @@ def delete_document(request, pk):
|
|||||||
doc.delete()
|
doc.delete()
|
||||||
return JsonResponse({'success': True})
|
return JsonResponse({'success': True})
|
||||||
return JsonResponse({'success': False, 'error': 'Método no permitido.'}, status=405)
|
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']
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Payroll app
|
||||||
@@ -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')
|
||||||
@@ -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)'
|
||||||
@@ -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'})
|
||||||
|
)
|
||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Payroll migrations package
|
||||||
@@ -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']
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .engine import PayrollEngine, BankExportService, PreviredExportService, LREExportService
|
||||||
|
|
||||||
|
__all__ = ['PayrollEngine', 'BankExportService', 'PreviredExportService', 'LREExportService']
|
||||||
@@ -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()
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Procurement app
|
||||||
@@ -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',)
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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']
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .matching import ThreeWayMatchingEngine
|
||||||
|
|
||||||
|
__all__ = ['ThreeWayMatchingEngine']
|
||||||
@@ -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).'
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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
@@ -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))
|
||||||
@@ -1,28 +1,52 @@
|
|||||||
|
amqp==5.3.1
|
||||||
|
annotated-types==0.8.0
|
||||||
asgiref==3.12.1
|
asgiref==3.12.1
|
||||||
attrs==26.1.0
|
attrs==26.1.0
|
||||||
autobahn==26.7.1
|
autobahn==26.7.1
|
||||||
Automat==25.4.16
|
Automat==25.4.16
|
||||||
|
billiard==4.2.4
|
||||||
cbor2==6.1.4
|
cbor2==6.1.4
|
||||||
|
celery==5.6.3
|
||||||
cffi==2.1.1
|
cffi==2.1.1
|
||||||
channels==4.3.2
|
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
|
constantly==23.10.4
|
||||||
cryptography==50.0.0
|
cryptography==50.0.0
|
||||||
daphne==4.2.3
|
daphne==4.2.3
|
||||||
distlib==0.3.8
|
distlib==0.3.8
|
||||||
Django==6.1
|
Django==6.1
|
||||||
|
django-environ==0.14.0
|
||||||
|
django-ninja==1.7.0
|
||||||
hyperlink==21.0.0
|
hyperlink==21.0.0
|
||||||
idna==3.18
|
idna==3.18
|
||||||
Incremental==24.11.0
|
Incremental==24.11.0
|
||||||
|
kombu==5.6.2
|
||||||
msgpack==1.2.1
|
msgpack==1.2.1
|
||||||
packaging==24.0
|
packaging==24.0
|
||||||
|
prompt_toolkit==3.0.53
|
||||||
|
psycopg2-binary==2.9.12
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
|
pydantic==2.13.5
|
||||||
|
pydantic_core==2.46.5
|
||||||
pyOpenSSL==26.4.0
|
pyOpenSSL==26.4.0
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
redis==8.1.0
|
||||||
service-identity==26.1.0
|
service-identity==26.1.0
|
||||||
setuptools==68.1.2
|
setuptools==68.1.2
|
||||||
|
six==1.17.0
|
||||||
sqlparse==0.6.0
|
sqlparse==0.6.0
|
||||||
Twisted==26.4.0
|
Twisted==26.4.0
|
||||||
txaio==26.6.1
|
txaio==26.6.1
|
||||||
|
typing-inspection==0.4.4
|
||||||
typing_extensions==4.16.0
|
typing_extensions==4.16.0
|
||||||
|
tzdata==2026.3
|
||||||
|
tzlocal==5.4.4
|
||||||
ujson==5.13.0
|
ujson==5.13.0
|
||||||
|
vine==5.1.0
|
||||||
|
wcwidth==0.8.3
|
||||||
wheel==0.42.0
|
wheel==0.42.0
|
||||||
zope.interface==8.5
|
zope.interface==8.5
|
||||||
|
|||||||
+132
@@ -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
@@ -10,15 +10,12 @@ django.setup()
|
|||||||
from expenses.models import Category
|
from expenses.models import Category
|
||||||
|
|
||||||
def run_seed():
|
def run_seed():
|
||||||
print("Clearing existing categories to ensure clean state...")
|
print("Seeding professional CBS categories and Chart of Accounts...")
|
||||||
Category.objects.all().delete()
|
|
||||||
|
|
||||||
print("Seeding professional CBS categories...")
|
# Define the complete EFINTEL CBS structure
|
||||||
|
|
||||||
# Define the structure
|
|
||||||
cbs_structure = {
|
cbs_structure = {
|
||||||
"1000": {
|
"1000": {
|
||||||
"name": "Recursos Humanos y Especialidades",
|
"name": "Recursos Humanos y Especialidades I+D+i",
|
||||||
"children": {
|
"children": {
|
||||||
"1100": "Personal Staff Core (Fijo / Sueldos Base)",
|
"1100": "Personal Staff Core (Fijo / Sueldos Base)",
|
||||||
"1201": "Electricidad de Potencia e Infraestructura",
|
"1201": "Electricidad de Potencia e Infraestructura",
|
||||||
@@ -31,11 +28,12 @@ def run_seed():
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"2000": {
|
"2000": {
|
||||||
"name": "Materiales e Insumos",
|
"name": "Materiales, Insumos e Infraestructura de Trabajo",
|
||||||
"children": {
|
"children": {
|
||||||
"2100": "Componentes Electrónicos Prototipado",
|
"2100": "Componentes Electrónicos Prototipado",
|
||||||
"2200": "Equipamiento Industrial de Campo",
|
"2200": "Equipamiento Industrial de Campo",
|
||||||
"2300": "Materiales Eléctricos y de Montaje",
|
"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"
|
"2400": "Insumos de Oficina, Aseo, Cafetería y Papelería"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -54,12 +52,22 @@ def run_seed():
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"4000": {
|
"4000": {
|
||||||
"name": "Software y Herramientas Digitales",
|
"name": "Software, Herramientas Digitales y Formación",
|
||||||
"children": {
|
"children": {
|
||||||
"4100": "Licencias de Ingeniería & Diseño (Altium/Unity/Unreal)",
|
"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"
|
"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,
|
code=p_code,
|
||||||
defaults={'name': p_data['name'], 'description': 'Partida Principal (Macro-categoría)'}
|
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():
|
for c_code, c_name in p_data['children'].items():
|
||||||
child_cat, _ = Category.objects.get_or_create(
|
child_cat, _ = Category.objects.get_or_create(
|
||||||
code=c_code,
|
code=c_code,
|
||||||
defaults={'name': c_name, 'parent': parent_cat}
|
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.")
|
print("✅ CBS Seeding completed successfully.")
|
||||||
|
|
||||||
|
|||||||
+217
-53
@@ -8,27 +8,28 @@
|
|||||||
--efintel-blue: #0078A8; /* Primary brand color, main navigation, interactive elements */
|
--efintel-blue: #0078A8; /* Primary brand color, main navigation, interactive elements */
|
||||||
--efintel-turquoise: #1EB8B0; /* R&D&I innovation accent, positive indicators, progress */
|
--efintel-turquoise: #1EB8B0; /* R&D&I innovation accent, positive indicators, progress */
|
||||||
--efintel-petroleum: #005B73; /* System modules, secondary navigation, tech headers */
|
--efintel-petroleum: #005B73; /* System modules, secondary navigation, tech headers */
|
||||||
--efintel-dark: #4B4B4B; /* Titles, primary headers, heavy contrast text */
|
--efintel-dark: #2A343D; /* Titles, primary headers, heavy contrast text */
|
||||||
--efintel-gray: #7A7F83; /* Secondary body text, captions, subtitles */
|
--efintel-gray: #64748B; /* Secondary body text, captions, subtitles */
|
||||||
--efintel-light: #F2F4F5; /* Backgrounds, secondary card surfaces */
|
--efintel-light: #F8FAFC; /* Backgrounds, secondary card surfaces */
|
||||||
--efintel-white: #FFFFFF; /* Primary surfaces, cards, modals */
|
--efintel-white: #FFFFFF; /* Primary surfaces, cards, modals */
|
||||||
--efintel-border: #DDE3E7; /* Clean subtle borders */
|
--efintel-border: #E2E8F0; /* Clean subtle borders */
|
||||||
--efintel-border-hover: #B8C4CE; /* Hover state borders */
|
--efintel-border-hover: #CBD5E1; /* Hover state borders */
|
||||||
|
|
||||||
/* State & Alert Palette */
|
/* State & Alert Palette */
|
||||||
--efintel-success: #2E8B57; /* Positive state, compliance */
|
--efintel-success: #10B981; /* Positive state, compliance */
|
||||||
--efintel-warning: #E3A008; /* Warnings, pending review */
|
--efintel-warning: #F59E0B; /* Warnings, pending review */
|
||||||
--efintel-danger: #D64545; /* Errors, critical risk */
|
--efintel-danger: #EF4444; /* Errors, critical risk */
|
||||||
|
|
||||||
/* Surface Shadows & Elevations (Extremely subtle, corporate) */
|
/* Modern Glassmorphic & Elevation Shadows */
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
|
--shadow-sm: 0 2px 8px rgba(15, 23, 42, 0.04), 0 1px 2px rgba(15, 23, 42, 0.02);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.05);
|
--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 12px 28px rgba(0, 0, 0, 0.08);
|
--shadow-lg: 0 20px 32px -8px rgba(15, 23, 42, 0.12), 0 8px 16px -4px rgba(15, 23, 42, 0.06);
|
||||||
|
|
||||||
/* Radius Tokens */
|
/* Modern Rounded Radius Tokens */
|
||||||
--radius-sm: 6px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 10px;
|
--radius-md: 16px;
|
||||||
--radius-lg: 14px;
|
--radius-lg: 24px;
|
||||||
|
--radius-xl: 32px;
|
||||||
|
|
||||||
/* Typography Tokens */
|
/* Typography Tokens */
|
||||||
--font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
--font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
@@ -180,6 +181,38 @@ body {
|
|||||||
MAIN CONTENT & LAYOUT STRUCTURE
|
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 {
|
.main-container {
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -192,6 +225,7 @@ body {
|
|||||||
|
|
||||||
.page-view.active {
|
.page-view.active {
|
||||||
display: block;
|
display: block;
|
||||||
|
animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-header {
|
.view-header {
|
||||||
@@ -222,22 +256,29 @@ body {
|
|||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--efintel-blue);
|
background: linear-gradient(135deg, var(--efintel-blue) 0%, var(--efintel-petroleum) 100%);
|
||||||
color: var(--efintel-white);
|
color: var(--efintel-white);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.6rem 1.1rem;
|
padding: 0.65rem 1.2rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 {
|
.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 {
|
.btn-secondary {
|
||||||
@@ -245,38 +286,41 @@ body {
|
|||||||
color: var(--efintel-dark);
|
color: var(--efintel-dark);
|
||||||
border: 1px solid var(--efintel-border);
|
border: 1px solid var(--efintel-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.6rem 1.1rem;
|
padding: 0.65rem 1.2rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
border-color: var(--efintel-border-hover);
|
border-color: var(--efintel-blue);
|
||||||
background-color: var(--efintel-light);
|
background-color: rgba(0, 120, 168, 0.04);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background-color: var(--efintel-white);
|
background-color: var(--efintel-white);
|
||||||
color: var(--efintel-danger);
|
color: var(--efintel-danger);
|
||||||
border: 1px solid var(--efintel-danger);
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.6rem 1.1rem;
|
padding: 0.65rem 1.2rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
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 */
|
/* Accent Lateral Bar */
|
||||||
@@ -351,6 +401,11 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card:hover .kpi-icon {
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-value {
|
.kpi-value {
|
||||||
@@ -390,35 +445,45 @@ body {
|
|||||||
background-color: var(--efintel-danger);
|
background-color: var(--efintel-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badges */
|
/* Badges with modern rounded pill design */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.35rem;
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.3rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 9999px;
|
||||||
font-size: 0.75rem;
|
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 {
|
.badge-emerald {
|
||||||
background-color: rgba(46, 139, 87, 0.12);
|
background-color: rgba(16, 185, 129, 0.12);
|
||||||
color: var(--efintel-success);
|
color: #047857;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-amber {
|
.badge-amber {
|
||||||
background-color: rgba(227, 160, 8, 0.12);
|
background-color: rgba(245, 158, 11, 0.12);
|
||||||
color: var(--efintel-warning);
|
color: #B45309;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-red {
|
.badge-red {
|
||||||
background-color: rgba(214, 69, 69, 0.12);
|
background-color: rgba(239, 68, 68, 0.12);
|
||||||
color: var(--efintel-danger);
|
color: #B91C1C;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-blue {
|
.badge-blue {
|
||||||
background-color: rgba(0, 120, 168, 0.12);
|
background-color: rgba(0, 120, 168, 0.12);
|
||||||
color: var(--efintel-blue);
|
color: var(--efintel-blue);
|
||||||
|
border: 1px solid rgba(0, 120, 168, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
@@ -655,34 +720,133 @@ body {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
/* Modern Form Styles */
|
||||||
margin-bottom: 1.25rem;
|
.modern-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-grid {
|
||||||
display: block;
|
display: grid;
|
||||||
font-size: 0.8rem;
|
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;
|
font-weight: 600;
|
||||||
color: var(--efintel-dark);
|
color: var(--efintel-dark);
|
||||||
margin-bottom: 0.4rem;
|
letter-spacing: -0.01em;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-help {
|
||||||
width: 100%;
|
font-size: 0.75rem;
|
||||||
padding: 0.65rem 0.85rem;
|
color: var(--efintel-gray);
|
||||||
border: 1px solid var(--efintel-border);
|
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);
|
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);
|
font-family: var(--font-main);
|
||||||
color: var(--efintel-dark);
|
color: var(--efintel-dark);
|
||||||
background-color: var(--efintel-white);
|
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;
|
outline: none;
|
||||||
border-color: var(--efintel-blue);
|
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 */
|
/* Evidence Slot Grid */
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Talent app
|
||||||
@@ -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')
|
||||||
@@ -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'
|
||||||
@@ -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...'}),
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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']
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .rubric import TalentScoringEngine
|
||||||
|
|
||||||
|
__all__ = ['TalentScoringEngine']
|
||||||
@@ -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')),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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">
|
<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://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></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 }}">
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -49,12 +50,21 @@
|
|||||||
<a href="{% url 'dashboard' %}" class="nav-tab {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}">
|
<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
|
<i data-lucide="pie-chart" style="width:16px;"></i> Resumen Financiero
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'expense_list' %}" class="nav-tab {% if request.resolver_match.url_name == 'expense_list' %}active{% endif %}">
|
<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 Gastos
|
<i data-lucide="truck" style="width:16px;"></i> Operaciones y Erogaciones
|
||||||
</a>
|
</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
|
<i data-lucide="folder-kanban" style="width:16px;"></i> Control de Proyectos
|
||||||
</a>
|
</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 %}
|
{% 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 %}">
|
<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)
|
<i data-lucide="calculator" style="width:16px;"></i> Cuentas por Pagar (CXP)
|
||||||
@@ -81,10 +91,77 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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;">×</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>
|
<script>
|
||||||
if (window.lucide) {
|
if (window.lucide) {
|
||||||
lucide.createIcons();
|
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>
|
</script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-card accent-blue">
|
||||||
<div class="kpi-header">
|
<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 class="kpi-icon"><i data-lucide="credit-card"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-value" id="kpi-spend">${{ total_all|floatformat:"0"|intcomma }} CLP</div>
|
<div class="kpi-value" id="kpi-spend">${{ total_all|floatformat:"0"|intcomma }} CLP</div>
|
||||||
@@ -27,25 +27,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-card accent-success">
|
||||||
<div class="kpi-header">
|
<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 class="kpi-icon"><i data-lucide="trending-up"></i></div>
|
||||||
</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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,9 +92,14 @@
|
|||||||
{% if expense.documents.all %}
|
{% if expense.documents.all %}
|
||||||
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
|
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
|
||||||
{% for doc in expense.documents.all %}
|
{% 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 }}">
|
<div style="display:inline-flex; align-items:center; gap:0.3rem;">
|
||||||
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:25 }}
|
<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>
|
</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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% if form.instance.pk %}Modificar Gasto Registrado{% else %}Registro de Nueva Erogación{% endif %}
|
{% if form.instance.pk %}Modificar Gasto Registrado{% else %}Registro de Nueva Erogación{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data" x-data="expenseForm()">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<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; 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 %}
|
{% if form.currency.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.currency.errors }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="background:#f8fafc; padding:0.75rem; border-radius:6px; border:1px solid #e2e8f0;">
|
||||||
<label for="{{ form.responsible_person.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Responsable Solicitante</label>
|
<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 }}
|
{{ 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 %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,14 +98,14 @@
|
|||||||
{{ form.vendor }}
|
{{ form.vendor }}
|
||||||
<div style="margin-top: 0.5rem;">
|
<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;">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% if form.vendor.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.vendor.errors }}</div>{% endif %}
|
{% 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>
|
</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;">
|
<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>
|
<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 }}
|
{{ form.new_vendor_rut }}
|
||||||
@@ -164,20 +170,67 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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;">
|
<!-- Requerimiento 14: El apartado de vehículo SOLO aplica para erogaciones de Combustible (3101) -->
|
||||||
<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>
|
<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">
|
<div class="form-group">
|
||||||
<label for="{{ form.region.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Región Operativa</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.comuna.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Comuna</label>
|
<label for="{{ form.comuna.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Comuna</label>
|
||||||
{{ form.comuna }}
|
<select name="comuna" id="id_comuna" x-model="selectedComuna">
|
||||||
</div>
|
<option value="">--- Seleccione Comuna ---</option>
|
||||||
<div class="form-group">
|
<template x-for="comunaName in availableComunas" :key="comunaName">
|
||||||
<label for="{{ form.vehicle_ownership.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Vehículo Asignado</label>
|
<option :value="comunaName" x-text="comunaName" :selected="comunaName === selectedComuna"></option>
|
||||||
{{ form.vehicle_ownership }}
|
</template>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,6 +249,98 @@
|
|||||||
{{ regions_communes_json|safe }}
|
{{ regions_communes_json|safe }}
|
||||||
</script>
|
</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>
|
<script>
|
||||||
document.querySelectorAll('input:not([type="checkbox"]), select, textarea').forEach(el => {
|
document.querySelectorAll('input:not([type="checkbox"]), select, textarea').forEach(el => {
|
||||||
el.style.width = '100%';
|
el.style.width = '100%';
|
||||||
@@ -207,63 +352,26 @@
|
|||||||
el.style.boxSizing = 'border-box';
|
el.style.boxSizing = 'border-box';
|
||||||
});
|
});
|
||||||
|
|
||||||
const amountInput = document.getElementById('id_amount_clp');
|
// AlpineJS manages tax calculation and toggles now.
|
||||||
const taxTypeSelect = document.getElementById('id_tax_type');
|
|
||||||
const taxAmountInput = document.getElementById('id_tax_amount_clp');
|
|
||||||
const totalAmountInput = document.getElementById('id_total_amount_clp');
|
|
||||||
|
|
||||||
function formatNumber(num) {
|
// Requerimiento 14: Apartado de Vehículo SOLO para Combustibles (3101)
|
||||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
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) {
|
if (selText.includes('3101') || selText.includes('combustible')) {
|
||||||
return str.replace(/\./g, '');
|
vehicleLogContainer.style.display = 'block';
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
} else {
|
} 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 categorySelect = document.getElementById('id_category');
|
||||||
const searchInput = document.getElementById('category-search-input');
|
const searchInput = document.getElementById('category-search-input');
|
||||||
const dropdownList = document.getElementById('category-dropdown-list');
|
const dropdownList = document.getElementById('category-dropdown-list');
|
||||||
|
|||||||
@@ -70,9 +70,14 @@
|
|||||||
{% if expense.documents.all %}
|
{% if expense.documents.all %}
|
||||||
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
|
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
|
||||||
{% for doc in expense.documents.all %}
|
{% 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 }}">
|
<div style="display:inline-flex; align-items:center; gap:0.3rem;">
|
||||||
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:25 }}
|
<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>
|
</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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
@@ -391,17 +391,23 @@
|
|||||||
<div id="tab-config" class="tab-content">
|
<div id="tab-config" class="tab-content">
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<h2 style="color: var(--efintel-petroleum); margin-bottom: 0.25rem;">Configuración General del Sistema</h2>
|
<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>
|
||||||
<div style="max-width: 500px; display: flex; flex-direction: column; gap: 1.5rem;">
|
<div style="max-width: 600px; display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
|
||||||
<div class="data-card" style="padding: 2rem;">
|
<div class="data-card" style="padding: 1.5rem;">
|
||||||
<div style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem;">Presupuesto General Asignado</div>
|
<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.5rem; font-weight: 700; color: var(--efintel-petroleum);">${{ settings.global_budget|floatformat:"0"|intcomma|default:"100000" }}</div>
|
<div style="font-size: 2.2rem; font-weight: 700; color: var(--efintel-petroleum);">{{ settings.iva_rate|default:"19.00" }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="data-card" style="padding: 1.5rem;">
|
||||||
<a href="{% url 'settings_edit' %}" class="btn-primary" style="text-decoration: none;">Modificar Presupuesto General</a>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Pestaña Bitácora -->
|
<!-- Pestaña Bitácora -->
|
||||||
|
|||||||
@@ -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;">← 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 %}
|
||||||
@@ -11,39 +11,71 @@
|
|||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% 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">→ 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 style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
||||||
<div class="form-group">
|
<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 }}
|
{{ form.code }}
|
||||||
{% if form.code.errors %}
|
{% if form.code.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.code.errors }}</div>{% endif %}
|
||||||
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.code.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<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>
|
<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 }}
|
{{ form.name }}
|
||||||
{% if form.name.errors %}
|
{% if form.name.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.name.errors }}</div>{% endif %}
|
||||||
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.name.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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 }}
|
{{ form.type }}
|
||||||
{% if form.type.errors %}
|
{% if form.type.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.type.errors }}</div>{% endif %}
|
||||||
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.type.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</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">
|
<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>
|
<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 }}
|
{{ form.assigned_budget_clp }}
|
||||||
{% if form.assigned_budget_clp.errors %}
|
{% 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 style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.assigned_budget_clp.errors }}</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,48 +83,25 @@
|
|||||||
<div class="form-group">
|
<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>
|
<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 }}
|
{{ form.category }}
|
||||||
{% if form.category.errors %}
|
{% if form.category.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>{% endif %}
|
||||||
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<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>
|
<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 }}
|
{{ form.parent }}
|
||||||
{% if form.parent.errors %}
|
{% if form.parent.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.parent.errors }}</div>{% endif %}
|
||||||
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.parent.errors }}</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
<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 }}
|
{{ form.description }}
|
||||||
{% if form.description.errors %}
|
{% if form.description.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>{% endif %}
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
|
<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>
|
<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 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
|
<a href="{% url 'project_list' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,47 +2,122 @@
|
|||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="view-title">
|
||||||
<h2>Proyectos</h2>
|
<h2 style="margin:0; color:var(--efintel-petroleum); display:flex; align-items:center; gap:0.5rem;">
|
||||||
<p>Monitoreo de Centros de Costo</p>
|
<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>
|
</div>
|
||||||
<a href="{% url 'project_create' %}" class="btn-primary">Nuevo Proyecto / Centro</a>
|
|
||||||
</div>
|
</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 %}
|
{% for project in projects %}
|
||||||
<div class="glass-card project-card">
|
<div class="data-card project-card" style="padding: 1.5rem; display:flex; flex-direction:column; justify-content:space-between; position:relative; background:white;">
|
||||||
<div class="project-header">
|
<div>
|
||||||
<div class="project-title">{{ project.name }}</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>
|
||||||
<div style="color: var(--text-secondary); font-size: 0.9rem;">
|
<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;">
|
||||||
<strong>Gestor:</strong> {{ project.manager.username|default:"No asignado" }}
|
{{ project.get_status_display }}
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between;">
|
|
||||||
<span style="font-size: 0.8rem; color: var(--efintel-gray);">Costo Real Acumulado</span>
|
<div style="color: var(--text-secondary); font-size: 0.8rem; margin-bottom: 1rem;">
|
||||||
<span style="font-weight: 700; font-family: var(--font-mono); color: var(--efintel-danger);">
|
<strong>Director:</strong> {{ project.manager.username|default:"No asignado" }} |
|
||||||
${{ project.actual_cost_clp|floatformat:"0"|intcomma }}
|
<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>
|
</span>
|
||||||
</div>
|
</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>
|
</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 →</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="glass-card" style="padding: 2rem; text-align: center; grid-column: 1 / -1;">
|
<div class="data-card" style="padding: 2rem; text-align: center; grid-column: 1 / -1;">
|
||||||
No hay proyectos registrados.
|
No hay proyectos registrados en este perfil.
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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 %}
|
||||||
@@ -8,11 +8,20 @@
|
|||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom:1.5rem;">
|
||||||
<label for="{{ form.global_budget.id_for_label }}">Presupuesto General Asignado ($)</label>
|
<label for="{{ form.iva_rate.id_for_label }}">Tasa de IVA (% Dinámico Configurable)</label>
|
||||||
{{ form.global_budget }}
|
{{ form.iva_rate }}
|
||||||
{% if form.global_budget.errors %}
|
{% if form.iva_rate.errors %}
|
||||||
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.global_budget.errors }}</div>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,30 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container" style="max-width: 800px; margin: 0 auto;">
|
<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);">
|
<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>
|
<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>
|
</h2>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" id="vendor-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<h3 style="margin-bottom: 1rem; color: var(--efintel-dark); font-size: 1rem;">Información General</h3>
|
<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 style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
||||||
<div class="form-group">
|
<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>
|
<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 }}
|
{{ 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 %}
|
{% if form.rut.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.rut.errors }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
|
<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>
|
<a href="{% url 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -81,5 +105,99 @@
|
|||||||
document.querySelectorAll('input, select, textarea').forEach(el => {
|
document.querySelectorAll('input, select, textarea').forEach(el => {
|
||||||
el.classList.add('form-control');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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))
|
||||||
Reference in New Issue
Block a user