iteracion 2

This commit is contained in:
Nachou-u
2026-09-07 18:44:32 -03:00
parent a103a47ae6
commit 09a3f6fb37
93 changed files with 7394 additions and 349 deletions
+1
View File
@@ -0,0 +1 @@
# Payroll app
+19
View File
@@ -0,0 +1,19 @@
from django.contrib import admin
from .models import EmployeeProfile, SalarySlip, PayrollCentralizationEntry
@admin.register(EmployeeProfile)
class EmployeeProfileAdmin(admin.ModelAdmin):
list_display = ('rut', 'first_name', 'last_name', 'position', 'contract_type', 'work_schedule', 'base_salary_clp', 'afp', 'health_system', 'is_active')
search_fields = ('rut', 'first_name', 'last_name', 'position')
list_filter = ('contract_type', 'work_schedule', 'afp', 'health_system', 'is_active')
@admin.register(SalarySlip)
class SalarySlipAdmin(admin.ModelAdmin):
list_display = ('employee', 'month', 'year', 'total_gross_income', 'net_salary', 'total_company_cost', 'status', 'signed_at')
search_fields = ('employee__rut', 'employee__first_name', 'employee__last_name')
list_filter = ('year', 'month', 'status')
@admin.register(PayrollCentralizationEntry)
class PayrollCentralizationEntryAdmin(admin.ModelAdmin):
list_display = ('month', 'year', 'account_code', 'total_debit', 'total_credit', 'is_balanced', 'status')
list_filter = ('year', 'month', 'is_balanced')
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PayrollConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'payroll'
verbose_name = 'Remuneraciones y RRHH (BUK)'
+92
View File
@@ -0,0 +1,92 @@
from django import forms
from .models import EmployeeProfile, SalarySlip, PayrollCentralizationEntry
from decimal import Decimal
class EmployeeProfileForm(forms.ModelForm):
hire_date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
label='Fecha de Contratación'
)
base_salary_clp = forms.DecimalField(
label='Sueldo Base Mensual (CLP)',
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0', 'required': True})
)
colacion_clp = forms.DecimalField(
label='Asignación Colación (CLP)',
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0'})
)
movilizacion_clp = forms.DecimalField(
label='Asignación Movilización (CLP)',
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0'})
)
class Meta:
model = EmployeeProfile
fields = [
'rut', 'first_name', 'last_name', 'email', 'phone', 'address',
'position', 'department', 'contract_type', 'work_schedule', 'hire_date',
'afp', 'afp_rate', 'health_system', 'health_institution', 'health_plan_uf',
'has_afc', 'base_salary_clp', 'colacion_clp', 'movilizacion_clp',
'bank_name', 'account_type', 'account_number', 'is_active'
]
widgets = {
'rut': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Ej: 12.345.678-9', 'pattern': r'^(\d{7,8}-[\dkK])$', 'required': True, 'title': 'RUT sin puntos y con guion'}),
'first_name': forms.TextInput(attrs={'class': 'form-control', 'required': True, 'minlength': '2'}),
'last_name': forms.TextInput(attrs={'class': 'form-control', 'required': True, 'minlength': '2'}),
'email': forms.EmailInput(attrs={'class': 'form-control', 'required': True}),
'phone': forms.TextInput(attrs={'class': 'form-control', 'pattern': r'^\+?\d{9,15}$', 'title': 'Teléfono válido (ej: +56912345678)'}),
'address': forms.TextInput(attrs={'class': 'form-control'}),
'position': forms.TextInput(attrs={'class': 'form-control', 'required': True}),
'department': forms.TextInput(attrs={'class': 'form-control', 'required': True}),
'contract_type': forms.Select(attrs={'class': 'form-control', 'required': True}),
'work_schedule': forms.Select(attrs={'class': 'form-control', 'required': True}),
'afp': forms.Select(attrs={'class': 'form-control', 'required': True}),
'afp_rate': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0', 'max': '100', 'required': True}),
'health_system': forms.Select(attrs={'class': 'form-control', 'required': True}),
'health_institution': forms.TextInput(attrs={'class': 'form-control'}),
'health_plan_uf': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.0001', 'min': '0'}),
'bank_name': forms.TextInput(attrs={'class': 'form-control', 'required': True}),
'account_type': forms.Select(attrs={'class': 'form-control', 'required': True}),
'account_number': forms.TextInput(attrs={'class': 'form-control', 'required': True, 'pattern': r'^\d+$', 'title': 'Solo números'}),
}
class SalarySlipGenerateForm(forms.Form):
month = forms.ChoiceField(
choices=[(i, f"{i:02d} - {m}") for i, m in enumerate([
"Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"
], 1)],
label="Mes a Liquidar",
widget=forms.Select(attrs={'class': 'form-control'})
)
year = forms.IntegerField(
initial=2026,
label="Año",
widget=forms.NumberInput(attrs={'class': 'form-control'})
)
employee = forms.ModelChoiceField(
queryset=EmployeeProfile.objects.filter(is_active=True),
required=False,
empty_label="--- Todos los colaboradores activos (Cálculo Masivo) ---",
label="Colaborador Específico",
widget=forms.Select(attrs={'class': 'form-control'})
)
overtime_hours = forms.DecimalField(
initial=Decimal('0'),
required=False,
label="Horas Extras 50% (Aplica a cálculo individual)",
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '0.5', 'min': '0'})
)
rd_bonus = forms.DecimalField(
initial=Decimal('0'),
required=False,
label="Bono I+D+i / Desempeño (CLP)",
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '10000', 'min': '0'})
)
other_deductions = forms.DecimalField(
initial=Decimal('0'),
required=False,
label="Otros Descuentos / Anticipos (CLP)",
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': '1000', 'min': '0'})
)
+133
View File
@@ -0,0 +1,133 @@
# Generated by Django 6.1 on 2026-09-07 02:36
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmployeeProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rut', models.CharField(max_length=20, unique=True, verbose_name='RUT Trabajador')),
('first_name', models.CharField(max_length=100, verbose_name='Nombres')),
('last_name', models.CharField(max_length=100, verbose_name='Apellidos')),
('email', models.EmailField(max_length=254, verbose_name='Correo Electrónico')),
('phone', models.CharField(blank=True, max_length=30, verbose_name='Teléfono Móvil')),
('address', models.CharField(blank=True, max_length=255, verbose_name='Dirección Particular')),
('position', models.CharField(default='Ingeniero de Desarrollo', max_length=150, verbose_name='Cargo / Especialidad I+D+i')),
('department', models.CharField(default='I+D+i y Operaciones', max_length=100, verbose_name='Departamento / Área')),
('contract_type', models.CharField(choices=[('INDEFINIDO', 'Contrato Indefinido'), ('PLAZO_FIJO', 'Contrato a Plazo Fijo'), ('HONORARIOS', 'Prestación de Servicios (Honorarios)'), ('PRACTICA', 'Práctica Profesional')], default='INDEFINIDO', max_length=50, verbose_name='Tipo de Contrato')),
('work_schedule', models.CharField(choices=[('40H_LEY_21561', '40 Horas Semanales (Ley 21.561)'), ('44H', '44 Horas Semanales'), ('45H', '45 Horas Semanales (Antiguo)'), ('30H_PART_TIME', 'Part-time 30 Horas'), ('20H_PART_TIME', 'Part-time 20 Horas'), ('ART_22_INC_2', 'Sin Fiscalización Superior (Art. 22 Inc. 2)')], default='40H_LEY_21561', max_length=50, verbose_name='Jornada Laboral')),
('hire_date', models.DateField(verbose_name='Fecha de Contratación')),
('afp', models.CharField(choices=[('HABITAT', 'AFP Habitat (11.27%)'), ('CUPRUM', 'AFP Cuprum (11.44%)'), ('MODELO', 'AFP Modelo (10.58%)'), ('PLANVITAL', 'AFP PlanVital (11.16%)'), ('PROVIDA', 'AFP ProVida (11.45%)'), ('CAPITAL', 'AFP Capital (11.44%)'), ('UNO', 'AFP Uno (10.49%)')], default='MODELO', max_length=50, verbose_name='AFP Previsional')),
('afp_rate', models.DecimalField(decimal_places=2, default=10.58, max_digits=5, verbose_name='Tasa Cotización AFP (%)')),
('health_system', models.CharField(choices=[('FONASA', 'FONASA (7.00%)'), ('ISAPRE', 'ISAPRE (Pactado UF / %)')], default='FONASA', max_length=50, verbose_name='Sistema de Salud')),
('health_institution', models.CharField(blank=True, default='FONASA', max_length=100, verbose_name='Institución Salud (FONASA / Isapre)')),
('health_plan_uf', models.DecimalField(decimal_places=4, default=0, max_digits=6, verbose_name='Plan Salud Pactado (UF - solo Isapre)')),
('health_rate', models.DecimalField(decimal_places=2, default=7.0, max_digits=5, verbose_name='Tasa Legal Salud (%)')),
('has_afc', models.BooleanField(default=True, verbose_name='Afecto a Seguro de Cesantía (AFC)')),
('base_salary_clp', models.DecimalField(decimal_places=2, default=1500000, max_digits=12, verbose_name='Sueldo Base Mensual (CLP)')),
('colacion_clp', models.DecimalField(decimal_places=2, default=65000, max_digits=10, verbose_name='Asignación Colación (No Imponible)')),
('movilizacion_clp', models.DecimalField(decimal_places=2, default=65000, max_digits=10, verbose_name='Asignación Movilización (No Imponible)')),
('bank_name', models.CharField(default='Banco Santander-Chile', max_length=100, verbose_name='Banco Receptor')),
('account_type', models.CharField(choices=[('CORRIENTE', 'Cuenta Corriente'), ('VISTA_RUT', 'Cuenta Vista / Cuenta RUT'), ('AHORRO', 'Cuenta de Ahorro')], default='CORRIENTE', max_length=50, verbose_name='Tipo de Cuenta')),
('account_number', models.CharField(default='00000000', max_length=50, verbose_name='Número de Cuenta Bancaria')),
('is_active', models.BooleanField(default=True, verbose_name='Trabajador Activo')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employee_profile', to=settings.AUTH_USER_MODEL, verbose_name='Usuario del Sistema')),
],
options={
'verbose_name': 'Perfil de Colaborador',
'verbose_name_plural': 'Nómina de Colaboradores',
'ordering': ['last_name', 'first_name'],
},
),
migrations.CreateModel(
name='PayrollCentralizationEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('month', models.IntegerField(verbose_name='Mes')),
('year', models.IntegerField(verbose_name='Año')),
('entry_date', models.DateField(verbose_name='Fecha del Asiento')),
('glosa', models.CharField(default='Centralización de Remuneraciones y Leyes Sociales', max_length=255, verbose_name='Glosa Contable')),
('account_code', models.CharField(default='5101', max_length=50, verbose_name='Cuenta Contable Débito (5101)')),
('account_name', models.CharField(default='Gastos en Personal y Cargas Sociales I+D+i', max_length=150, verbose_name='Nombre Cuenta Débito')),
('total_taxable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Haberes Imponibles')),
('total_non_taxable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Haberes No Imponibles')),
('total_employer_charges', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Cargas Sociales Empleador (SIS/ACHS/AFC)')),
('total_net_payable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Sueldos Líquidos por Pagar (Banco)')),
('total_previred_payable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Cotizaciones por Pagar (Previred)')),
('total_sii_payable', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Retención Impuesto Único SII')),
('total_debit', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Debe (CLP)')),
('total_credit', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Total Haber (CLP)')),
('is_balanced', models.BooleanField(default=True, verbose_name='Asiento Cuadrado (Debe == Haber)')),
('status', models.CharField(default='CONTABILIZADO', max_length=30, verbose_name='Estado Asiento')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Asiento de Centralización de Remuneraciones',
'verbose_name_plural': 'Asientos de Centralización (Cuenta 5101)',
'ordering': ['-year', '-month'],
'unique_together': {('month', 'year')},
},
),
migrations.CreateModel(
name='SalarySlip',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slip_uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('month', models.IntegerField(verbose_name='Mes (1-12)')),
('year', models.IntegerField(verbose_name='Año (ej. 2026)')),
('issue_date', models.DateField(verbose_name='Fecha de Emisión')),
('days_worked', models.IntegerField(default=30, verbose_name='Días Trabajados')),
('base_salary', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Sueldo Base')),
('overtime_hours_50', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Horas Extras (50%)')),
('overtime_amount_50', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Monto Horas Extras 50%')),
('legal_gratification', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Gratificación Legal (Art. 50)')),
('rd_bonus', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Bonos de Rendimiento I+D+i')),
('other_taxable', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Otros Haberes Imponibles')),
('total_taxable_income', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Haberes Imponibles')),
('allowance_lunch', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Asignación Colación')),
('allowance_commute', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Asignación Movilización')),
('expense_reimbursements', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Reembolsos de Gastos en Terreno')),
('total_non_taxable_income', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Total Haberes No Imponibles')),
('total_gross_income', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Haberes (Bruto)')),
('afp_deduction', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Cotización Previsional AFP')),
('health_deduction', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Cotización Legal Salud (7% o Isapre)')),
('afc_deduction', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Seguro de Cesantía Trabajador (AFC 0.6%)')),
('second_category_tax', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Impuesto Único 2da Categoría SII')),
('other_deductions', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Otros Descuentos / Anticipos')),
('total_deductions', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Total Descuentos')),
('net_salary', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Sueldo Líquido a Pagar')),
('employer_sis', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Seguro Invalidez y Sobrevivencia SIS (1.49%)')),
('employer_mutual', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Aporte Mutualidad / ACHS (0.93%)')),
('employer_afc', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Seguro Cesantía Empleador AFC (2.4%)')),
('total_company_cost', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Costo Total Empresa')),
('status', models.CharField(choices=[('BORRADOR', 'Borrador'), ('EMITIDA', 'Emitida (Pendiente de Firma)'), ('FIRMADA', 'Firmada Digitalmente'), ('PAGADA', 'Pagada / Transferida'), ('ANULADA', 'Anulada')], default='BORRADOR', max_length=30, verbose_name='Estado de Liquidación')),
('digital_signature_hash', models.CharField(blank=True, max_length=128, verbose_name='Hash de Firma Digital (SHA-256)')),
('signed_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha y Hora de Firma')),
('notes', models.TextField(blank=True, verbose_name='Observaciones')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='salary_slips', to='payroll.employeeprofile', verbose_name='Colaborador')),
],
options={
'verbose_name': 'Liquidación de Sueldo',
'verbose_name_plural': 'Liquidaciones de Sueldo (Buk)',
'ordering': ['-year', '-month', 'employee__last_name'],
'unique_together': {('employee', 'month', 'year')},
},
),
]
+1
View File
@@ -0,0 +1 @@
# Payroll migrations package
+213
View File
@@ -0,0 +1,213 @@
from django.db import models
from django.contrib.auth.models import User
from decimal import Decimal
import uuid
class ContractTypeEnum(models.TextChoices):
INDEFINIDO = 'INDEFINIDO', 'Contrato Indefinido'
PLAZO_FIJO = 'PLAZO_FIJO', 'Contrato a Plazo Fijo'
HONORARIOS = 'HONORARIOS', 'Prestación de Servicios (Honorarios)'
PRACTICA = 'PRACTICA', 'Práctica Profesional'
class WorkScheduleEnum(models.TextChoices):
LEY_21561_40H = '40H_LEY_21561', '40 Horas Semanales (Ley 21.561)'
REGULAR_44H = '44H', '44 Horas Semanales'
REGULAR_45H = '45H', '45 Horas Semanales (Antiguo)'
PART_TIME_30H = '30H_PART_TIME', 'Part-time 30 Horas'
PART_TIME_20H = '20H_PART_TIME', 'Part-time 20 Horas'
ART_22 = 'ART_22_INC_2', 'Sin Fiscalización Superior (Art. 22 Inc. 2)'
class AFPEnum(models.TextChoices):
HABITAT = 'HABITAT', 'AFP Habitat (11.27%)'
CUPRUM = 'CUPRUM', 'AFP Cuprum (11.44%)'
MODELO = 'MODELO', 'AFP Modelo (10.58%)'
PLANVITAL = 'PLANVITAL', 'AFP PlanVital (11.16%)'
PROVIDA = 'PROVIDA', 'AFP ProVida (11.45%)'
CAPITAL = 'CAPITAL', 'AFP Capital (11.44%)'
UNO = 'UNO', 'AFP Uno (10.49%)'
class HealthSystemEnum(models.TextChoices):
FONASA = 'FONASA', 'FONASA (7.00%)'
ISAPRE = 'ISAPRE', 'ISAPRE (Pactado UF / %)'
class BankAccountTypeEnum(models.TextChoices):
CORRIENTE = 'CORRIENTE', 'Cuenta Corriente'
VISTA_RUT = 'VISTA_RUT', 'Cuenta Vista / Cuenta RUT'
AHORRO = 'AHORRO', 'Cuenta de Ahorro'
class SalarySlipStatusEnum(models.TextChoices):
BORRADOR = 'BORRADOR', 'Borrador'
EMITIDA = 'EMITIDA', 'Emitida (Pendiente de Firma)'
FIRMADA = 'FIRMADA', 'Firmada Digitalmente'
PAGADA = 'PAGADA', 'Pagada / Transferida'
ANULADA = 'ANULADA', 'Anulada'
class EmployeeProfile(models.Model):
"""
Ficha de Empleado / Colaborador conforme a la legislación laboral chilena (Ley 21.561 40h).
"""
user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='employee_profile', verbose_name="Usuario del Sistema")
rut = models.CharField(max_length=20, unique=True, verbose_name="RUT Trabajador")
first_name = models.CharField(max_length=100, verbose_name="Nombres")
last_name = models.CharField(max_length=100, verbose_name="Apellidos")
email = models.EmailField(verbose_name="Correo Electrónico")
phone = models.CharField(max_length=30, blank=True, verbose_name="Teléfono Móvil")
address = models.CharField(max_length=255, blank=True, verbose_name="Dirección Particular")
position = models.CharField(max_length=150, verbose_name="Cargo / Especialidad I+D+i", default="Ingeniero de Desarrollo")
department = models.CharField(max_length=100, verbose_name="Departamento / Área", default="I+D+i y Operaciones")
contract_type = models.CharField(max_length=50, choices=ContractTypeEnum.choices, default=ContractTypeEnum.INDEFINIDO, verbose_name="Tipo de Contrato")
work_schedule = models.CharField(max_length=50, choices=WorkScheduleEnum.choices, default=WorkScheduleEnum.LEY_21561_40H, verbose_name="Jornada Laboral")
hire_date = models.DateField(verbose_name="Fecha de Contratación")
# Previsional
afp = models.CharField(max_length=50, choices=AFPEnum.choices, default=AFPEnum.MODELO, verbose_name="AFP Previsional")
afp_rate = models.DecimalField(max_digits=5, decimal_places=2, default=10.58, verbose_name="Tasa Cotización AFP (%)")
health_system = models.CharField(max_length=50, choices=HealthSystemEnum.choices, default=HealthSystemEnum.FONASA, verbose_name="Sistema de Salud")
health_institution = models.CharField(max_length=100, blank=True, default="FONASA", verbose_name="Institución Salud (FONASA / Isapre)")
health_plan_uf = models.DecimalField(max_digits=6, decimal_places=4, default=0, verbose_name="Plan Salud Pactado (UF - solo Isapre)")
health_rate = models.DecimalField(max_digits=5, decimal_places=2, default=7.00, verbose_name="Tasa Legal Salud (%)")
has_afc = models.BooleanField(default=True, verbose_name="Afecto a Seguro de Cesantía (AFC)")
# Remuneración Pactada
base_salary_clp = models.DecimalField(max_digits=12, decimal_places=2, default=1500000, verbose_name="Sueldo Base Mensual (CLP)")
colacion_clp = models.DecimalField(max_digits=10, decimal_places=2, default=65000, verbose_name="Asignación Colación (No Imponible)")
movilizacion_clp = models.DecimalField(max_digits=10, decimal_places=2, default=65000, verbose_name="Asignación Movilización (No Imponible)")
# Datos Bancarios para Pago de Nómina
bank_name = models.CharField(max_length=100, default="Banco Santander-Chile", verbose_name="Banco Receptor")
account_type = models.CharField(max_length=50, choices=BankAccountTypeEnum.choices, default=BankAccountTypeEnum.CORRIENTE, verbose_name="Tipo de Cuenta")
account_number = models.CharField(max_length=50, default="00000000", verbose_name="Número de Cuenta Bancaria")
is_active = models.BooleanField(default=True, verbose_name="Trabajador Activo")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.rut} - {self.first_name} {self.last_name} ({self.position})"
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
class Meta:
verbose_name = "Perfil de Colaborador"
verbose_name_plural = "Nómina de Colaboradores"
ordering = ['last_name', 'first_name']
class SalarySlip(models.Model):
"""
Liquidación de Sueldo Digital (Estilo Buk).
Calcula desglose analítico de Haberes Imponibles/No Imponibles, Descuentos Legales,
Impuesto Único de Segunda Categoría SII y Aportes Patronales (SIS, Mutual, AFC).
"""
slip_uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
employee = models.ForeignKey(EmployeeProfile, on_delete=models.CASCADE, related_name='salary_slips', verbose_name="Colaborador")
month = models.IntegerField(verbose_name="Mes (1-12)")
year = models.IntegerField(verbose_name="Año (ej. 2026)")
issue_date = models.DateField(verbose_name="Fecha de Emisión")
days_worked = models.IntegerField(default=30, verbose_name="Días Trabajados")
# 1. Haberes Imponibles
base_salary = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Sueldo Base")
overtime_hours_50 = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name="Horas Extras (50%)")
overtime_amount_50 = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Monto Horas Extras 50%")
legal_gratification = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Gratificación Legal (Art. 50)")
rd_bonus = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name="Bonos de Rendimiento I+D+i")
other_taxable = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Otros Haberes Imponibles")
total_taxable_income = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Total Haberes Imponibles")
# 2. Haberes No Imponibles
allowance_lunch = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Asignación Colación")
allowance_commute = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Asignación Movilización")
expense_reimbursements = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Reembolsos de Gastos en Terreno")
total_non_taxable_income = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Total Haberes No Imponibles")
total_gross_income = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Total Haberes (Bruto)")
# 3. Descuentos Previsionales e Impositivos
afp_deduction = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Cotización Previsional AFP")
health_deduction = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Cotización Legal Salud (7% o Isapre)")
afc_deduction = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Seguro de Cesantía Trabajador (AFC 0.6%)")
second_category_tax = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Impuesto Único 2da Categoría SII")
other_deductions = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Otros Descuentos / Anticipos")
total_deductions = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Total Descuentos")
# 4. Líquido a Pagar
net_salary = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Sueldo Líquido a Pagar")
# 5. Cargas Sociales del Empleador (Costo Empresa)
employer_sis = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Seguro Invalidez y Sobrevivencia SIS (1.49%)")
employer_mutual = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Aporte Mutualidad / ACHS (0.93%)")
employer_afc = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Seguro Cesantía Empleador AFC (2.4%)")
total_company_cost = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Costo Total Empresa")
# 6. Workflow y Firma Digital
status = models.CharField(max_length=30, choices=SalarySlipStatusEnum.choices, default=SalarySlipStatusEnum.BORRADOR, verbose_name="Estado de Liquidación")
digital_signature_hash = models.CharField(max_length=128, blank=True, verbose_name="Hash de Firma Digital (SHA-256)")
signed_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha y Hora de Firma")
notes = models.TextField(blank=True, verbose_name="Observaciones")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Liquidación {self.month:02d}/{self.year} - {self.employee.full_name} (${self.net_salary:,.0f} CLP)"
@property
def period_display(self):
months = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]
m_name = months[self.month - 1] if 1 <= self.month <= 12 else str(self.month)
return f"{m_name} {self.year}"
class Meta:
verbose_name = "Liquidación de Sueldo"
verbose_name_plural = "Liquidaciones de Sueldo (Buk)"
unique_together = ('employee', 'month', 'year')
ordering = ['-year', '-month', 'employee__last_name']
class PayrollCentralizationEntry(models.Model):
"""
Asiento Contable de Centralización de Remuneraciones.
Imputa a Cuenta 5101: Remuneraciones y Cargas Sociales I+D+i.
Valida cuadratura Débitos = Créditos.
"""
month = models.IntegerField(verbose_name="Mes")
year = models.IntegerField(verbose_name="Año")
entry_date = models.DateField(verbose_name="Fecha del Asiento")
glosa = models.CharField(max_length=255, verbose_name="Glosa Contable", default="Centralización de Remuneraciones y Leyes Sociales")
account_code = models.CharField(max_length=50, default="5101", verbose_name="Cuenta Contable Débito (5101)")
account_name = models.CharField(max_length=150, default="Gastos en Personal y Cargas Sociales I+D+i", verbose_name="Nombre Cuenta Débito")
# Componentes del Asiento
total_taxable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Haberes Imponibles")
total_non_taxable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Haberes No Imponibles")
total_employer_charges = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Cargas Sociales Empleador (SIS/ACHS/AFC)")
total_net_payable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Sueldos Líquidos por Pagar (Banco)")
total_previred_payable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Cotizaciones por Pagar (Previred)")
total_sii_payable = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Retención Impuesto Único SII")
total_debit = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Debe (CLP)")
total_credit = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Total Haber (CLP)")
is_balanced = models.BooleanField(default=True, verbose_name="Asiento Cuadrado (Debe == Haber)")
status = models.CharField(max_length=30, default="CONTABILIZADO", verbose_name="Estado Asiento")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Asiento Centralización {self.month:02d}/{self.year} - {self.account_code}: {self.total_debit:,.0f} CLP"
class Meta:
verbose_name = "Asiento de Centralización de Remuneraciones"
verbose_name_plural = "Asientos de Centralización (Cuenta 5101)"
unique_together = ('month', 'year')
ordering = ['-year', '-month']
+3
View File
@@ -0,0 +1,3 @@
from .engine import PayrollEngine, BankExportService, PreviredExportService, LREExportService
__all__ = ['PayrollEngine', 'BankExportService', 'PreviredExportService', 'LREExportService']
+352
View File
@@ -0,0 +1,352 @@
"""
Payroll Engine and Legal Exporters for Chilean Labor Regulation (Ley 21.561 40h, Previred, DT LRE, Banking).
"""
from decimal import Decimal, ROUND_HALF_UP
import hashlib
from datetime import datetime, date
from typing import Dict, List, Any
import io
import csv
class PayrollEngine:
# 2026 Benchmark Economic Indicators (Chile)
IMM_2026 = Decimal('500000') # Ingreso Mínimo Mensual
UTM_2026 = Decimal('67000') # Unidad Tributaria Mensual
UF_2026 = Decimal('38500') # Unidad de Fomento
# Previsional Caps in UF
AFP_HEALTH_CAP_UF = Decimal('84.3') # Tope Imponible AFP y Salud (84.3 UF)
AFC_CAP_UF = Decimal('126.6') # Tope Imponible Seguro de Cesantía (126.6 UF)
# Employer Rates
SIS_RATE = Decimal('0.0149') # Seguro de Invalidez y Sobrevivencia (1.49%)
MUTUAL_RATE = Decimal('0.0093') # Mutual de Seguridad / ACHS (0.93% base)
AFC_EMPLOYER_INDEF = Decimal('0.024') # AFC Empleador Contrato Indefinido (2.4%)
AFC_EMPLOYER_FIJO = Decimal('0.030') # AFC Empleador Contrato Plazo Fijo (3.0%)
AFC_WORKER_RATE = Decimal('0.006') # AFC Trabajador Contrato Indefinido (0.6%)
@classmethod
def calculate_overtime(cls, base_salary: Decimal, weekly_hours: int, overtime_hours: Decimal) -> Decimal:
"""
Calculates 50% overtime pay based on weekly hours (Ley 21.561 40h vs 44h vs 45h).
Formula: (BaseSalary / WeeklyHours) * (7 / 30) * 1.5 * OvertimeHours
For 40 hours: Factor = (1/40) * (7/30) * 1.5 = 0.00875
"""
if overtime_hours <= 0 or base_salary <= 0:
return Decimal('0')
hours = Decimal(str(weekly_hours if weekly_hours > 0 else 40))
factor = (Decimal('1') / hours) * (Decimal('7') / Decimal('30')) * Decimal('1.5')
amount = base_salary * factor * overtime_hours
return amount.quantize(Decimal('1'), rounding=ROUND_HALF_UP)
@classmethod
def calculate_legal_gratification(cls, base_salary: Decimal, overtime_amount: Decimal = Decimal('0'), rd_bonus: Decimal = Decimal('0')) -> Decimal:
"""
Calculates Legal Gratification under Art. 50 Código del Trabajo:
25% of taxable earnings capped at (4.75 * IMM) / 12 per month.
"""
monthly_cap = ((Decimal('4.75') * cls.IMM_2026) / Decimal('12')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
earnings = (base_salary + overtime_amount + rd_bonus) * Decimal('0.25')
return min(earnings.quantize(Decimal('1'), rounding=ROUND_HALF_UP), monthly_cap)
@classmethod
def calculate_second_category_tax(cls, taxable_tax_base: Decimal) -> Decimal:
"""
Calculates Chilean Second Category Income Tax (Impuesto Único de Segunda Categoría SII).
Taxable Tax Base = Total Taxable Income - Previsional Deductions (AFP + 7% Health + AFC).
"""
if taxable_tax_base <= Decimal('0'):
return Decimal('0')
utm = cls.UTM_2026
base_in_utm = taxable_tax_base / utm
# Official SII Brackets
# Tramo 1: 0 - 13.5 UTM -> Exento
if base_in_utm <= Decimal('13.5'):
return Decimal('0')
# Tramo 2: 13.5 - 30 UTM -> 4% (rebaja 0.54 UTM)
elif base_in_utm <= Decimal('30'):
tax = (taxable_tax_base * Decimal('0.04')) - (Decimal('0.54') * utm)
# Tramo 3: 30 - 50 UTM -> 8% (rebaja 1.74 UTM)
elif base_in_utm <= Decimal('50'):
tax = (taxable_tax_base * Decimal('0.08')) - (Decimal('1.74') * utm)
# Tramo 4: 50 - 70 UTM -> 13.5% (rebaja 4.49 UTM)
elif base_in_utm <= Decimal('70'):
tax = (taxable_tax_base * Decimal('0.135')) - (Decimal('4.49') * utm)
# Tramo 5: 70 - 90 UTM -> 23% (rebaja 11.14 UTM)
elif base_in_utm <= Decimal('90'):
tax = (taxable_tax_base * Decimal('0.23')) - (Decimal('11.14') * utm)
# Tramo 6: 90 - 120 UTM -> 30.4% (rebaja 17.80 UTM)
elif base_in_utm <= Decimal('120'):
tax = (taxable_tax_base * Decimal('0.304')) - (Decimal('17.80') * utm)
# Tramo 7: 120 - 310 UTM -> 35% (rebaja 23.32 UTM)
elif base_in_utm <= Decimal('310'):
tax = (taxable_tax_base * Decimal('0.35')) - (Decimal('23.32') * utm)
# Tramo 8: > 310 UTM -> 40% (rebaja 38.82 UTM)
else:
tax = (taxable_tax_base * Decimal('0.40')) - (Decimal('38.82') * utm)
return max(Decimal('0'), tax.quantize(Decimal('1'), rounding=ROUND_HALF_UP))
@classmethod
def compute_salary_slip(cls, employee, month: int, year: int, days_worked: int = 30,
overtime_hours: Decimal = Decimal('0'), rd_bonus: Decimal = Decimal('0'),
other_taxable: Decimal = Decimal('0'), other_deductions: Decimal = Decimal('0')) -> Dict[str, Any]:
"""
Executes complete salary slip calculation according to Chilean Labor and Tax code.
"""
base_salary_pactado = Decimal(str(employee.base_salary_clp))
days = Decimal(str(days_worked))
# Base salary proportional to days worked (standard 30 days)
base_salary_period = (base_salary_pactado * (days / Decimal('30'))).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
# Weekly hours determination
schedule_hours = 40
if employee.work_schedule == '44H':
schedule_hours = 44
elif employee.work_schedule == '45H':
schedule_hours = 45
elif employee.work_schedule == '30H_PART_TIME':
schedule_hours = 30
elif employee.work_schedule == '20H_PART_TIME':
schedule_hours = 20
# Overtime
overtime_amount = cls.calculate_overtime(base_salary_period, schedule_hours, overtime_hours)
# Legal Gratification Art. 50
legal_grat = cls.calculate_legal_gratification(base_salary_period, overtime_amount, rd_bonus)
# Total Taxable
total_taxable = base_salary_period + overtime_amount + legal_grat + rd_bonus + other_taxable
# Non-taxable items
allowance_lunch = Decimal(str(employee.colacion_clp or 0))
allowance_commute = Decimal(str(employee.movilizacion_clp or 0))
total_non_taxable = allowance_lunch + allowance_commute
total_gross = total_taxable + total_non_taxable
# Previsional Caps Application
afp_health_cap_clp = (cls.AFP_HEALTH_CAP_UF * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
afc_cap_clp = (cls.AFC_CAP_UF * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
taxable_for_afp_health = min(total_taxable, afp_health_cap_clp)
taxable_for_afc = min(total_taxable, afc_cap_clp)
# AFP Deduction
afp_rate = Decimal(str(employee.afp_rate or 10.58)) / Decimal('100')
afp_deduction = (taxable_for_afp_health * afp_rate).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
# Health Deduction
if employee.health_system == 'ISAPRE' and employee.health_plan_uf > 0:
pactado_isapre_clp = (Decimal(str(employee.health_plan_uf)) * cls.UF_2026).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
legal_7_pct = (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
health_deduction = max(pactado_isapre_clp, legal_7_pct)
else:
# Fonasa or 7%
health_deduction = (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
# AFC Worker Deduction (0.6% if indefinite and has AFC)
if employee.has_afc and employee.contract_type == 'INDEFINIDO':
afc_deduction = (taxable_for_afc * cls.AFC_WORKER_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
else:
afc_deduction = Decimal('0')
# Tax Base for Second Category Tax
tax_base = total_taxable - afp_deduction - min(health_deduction, (taxable_for_afp_health * Decimal('0.07')).quantize(Decimal('1'))) - afc_deduction
second_cat_tax = cls.calculate_second_category_tax(tax_base)
# Total Deductions
total_deductions = afp_deduction + health_deduction + afc_deduction + second_cat_tax + other_deductions
# Net Salary
net_salary = total_gross - total_deductions
# Employer Charges (Costo Empresa)
employer_sis = (taxable_for_afp_health * cls.SIS_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
employer_mutual = (taxable_for_afp_health * cls.MUTUAL_RATE).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
if employee.has_afc:
if employee.contract_type == 'INDEFINIDO':
employer_afc = (taxable_for_afc * cls.AFC_EMPLOYER_INDEF).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
else:
employer_afc = (taxable_for_afc * cls.AFC_EMPLOYER_FIJO).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
else:
employer_afc = Decimal('0')
total_employer_charges = employer_sis + employer_mutual + employer_afc
total_company_cost = total_gross + total_employer_charges
return {
'month': month,
'year': year,
'days_worked': days_worked,
'base_salary': base_salary_period,
'overtime_hours_50': overtime_hours,
'overtime_amount_50': overtime_amount,
'legal_gratification': legal_grat,
'rd_bonus': rd_bonus,
'other_taxable': other_taxable,
'total_taxable_income': total_taxable,
'allowance_lunch': allowance_lunch,
'allowance_commute': allowance_commute,
'total_non_taxable_income': total_non_taxable,
'total_gross_income': total_gross,
'afp_deduction': afp_deduction,
'health_deduction': health_deduction,
'afc_deduction': afc_deduction,
'second_category_tax': second_cat_tax,
'other_deductions': other_deductions,
'total_deductions': total_deductions,
'net_salary': net_salary,
'employer_sis': employer_sis,
'employer_mutual': employer_mutual,
'employer_afc': employer_afc,
'total_employer_charges': total_employer_charges,
'total_company_cost': total_company_cost,
}
@staticmethod
def generate_signature_hash(slip_id: int, rut: str, net_amount: Decimal, timestamp: datetime) -> str:
"""Generates cryptographic SHA-256 digital signature token for Buk-style slip validation."""
payload = f"EFINTEL-BUK|SLIP:{slip_id}|RUT:{rut}|NET:{net_amount}|TS:{timestamp.isoformat()}"
return hashlib.sha256(payload.encode('utf-8')).hexdigest()
class BankExportService:
"""
Exports payroll disbursements into standard Chilean banking formats (TXT/CSV).
"""
@staticmethod
def export_santander_format(slips: List) -> str:
"""Generates Banco Santander Nómina CSV/TXT."""
output = io.StringIO()
writer = csv.writer(output, delimiter=';')
writer.writerow(['RUT_BENEFICIARIO', 'NOMBRE_BENEFICIARIO', 'BANCO_DESTINO', 'TIPO_CUENTA', 'NUMERO_CUENTA', 'MONTO_CLP', 'GLOSA'])
for slip in slips:
emp = slip.employee
writer.writerow([
emp.rut,
emp.full_name,
emp.bank_name,
emp.get_account_type_display(),
emp.account_number,
int(slip.net_salary),
f"Sueldo {slip.month:02d}/{slip.year} EFINTEL"
])
return output.getvalue()
@staticmethod
def export_banco_chile_txt(slips: List) -> str:
"""Generates Banco de Chile flat file format."""
lines = []
for slip in slips:
emp = slip.employee
clean_rut = emp.rut.replace('.', '').replace('-', '').zfill(10)
clean_name = emp.full_name[:30].ljust(30)
account_num = emp.account_number.replace('-', '')[:18].zfill(18)
amount_str = str(int(slip.net_salary)).zfill(11)
line = f"01{clean_rut}{clean_name}{account_num}{amount_str}001"
lines.append(line)
return "\n".join(lines)
class PreviredExportService:
"""
Generates official Previred 105-fields previsional payment file.
"""
@staticmethod
def generate_previred_105(slips: List, company_rut: str = "77.123.456-7") -> str:
"""
Builds positional/delimited 105-column dataset for bulk previsional declaration at Previred.
"""
output = io.StringIO()
for slip in slips:
emp = slip.employee
rut_parts = emp.rut.replace('.', '').split('-')
rut_num = rut_parts[0].zfill(9) if len(rut_parts) > 0 else "000000000"
rut_dv = rut_parts[1].upper() if len(rut_parts) > 1 else "0"
# Fields 1 to 105 representation
row = [
rut_num, rut_dv, emp.last_name[:30], emp.first_name[:30],
"M" if "M" in emp.rut else "F", # Gen
"056", # Pais
"0", # Tipo trab
"1", # Tipo pago
f"{slip.year}{slip.month:02d}", # Periodo
f"{slip.year}{slip.month:02d}01", # Fecha desde
f"{slip.year}{slip.month:02d}30", # Fecha hasta
str(slip.days_worked), # Dias trab
"0", # Tipo linea
str(int(slip.total_taxable_income)), # Renta imp
str(int(slip.total_taxable_income)), # Renta imp AFP
str(int(slip.afp_deduction)), # Cotiz obligatoria
str(int(slip.employer_sis)), # SIS
emp.afp, # Codigo AFP
"0", # Ahorro voluntario
str(int(slip.total_taxable_income)), # Renta imp Salud
str(int(slip.health_deduction)), # Cotiz Salud 7%
emp.health_system, # Fonasa / Isapre
str(int(slip.employer_mutual)), # Mutualidad ACHS
str(int(slip.total_taxable_income)), # Renta imp AFC
str(int(slip.afc_deduction)), # AFC Trabajador
str(int(slip.employer_afc)), # AFC Empleador
str(int(slip.second_category_tax)), # Impuesto Unico
]
# Fill remaining columns to complete 105 Previred fields standard
while len(row) < 105:
row.append("0")
output.write(";".join(row) + "\n")
return output.getvalue()
class LREExportService:
"""
Exports Libro de Remuneraciones Electrónico (LRE) conforming to Dirección del Trabajo (DT).
"""
@staticmethod
def generate_dt_lre_csv(slips: List) -> str:
output = io.StringIO()
writer = csv.writer(output, delimiter=';')
# DT LRE Header Standard
header = [
'RUT_TRABAJADOR', 'DV_TRABAJADOR', 'APELLIDO_PATERNO', 'APELLIDO_MATERNO', 'NOMBRES',
'SUELDO_BASE_1101', 'SOBRESUELDO_HORAS_EXTRAS_1102', 'GRATIFICACION_LEGAL_1104',
'BONOS_INCENTIVOS_1106', 'TOTAL_HABERES_IMPONIBLES_1100', 'ASIGNACION_COLACION_2101',
'ASIGNACION_MOVILIZACION_2102', 'TOTAL_HABERES_NO_IMPONIBLES_2100', 'TOTAL_HABERES_BRUTO',
'COTIZACION_OBLIGATORIA_AFP_3101', 'SEGURO_INVALIDEZ_SOBREVIVENCIA_SIS', 'COTIZACION_SALUD_3102',
'COTIZACION_SEGURO_CESANTIA_TRABAJADOR_3103', 'IMPUESTO_UNICO_SEGUNDA_CATEGORIA_3104',
'TOTAL_DESCUENTOS_PREVISIONALES_3100', 'TOTAL_DESCUENTOS', 'SUELDO_LIQUIDO_ALCANCE_LIQUIDO',
'APORTE_PATRONAL_MUTUALIDAD', 'APORTE_PATRONAL_AFC', 'COSTO_TOTAL_EMPRESA'
]
writer.writerow(header)
for s in slips:
emp = s.employee
rut_parts = emp.rut.replace('.', '').split('-')
rut_n = rut_parts[0] if len(rut_parts) > 0 else ""
rut_d = rut_parts[1] if len(rut_parts) > 1 else ""
name_parts = emp.last_name.split(' ')
pat = name_parts[0] if len(name_parts) > 0 else emp.last_name
mat = name_parts[1] if len(name_parts) > 1 else ""
writer.writerow([
rut_n, rut_d, pat, mat, emp.first_name,
int(s.base_salary), int(s.overtime_amount_50), int(s.legal_gratification),
int(s.rd_bonus), int(s.total_taxable_income), int(s.allowance_lunch),
int(s.allowance_commute), int(s.total_non_taxable_income), int(s.total_gross_income),
int(s.afp_deduction), int(s.employer_sis), int(s.health_deduction),
int(s.afc_deduction), int(s.second_category_tax),
int(s.afp_deduction + s.health_deduction + s.afc_deduction),
int(s.total_deductions), int(s.net_salary),
int(s.employer_mutual), int(s.employer_afc), int(s.total_company_cost)
])
return output.getvalue()
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path
from . import views
app_name = 'payroll'
urlpatterns = [
path('', views.employee_list, name='payroll_index'),
path('colaboradores/', views.employee_list, name='employee_list'),
path('colaboradores/nuevo/', views.employee_create, name='employee_create'),
path('colaboradores/<int:pk>/editar/', views.employee_edit, name='employee_edit'),
path('liquidaciones/', views.slip_list, name='slip_list'),
path('liquidaciones/generar/', views.slip_generate, name='slip_generate'),
path('liquidaciones/<int:pk>/', views.slip_detail, name='slip_detail'),
path('liquidaciones/<int:pk>/firmar/', views.slip_sign, name='slip_sign'),
path('centralizacion/', views.centralization_view, name='centralization'),
path('exportar/previred/', views.export_previred, name='export_previred'),
path('exportar/lre/', views.export_lre, name='export_lre'),
path('exportar/banco/', views.export_bank_txt, name='export_bank_txt'),
]
+247
View File
@@ -0,0 +1,247 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from django.db.models import Sum, Count
from decimal import Decimal
from datetime import date, datetime
from .models import EmployeeProfile, SalarySlip, PayrollCentralizationEntry, SalarySlipStatusEnum
from .forms import EmployeeProfileForm, SalarySlipGenerateForm
from .services.engine import PayrollEngine, BankExportService, PreviredExportService, LREExportService
@login_required
def employee_list(request):
employees = EmployeeProfile.objects.all().order_by('last_name', 'first_name')
active_count = employees.filter(is_active=True).count()
total_base_clp = employees.filter(is_active=True).aggregate(Sum('base_salary_clp'))['base_salary_clp__sum'] or 0
return render(request, 'payroll/employee_list.html', {
'employees': employees,
'active_count': active_count,
'total_base_clp': total_base_clp,
})
@login_required
def employee_create(request):
if request.method == 'POST':
form = EmployeeProfileForm(request.POST)
if form.is_valid():
emp = form.save()
messages.success(request, f"Colaborador {emp.full_name} registrado exitosamente.")
return redirect('payroll:employee_list')
else:
form = EmployeeProfileForm()
return render(request, 'payroll/employee_form.html', {'form': form, 'title': 'Registrar Nuevo Colaborador'})
@login_required
def employee_edit(request, pk):
emp = get_object_or_404(EmployeeProfile, pk=pk)
if request.method == 'POST':
form = EmployeeProfileForm(request.POST, instance=emp)
if form.is_valid():
emp = form.save()
messages.success(request, f"Ficha de {emp.full_name} actualizada.")
return redirect('payroll:employee_list')
else:
form = EmployeeProfileForm(instance=emp)
return render(request, 'payroll/employee_form.html', {'form': form, 'title': f"Editar Colaborador: {emp.full_name}"})
@login_required
def slip_list(request):
month = request.GET.get('month')
year = request.GET.get('year', 2026)
slips = SalarySlip.objects.select_related('employee').all()
if month:
slips = slips.filter(month=int(month))
if year:
slips = slips.filter(year=int(year))
totals = slips.aggregate(
total_liquid=Sum('net_salary'),
total_gross=Sum('total_gross_income'),
total_company=Sum('total_company_cost')
)
return render(request, 'payroll/slip_list.html', {
'slips': slips,
'selected_month': month,
'selected_year': int(year),
'total_liquid': totals['total_liquid'] or 0,
'total_gross': totals['total_gross'] or 0,
'total_company': totals['total_company'] or 0,
})
@login_required
def slip_generate(request):
if request.method == 'POST':
form = SalarySlipGenerateForm(request.POST)
if form.is_valid():
month = int(form.cleaned_data['month'])
year = int(form.cleaned_data['year'])
specific_employee = form.cleaned_data.get('employee')
overtime_hours = form.cleaned_data.get('overtime_hours') or Decimal('0')
rd_bonus = form.cleaned_data.get('rd_bonus') or Decimal('0')
other_deductions = form.cleaned_data.get('other_deductions') or Decimal('0')
employees_to_calc = [specific_employee] if specific_employee else list(EmployeeProfile.objects.filter(is_active=True))
created_count = 0
for emp in employees_to_calc:
# Calculate
calc = PayrollEngine.compute_salary_slip(
employee=emp,
month=month,
year=year,
days_worked=30,
overtime_hours=overtime_hours if specific_employee else Decimal('0'),
rd_bonus=rd_bonus if specific_employee else Decimal('0'),
other_deductions=other_deductions if specific_employee else Decimal('0')
)
slip, created = SalarySlip.objects.update_or_create(
employee=emp,
month=month,
year=year,
defaults={
'issue_date': date(year, month, 28),
'days_worked': calc['days_worked'],
'base_salary': calc['base_salary'],
'overtime_hours_50': calc['overtime_hours_50'],
'overtime_amount_50': calc['overtime_amount_50'],
'legal_gratification': calc['legal_gratification'],
'rd_bonus': calc['rd_bonus'],
'other_taxable': calc['other_taxable'],
'total_taxable_income': calc['total_taxable_income'],
'allowance_lunch': calc['allowance_lunch'],
'allowance_commute': calc['allowance_commute'],
'total_non_taxable_income': calc['total_non_taxable_income'],
'total_gross_income': calc['total_gross_income'],
'afp_deduction': calc['afp_deduction'],
'health_deduction': calc['health_deduction'],
'afc_deduction': calc['afc_deduction'],
'second_category_tax': calc['second_category_tax'],
'other_deductions': calc['other_deductions'],
'total_deductions': calc['total_deductions'],
'net_salary': calc['net_salary'],
'employer_sis': calc['employer_sis'],
'employer_mutual': calc['employer_mutual'],
'employer_afc': calc['employer_afc'],
'total_company_cost': calc['total_company_cost'],
'status': SalarySlipStatusEnum.EMITIDA
}
)
created_count += 1
# Auto-update centralization entry for this month/year
month_slips = SalarySlip.objects.filter(month=month, year=year)
m_totals = month_slips.aggregate(
sum_taxable=Sum('total_taxable_income'),
sum_non_taxable=Sum('total_non_taxable_income'),
sum_employer=Sum('employer_sis') + Sum('employer_mutual') + Sum('employer_afc'),
sum_net=Sum('net_salary'),
sum_previred=Sum('afp_deduction') + Sum('health_deduction') + Sum('afc_deduction') + Sum('employer_sis') + Sum('employer_mutual') + Sum('employer_afc'),
sum_sii=Sum('second_category_tax'),
sum_cost=Sum('total_company_cost')
)
debit_total = (m_totals['sum_cost'] or Decimal('0'))
credit_total = (m_totals['sum_net'] or Decimal('0')) + (m_totals['sum_previred'] or Decimal('0')) + (m_totals['sum_sii'] or Decimal('0'))
PayrollCentralizationEntry.objects.update_or_create(
month=month,
year=year,
defaults={
'entry_date': date(year, month, 28),
'glosa': f"Centralización Sueldos y Leyes Sociales {month:02d}/{year} EFINTEL I+D+i",
'account_code': '5101',
'account_name': 'Remuneraciones y Cargas Sociales I+D+i',
'total_taxable': m_totals['sum_taxable'] or 0,
'total_non_taxable': m_totals['sum_non_taxable'] or 0,
'total_employer_charges': m_totals['sum_employer'] or 0,
'total_net_payable': m_totals['sum_net'] or 0,
'total_previred_payable': m_totals['sum_previred'] or 0,
'total_sii_payable': m_totals['sum_sii'] or 0,
'total_debit': debit_total,
'total_credit': credit_total,
'is_balanced': abs(debit_total - credit_total) < Decimal('1.00'),
'status': 'CONTABILIZADO'
}
)
messages.success(request, f"Se generaron/actualizaron con éxito {created_count} liquidaciones para el periodo {month:02d}/{year}.")
return redirect('payroll:slip_list')
else:
form = SalarySlipGenerateForm()
return render(request, 'payroll/slip_generate.html', {'form': form})
@login_required
def slip_detail(request, pk):
slip = get_object_or_404(SalarySlip.objects.select_related('employee'), pk=pk)
return render(request, 'payroll/slip_detail.html', {'slip': slip})
@login_required
def slip_sign(request, pk):
slip = get_object_or_404(SalarySlip, pk=pk)
if request.method == 'POST':
now = timezone.now()
sig_hash = PayrollEngine.generate_signature_hash(slip.id, slip.employee.rut, slip.net_salary, now)
slip.digital_signature_hash = sig_hash
slip.signed_at = now
slip.status = SalarySlipStatusEnum.FIRMADA
slip.save()
messages.success(request, f"Liquidación firmada digitalmente con éxito. Hash: {sig_hash[:16]}...")
return redirect('payroll:slip_detail', pk=pk)
@login_required
def centralization_view(request):
entries = PayrollCentralizationEntry.objects.all().order_by('-year', '-month')
return render(request, 'payroll/centralization.html', {'entries': entries})
# Export Views
@login_required
def export_previred(request):
month = request.GET.get('month', date.today().month)
year = request.GET.get('year', date.today().year)
slips = SalarySlip.objects.filter(month=int(month), year=int(year)).select_related('employee')
if not slips.exists():
messages.warning(request, f"No hay liquidaciones emitidas para {month}/{year}.")
return redirect('payroll:slip_list')
content = PreviredExportService.generate_previred_105(list(slips))
response = HttpResponse(content, content_type='text/plain; charset=iso-8859-1')
response['Content-Disposition'] = f'attachment; filename="PREVIRED_105_{year}_{int(month):02d}.txt"'
return response
@login_required
def export_lre(request):
month = request.GET.get('month', date.today().month)
year = request.GET.get('year', date.today().year)
slips = SalarySlip.objects.filter(month=int(month), year=int(year)).select_related('employee')
if not slips.exists():
messages.warning(request, f"No hay liquidaciones emitidas para {month}/{year}.")
return redirect('payroll:slip_list')
content = LREExportService.generate_dt_lre_csv(list(slips))
response = HttpResponse(content, content_type='text/csv; charset=utf-8-sig')
response['Content-Disposition'] = f'attachment; filename="LRE_DT_{year}_{int(month):02d}.csv"'
return response
@login_required
def export_bank_txt(request):
month = request.GET.get('month', date.today().month)
year = request.GET.get('year', date.today().year)
slips = SalarySlip.objects.filter(month=int(month), year=int(year)).select_related('employee')
if not slips.exists():
messages.warning(request, f"No hay liquidaciones emitidas para {month}/{year}.")
return redirect('payroll:slip_list')
content = BankExportService.export_santander_format(list(slips))
response = HttpResponse(content, content_type='text/csv; charset=utf-8-sig')
response['Content-Disposition'] = f'attachment; filename="NOMINA_BANCO_{year}_{int(month):02d}.csv"'
return response