iteracion 2
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user