Files
oc-facho/facho/fe/nomina/__init__.py
aserrador 4a7c2e2947 Fix: Formateo PEP8 (79 cols) y limpieza flake8 en paquete facho
- Líneas >79 cols reajustadas en todo el paquete
- Fix F821: etree.cleanup_namespaces(security) en signature.py (era 'header', NameError)
- Fix bug cune_xpath -> xpath en nomina/informacion_general() (NameError)
- Elimina clase TaxScheme duplicada en form/__init__.py
- Imports explícitos (F403/F405) y __all__ en form_xml/invoice.py, nomina
- Elimina imports y variables muertas (F401/F841), E712 (== None -> is None)
2026-08-12 19:56:29 -05:00

828 lines
26 KiB
Python

#
# Para esta implementacion se usa BDD
# ver **test_nomina.py**.
#
# La idea en general es validar comportamiento desde el XML,
# creando las estructuras minimas necesaras.
from dataclasses import dataclass
from datetime import datetime
import hashlib
from .. import fe
from ..data.dian import codelist
from .amount import Amount
from .deduccion import (
Deduccion,
DeduccionFondoPension,
DeduccionSalud,
)
from .devengado import (
Devengado,
DevengadoBasico,
DevengadoHoraExtra,
DevengadoHorasExtrasDiarias,
DevengadoHorasExtrasDiariasDominicalesYFestivos,
DevengadoHorasExtrasNocturnas,
DevengadoHorasExtrasNocturnasDominicalesYFestivos,
DevengadoHorasRecargoDiariasDominicalesYFestivos,
DevengadoHorasRecargoNocturno,
DevengadoHorasRecargoNocturnoDominicalesYFestivos,
DevengadoTransporte,
)
from .empleador import Empleador
from .exception import DIANNominaIndividualError
from .lugar import Lugar
from .pago import (
FormaPago,
MetodoPago,
Pago,
)
from .trabajador import (
LugarTrabajo,
SubTipoTrabajador,
TipoContrato,
TipoDocumento,
TipoTrabajador,
Trabajador,
)
__all__ = [
'Amount',
'Deduccion',
'DeduccionFondoPension',
'DeduccionSalud',
'Devengado',
'DevengadoBasico',
'DevengadoHoraExtra',
'DevengadoHorasExtrasDiarias',
'DevengadoHorasExtrasDiariasDominicalesYFestivos',
'DevengadoHorasExtrasNocturnas',
'DevengadoHorasExtrasNocturnasDominicalesYFestivos',
'DevengadoHorasRecargoDiariasDominicalesYFestivos',
'DevengadoHorasRecargoNocturno',
'DevengadoHorasRecargoNocturnoDominicalesYFestivos',
'DevengadoTransporte',
'DIANNominaIndividual',
'DIANNominaIndividualDeAjuste',
'DIANNominaXML',
'DIANNominaIndividualError',
'DianXMLExtensionSigner',
'Empleador',
'Fecha',
'FechaPago',
'FormaPago',
'InformacionGeneral',
'Lugar',
'LugarTrabajo',
'Metadata',
'MetodoPago',
'Novedad',
'NumeroSecuencia',
'Pago',
'Periodo',
'PeriodoNomina',
'Proveedor',
'SubTipoTrabajador',
'TipoContrato',
'TipoDocumento',
'TipoMoneda',
'TipoTrabajador',
'Trabajador',
]
class Fecha:
def __init__(self, fecha):
try:
datetime.strptime(fecha, "%Y-%m-%d")
except ValueError:
raise ValueError("fecha debe ser formato YYYY-MM-DD")
self.value = fecha
@classmethod
def cast(cls, data, optional=False):
if isinstance(data, str):
return cls(data)
elif isinstance(data, cls):
return data
elif data is None and optional:
return None
else:
raise ValueError('no se logra hacer casting a Fecha')
def __str__(self):
return self.value
class FechaPago(Fecha):
def apply(self, fragment):
fragment.set_element('./FechaPago', self.value)
@dataclass
class Novedad:
# cune de nomina a relacionar
# NIE204
cune: str
# NIE199
activa: bool = False
def apply(self, fragment):
if self.cune != "":
fragment.set_attributes('./Novedad',
CUNENov=self.cune,
)
def post_apply(self, fexml, scopexml, fragment):
scopexml.set_element('./Novedad', self.activa)
@dataclass
class NumeroSecuencia:
consecutivo: int
prefijo: str
def apply(self, fragment):
numero = f"{self.prefijo}{self.consecutivo}"
fragment.set_attributes('./NumeroSecuenciaXML',
# NIE010
Prefijo=self.prefijo,
# NIE011
Consecutivo=self.consecutivo,
# NIE012
Numero=numero)
@dataclass
class Periodo:
fecha_ingreso: str | Fecha
fecha_liquidacion_inicio: str | Fecha
fecha_liquidacion_fin: str | Fecha
fecha_generacion: str | Fecha
tiempo_laborado: int = 1
fecha_retiro: str | Fecha | None = None
def __post_init__(self):
self.fecha_ingreso = Fecha.cast(self.fecha_ingreso)
self.fecha_liquidacion_inicio = Fecha.cast(
self.fecha_liquidacion_inicio)
self.fecha_liquidacion_fin = Fecha.cast(self.fecha_liquidacion_fin)
self.fecha_retiro = Fecha.cast(self.fecha_retiro, optional=True)
def apply(self, fragment):
fragment.set_attributes('./Periodo',
# NIE002
FechaIngreso=self.fecha_ingreso,
# NIE003
FechaRetiro=self.fecha_retiro,
# NIE004
FechaLiquidacionInicio=(
self.fecha_liquidacion_inicio),
# NIE005
FechaLiquidacionFin=self.fecha_liquidacion_fin,
# NIE006
TiempoLaborado=self.tiempo_laborado,
# NIE008
FechaGen=self.fecha_generacion)
@dataclass
class Proveedor:
razon_social: str
nit: str
dv: int
software_id: str
software_pin: str
def apply(self, fragment):
fragment.set_attributes('./ProveedorXML',
# NIE017
NIT=self.nit,
# NIE018
DV=self.dv,
# NIE019
SoftwareID=self.software_id,
SoftwareSC=None,
# NIE025
RazonSocial=self.razon_social
)
def post_apply(self, fexml, scopexml, fragment):
cune_xpath = scopexml.xpath_from_root('/InformacionGeneral')
cune = fexml.get_element_attribute(cune_xpath, 'CUNE')
ambiente = fexml.get_element_attribute(
scopexml.xpath_from_root('/InformacionGeneral'), 'Ambiente')
codigo_qr = (
f"https://catalogo-vpfe.dian.gov.co/document/"
f"searchqr?documentkey={cune}")
if InformacionGeneral.AMBIENTE_PRUEBAS == ambiente:
codigo_qr = (
f"https://catalogo-vpfe-hab.dian.gov.co/document/"
f"searchqr?documentkey={cune}")
elif ambiente is None:
raise RuntimeError('fail to get InformacionGeneral/@Ambiente')
scopexml.set_element('./CodigoQR', codigo_qr)
# NIE020
software_code = self._software_security_code(fexml, scopexml)
fexml.set_attributes(
scopexml.xpath_from_root('/ProveedorXML'),
SoftwareSC=software_code)
def _software_security_code(self, fexml, scopexml):
# 8.2
numero = fexml.get_element_attribute(
scopexml.xpath_from_root('/NumeroSecuenciaXML'), 'Numero')
if numero is None:
raise RuntimeError('fallo obtener NumeroSequenciaXML/@Numero')
id_software = self.software_id
software_pin = self.software_pin
code = "".join([id_software, software_pin, numero])
fexml.set_attributes(scopexml.xpath_from_root('/ProveedorXML'))
h = hashlib.sha384()
h.update(code.encode('utf-8'))
return h.hexdigest()
@dataclass
class Metadata:
novedad: Novedad
secuencia: NumeroSecuencia
# NIE013, NIE014, NIE015, NIE016
lugar_generacion: Lugar
proveedor: Proveedor
def apply(
self,
novedad,
numero_secuencia_xml,
lugar_generacion_xml,
proveedor_xml):
if novedad:
self.novedad.apply(novedad)
self.secuencia.apply(numero_secuencia_xml)
self.lugar_generacion.apply(
lugar_generacion_xml,
'./LugarGeneracionXML')
self.proveedor.apply(proveedor_xml)
def post_apply(
self,
fexml,
scopexml,
novedad,
numero_secuencia_xml,
lugar_generacion_xml,
proveedor_xml):
self.proveedor.post_apply(fexml, scopexml, proveedor_xml)
if novedad:
self.novedad.post_apply(fexml, scopexml, proveedor_xml)
@dataclass
class PeriodoNomina:
code: str
name: str = ''
def __post_init__(self):
if self.code not in codelist.PeriodoNomina:
raise ValueError("code [%s] not found" % (self.code))
self.name = codelist.PeriodoNomina[self.code]['name']
@dataclass
class TipoMoneda:
code: str
name: str = ''
def __post_init__(self):
if self.code not in codelist.TipoMoneda:
raise ValueError("code [%s] not found" % (self.code))
self.name = codelist.TipoMoneda[self.code]['name']
@dataclass
class InformacionGeneral:
@dataclass
class TIPO_AMBIENTE:
valor: str
def __eq__(self, other):
return self.valor == str(other)
# TABLA 5.1.1
@dataclass
class AMBIENTE_PRODUCCION(TIPO_AMBIENTE):
valor: str = '1'
def __str__(self):
self.valor
@dataclass
class AMBIENTE_PRUEBAS(TIPO_AMBIENTE):
valor: str = '2'
def __str__(self):
self.valor
# TABLA 5.5.7
@dataclass
class TIPO_XML:
valor: str
def __eq__(self, other):
return self.valor == str(other)
@dataclass
class TIPO_XML_NORMAL(TIPO_XML):
valor: str = '102'
def __str__(self):
self.valor
@dataclass
class TIPO_XML_AJUSTES(TIPO_XML):
valor: str = '103'
def __str__(self):
self.valor
fecha_generacion: str | Fecha
hora_generacion: str
periodo_nomina: PeriodoNomina
tipo_moneda: TipoMoneda
tipo_ambiente: TIPO_AMBIENTE
tipo_xml: TIPO_XML
software_pin: str
def __post_init__(self):
self.fecha_generacion = Fecha.cast(self.fecha_generacion)
def apply(self, fragment, version):
fragment.set_attributes('./InformacionGeneral',
# NIE022
Version=version,
# NIE023
Ambiente=self.tipo_ambiente.valor,
# NIE202
# TABLA 5.5.2
# TODO(bit4bit) solo NominaIndividual
TipoXML=self.tipo_xml.valor,
# NIE024
CUNE=None,
# NIE025
EncripCUNE='CUNE-SHA384',
# NIE026
FechaGen=self.fecha_generacion,
# NIE027
HoraGen=self.hora_generacion,
# NIE029
PeriodoNomina=self.periodo_nomina.code,
# NIE030
TipoMoneda=self.tipo_moneda.code,
TRM=0
# TODO(bit4bit) resto...
# .....
)
def post_apply(self, fexml, scopexml, fragment):
# generar cune
# ver 8.1.1.1
xpaths = [
scopexml.xpath_from_root('/NumeroSecuenciaXML/@Numero'),
scopexml.xpath_from_root('/InformacionGeneral/@FechaGen'),
scopexml.xpath_from_root('/InformacionGeneral/@HoraGen'),
scopexml.xpath_from_root('/DevengadosTotal'),
scopexml.xpath_from_root('/DeduccionesTotal'),
scopexml.xpath_from_root('/ComprobanteTotal'),
scopexml.xpath_from_root('/Empleador/@NIT'),
scopexml.xpath_from_root('/Trabajador/@NumeroDocumento'),
scopexml.xpath_from_root('/InformacionGeneral/@TipoXML'),
tuple([self.software_pin]),
scopexml.xpath_from_root('/InformacionGeneral/@Ambiente')
]
campos = fexml.get_elements_text_or_attributes(xpaths)
cune = "".join(campos)
h = hashlib.sha384()
h.update(cune.encode('utf-8'))
cune_hash = h.hexdigest()
fragment.set_attributes(
'./InformacionGeneral',
# NIE024
CUNE=cune_hash
)
class DianXMLExtensionSigner(fe.DianXMLExtensionSigner):
def __init__(self, pkcs12_path, passphrase=None, localpolicy=True):
super().__init__(
pkcs12_path,
passphrase=passphrase,
localpolicy=localpolicy)
def _element_extension_content(self, fachoxml):
return fachoxml.builder.xpath(
fachoxml.root,
'./ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent')
class DIANNominaXML:
def __init__(
self,
tag_document,
xpath_ajuste=None,
schemaLocation=None,
namespace_ajuste=None):
self.informacion_general_version = None
self.tag_document = tag_document
if namespace_ajuste:
self.fexml = fe.FeXML(tag_document, namespace_ajuste)
else:
self.fexml = fe.FeXML(
tag_document,
'dian:gov:co:facturaelectronica:NominaIndividual')
self.fexml.root.set("SchemaLocation", "")
self.fexml.root.set("schemaLocation", schemaLocation)
# layout, la dian requiere que los elementos
# esten ordenados segun el anexo tecnico
self.fexml.placeholder_for(
'./ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent')
self.fexml.placeholder_for('./TipoNota', optional=True)
self.root_fragment = self.fexml
if xpath_ajuste is not None:
self.root_fragment = self.fexml.fragment(xpath_ajuste)
self.root_fragment.placeholder_for(
'./ReemplazandoPredecesor', optional=True)
self.root_fragment.placeholder_for(
'./EliminandoPredecesor', optional=True)
if not namespace_ajuste:
self.root_fragment.placeholder_for('./Novedad', optional=False)
self.root_fragment.placeholder_for('./Periodo')
self.root_fragment.placeholder_for('./NumeroSecuenciaXML')
self.root_fragment.placeholder_for('./LugarGeneracionXML')
self.root_fragment.placeholder_for('./ProveedorXML')
self.root_fragment.placeholder_for('./CodigoQR')
self.root_fragment.placeholder_for('./InformacionGeneral')
self.root_fragment.placeholder_for('./Empleador')
self.root_fragment.placeholder_for('./Trabajador')
self.root_fragment.placeholder_for('./Pago')
self.root_fragment.placeholder_for('./FechasPagos')
self.root_fragment.placeholder_for('./Devengados/Basico')
self.root_fragment.placeholder_for(
'./Devengados/Transporte', optional=True)
if not namespace_ajuste:
self.novedad = self.root_fragment.fragment('./Novedad')
else:
self.novedad = None
self.informacion_general_xml = self.root_fragment.fragment(
'./InformacionGeneral')
self.periodo_xml = self.root_fragment.fragment('./Periodo')
self.fecha_pagos_xml = self.root_fragment.fragment('./FechasPagos')
self.numero_secuencia_xml = self.root_fragment.fragment(
'./NumeroSecuenciaXML')
self.lugar_generacion_xml = self.root_fragment.fragment(
'./LugarGeneracionXML')
self.proveedor_xml = self.root_fragment.fragment('./ProveedorXML')
self.empleador = self.root_fragment.fragment('./Empleador')
self.trabajador = self.root_fragment.fragment('./Trabajador')
self.pago_xml = self.root_fragment.fragment('./Pago')
self.devengados = self.root_fragment.fragment('./Devengados')
self.deducciones = self.root_fragment.fragment('./Deducciones')
self.informacion_general = None
self.metadata = None
def asignar_metadata(self, metadata):
if not isinstance(metadata, Metadata):
raise ValueError('se espera tipo Metadata')
self.metadata = metadata
self.metadata.apply(
self.novedad,
self.numero_secuencia_xml,
self.lugar_generacion_xml,
self.proveedor_xml)
def asignar_informacion_general(self, general):
if not isinstance(general, InformacionGeneral):
raise ValueError('se espera tipo InformacionGeneral')
self.informacion_general = general
self.informacion_general.apply(
self.informacion_general_xml,
self.informacion_general_version)
def asignar_periodo(self, periodo):
if not isinstance(periodo, Periodo):
raise ValueError('se espera tipo Periodo')
periodo.apply(self.periodo_xml)
def asignar_pago(self, pago):
if not isinstance(pago, Pago):
raise ValueError('se espera tipo Pago')
pago.apply(self.pago_xml)
def asignar_fecha_pago(self, data):
if isinstance(data, str):
fecha = FechaPago(data)
elif isinstance(data, FechaPago):
fecha = data
fecha.apply(self.fecha_pagos_xml)
def asignar_empleador(self, empleador):
if not isinstance(empleador, Empleador):
raise ValueError('se espera tipo Empleador')
empleador.apply(self.empleador)
def asignar_trabajador(self, trabajador):
if not isinstance(trabajador, Trabajador):
raise ValueError('se espera tipo Trabajador')
trabajador.apply(self.trabajador)
def adicionar_devengado(self, devengado):
if not isinstance(devengado, Devengado):
raise ValueError('se espera tipo Devengado')
devengado.apply(self.devengados)
def adicionar_deduccion(self, deduccion):
if not isinstance(deduccion, Deduccion):
raise ValueError('se espera tipo Devengado')
deduccion.apply(self.deducciones)
def validate(self):
"""
Valida requisitos segun anexo tecnico
"""
errors = []
def check_element(xpath, msg):
if not self.fexml.exist_element(xpath):
errors.append(DIANNominaIndividualError(msg))
def check_attribute(xpath, key, msg):
err = DIANNominaIndividualError(msg)
elem = self.fexml.get_element(xpath)
if elem is None:
return errors.append(err)
if elem.get(key, None) is None:
return errors.append(err)
check_attribute(
self.fexml.xpath_from_root('/Periodo'),
'FechaIngreso',
'se requiere Periodo')
check_element(
self.fexml.xpath_from_root('/Pago'),
'se requiere Pago'
)
check_element(
self.fexml.xpath_from_root('/Devengados/Basico'),
'se requiere DevengadoBasico'
)
check_element(
self.fexml.xpath_from_root('/Deducciones/Salud'),
'se requiere DeduccionSalud'
)
check_element(
self.fexml.xpath_from_root('/Deducciones/FondoPension'),
'se requiere DeduccionFondoPension'
)
return errors
def informacion_general(self):
xpath = self.root_fragment.xpath_from_root('/InformacionGeneral')
return {
'cune': self.fexml.get_element_attribute(
xpath, 'CUNE'),
'fecha_generacion': self.fexml.get_element_attribute(
xpath, 'FechaGen'),
'numero': self.fexml.get_element_attribute(
self.root_fragment(
'/NumeroSecuenciaXML', 'Numero'))}
def toFachoXML(self):
self._devengados_total()
self._deducciones_total()
self._comprobante_total()
if self.informacion_general is not None:
# TODO(bit4bit) acoplamiento temporal
# es importante el orden de ejecucion
self.informacion_general.post_apply(
self.fexml, self.root_fragment, self.informacion_general_xml)
if self.metadata is not None:
self.metadata.post_apply(
self.fexml,
self.root_fragment,
self.novedad,
self.numero_secuencia_xml,
self.lugar_generacion_xml,
self.proveedor_xml)
return self.fexml
def _comprobante_total(self):
devengados_total = self.root_fragment.get_element_text_or_attribute(
'./DevengadosTotal', '0.0')
deducciones_total = self.root_fragment.get_element_text_or_attribute(
'./DeduccionesTotal', '0.0')
comprobante_total = Amount(devengados_total) - \
Amount(deducciones_total)
self.root_fragment.set_element(
'./ComprobanteTotal', str(round(comprobante_total, 2)))
def _deducciones_total(self):
xpaths = [
self.root_fragment.xpath_from_root(
'/Deducciones/Salud/@Deduccion'),
self.root_fragment.xpath_from_root(
'/Deducciones/FondoPension/@Deduccion')]
deducciones = map(lambda valor: Amount(valor),
self._values_of_xpaths(xpaths))
deducciones_total = Amount(0.0)
for deduccion in deducciones:
deducciones_total += deduccion
self.root_fragment.set_element(
'./DeduccionesTotal', str(round(deducciones_total, 2)))
def _devengados_total(self):
xpaths = [
self.root_fragment.xpath_from_root(
'/Devengados/Basico/@SueldoTrabajado'),
self.root_fragment.xpath_from_root(
'/Devengados/Transporte/@AuxilioTransporte'),
self.root_fragment.xpath_from_root(
'/Devengados/Transporte/@ViaticoManuAlojS'),
self.root_fragment.xpath_from_root(
'/Devengados/Transporte/@ViaticoManuAlojNS')]
devengados = map(lambda valor: Amount(valor),
self._values_of_xpaths(xpaths))
devengados_total = Amount(0.0)
for devengado in devengados:
devengados_total += devengado
# TODO(bit4bit) nque valor va redondeado?
# NIE186
self.root_fragment.set_element('./Redondeo', str(round(0, 2)))
self.root_fragment.set_element(
'./DevengadosTotal', str(round(devengados_total, 2)))
def _values_of_xpaths(self, xpaths):
xpaths_values_of_values = map(
lambda val: self.fexml.get_element_text_or_attribute(
val, multiple=True), xpaths)
xpaths_values = []
# toda esta carreta para hacer un aplano de lista
for xpath_values in xpaths_values_of_values:
if xpath_values is None:
continue
for xpath_value in xpath_values:
xpaths_values.append(xpath_value)
return filter(lambda val: val is not None, xpaths_values)
class DIANNominaIndividual(DIANNominaXML):
def __init__(self):
schema = (
"dian:gov:co:facturaelectronica:NominaIndividual"
" NominaIndividualElectronicaXSD.xsd"
)
super().__init__('NominaIndividual', schemaLocation=schema)
self.informacion_general_version = (
'V1.0: Documento Soporte de Pago de Nómina Electrónica')
# TODO(bit4bit) confirmar que no tienen en comun con NominaIndividual
class DIANNominaIndividualDeAjuste(DIANNominaXML):
class Reemplazar(DIANNominaXML):
@dataclass
class Predecesor:
numero: str
cune: str
fecha_generacion: str
def apply(self, fragment):
# NIAE214
fragment.set_element('./TipoNota', '1')
fragment.set_element(
'./Reemplazar/ReemplazandoPredecesor', None,
# NIAE090
NumeroPred=self.numero,
# NIAE191
CUNEPred=self.cune,
# NIAE192
FechaGenPred=self.fecha_generacion
)
def __init__(self):
schema = (
"dian:gov:co:facturaelectronica:NominaIndividualDeAjuste"
" NominaIndividualDeAjusteElectronicaXSD.xsd"
)
super().__init__(
'NominaIndividualDeAjuste',
'./Reemplazar',
schemaLocation=schema,
namespace_ajuste=(
'dian:gov:co:facturaelectronica:'
'NominaIndividualDeAjuste'))
self.informacion_general_version = (
'V1.0: Nota de Ajuste de Documento Soporte de Pago de '
'Nómina Electrónica')
def asignar_predecesor(self, predecesor):
if not isinstance(predecesor, self.Predecesor):
raise ValueError("se espera tipo Predecesor")
predecesor.apply(self.fexml)
class Eliminar(DIANNominaXML):
@dataclass
class Predecesor:
numero: str
cune: str
fecha_generacion: str
def apply(self, fragment):
fragment.set_element('./TipoNota', '2')
fragment.set_element('./Eliminar/EliminandoPredecesor', None,
# NIAE090
NumeroPred=self.numero,
# NIAE191
CUNEPred=self.cune,
# NIAE192
FechaGenPred=self.fecha_generacion
)
def __init__(self):
schema = (
"dian:gov:co:facturaelectronica:NominaIndividualDeAjuste"
" NominaIndividualDeAjusteElectronicaXSD.xsd"
)
super().__init__(
'NominaIndividualDeAjuste',
'./Eliminar',
schemaLocation=schema,
namespace_ajuste=(
'dian:gov:co:facturaelectronica:'
'NominaIndividualDeAjuste'))
self.informacion_general_version = (
"V1.0: Nota de Ajuste de Documento Soporte de Pago de "
"Nómina Electrónica")
def asignar_predecesor(self, predecesor):
if not isinstance(predecesor, self.Predecesor):
raise ValueError("se espera tipo Eliminar.Predecesor")
predecesor.apply(self.fexml)
def __init__(self):
super().__init__('NominaIndividualDeAjuste')