diff --git a/facho/facho.py b/facho/facho.py index 2f32907..97a564e 100644 --- a/facho/facho.py +++ b/facho/facho.py @@ -117,8 +117,12 @@ class LXMLBuilder: def set_attribute(self, elem, key, value): elem.attrib[key] = value - def remove_attributes(self, elem, keys): + @classmethod + def remove_attributes(cls, elem, keys, exclude = []): for key in keys: + if key in exclude: + continue + try: del elem.attrib[key] except KeyError: @@ -132,10 +136,8 @@ class LXMLBuilder: attrs['encoding'] = attrs.pop('encoding', 'UTF-8') for el in elem.getiterator(): - try: - del el.attrib['facho_placeholder'] - except KeyError: - pass + keys = filter(lambda key: key.startswith('facho_'), el.keys()) + self.remove_attributes(el, keys, exclude=['facho_optional']) is_optional = el.get('facho_optional', 'False') == 'True' if is_optional and el.getchildren() == [] and el.keys() == ['facho_optional']: @@ -364,6 +366,20 @@ class FachoXML: text = self.builder.get_text(elem) return format_(text) + def exist_element(self, xpath): + elem = self.get_element(xpath) + + if elem is None: + return False + + if elem.get('facho_placeholder') == 'True': + return False + + if elem.get('facho_optional') == 'True': + return False + + return True + def _remove_facho_attributes(self, elem): self.builder.remove_attributes(elem, ['facho_optional', 'facho_placeholder']) diff --git a/facho/fe/nomina/__init__.py b/facho/fe/nomina/__init__.py new file mode 100644 index 0000000..07708fb --- /dev/null +++ b/facho/fe/nomina/__init__.py @@ -0,0 +1,125 @@ +from .. import fe +from .. import form + +from dataclasses import dataclass + +class Amount(form.Amount): + pass + + +class Devengado: + pass + +@dataclass +class DevengadoBasico(Devengado): + dias_trabajados: int + sueldo_trabajado: Amount + + def apply(self, fragment): + fragment.find_or_create_element('./Basico') + + fragment.set_attributes('/Basico', + # NIE069 + DiasTrabajados = str(self.dias_trabajados), + # NIE070 + SueldoTrabajado = str(self.sueldo_trabajado) + ) + +@dataclass +class DevengadoTransporte(Devengado): + auxilio_transporte: Amount = None + viatico_manutencion: Amount = None + viatico_manutencion_no_salarial: Amount = None + + def apply(self, fragment): + fragment.set_element('./Transporte', None, + append_ = True, + # NIE071 + AuxilioTransporte = self.auxilio_transporte, + # NIE072 + ViaticoManuAlojS = self.viatico_manutencion, + # NIE073 + ViaticoManuAlojNS = self.viatico_manutencion_no_salarial + ) + +class Deduccion: + pass + +@dataclass +class DeduccionSalud(Deduccion): + porcentaje: Amount + deduccion: Amount + + def apply(self, fragment): + fragment.set_element('./Salud', None, + append_ = True, + # NIE161 + Porcentaje = self.porcentaje, + # NIE163 + Deduccion = self.deduccion + ) + +@dataclass +class DeduccionFondoPension(Deduccion): + porcentaje: Amount + deduccion: Amount + + def apply(self, fragment): + fragment.set_element('./FondoPension', None, + append_ = True, + # NIE164 + Porcentaje = self.porcentaje, + # NIE166 + Deduccion = self.deduccion + ) + +class DIANNominaIndividualError(Exception): + pass + +class DIANNominaIndividual: + def __init__(self): + self.fexml = fe.FeXML('NominaIndividual', 'http://www.dian.gov.co/contratos/facturaelectronica/v1') + + # layout, la dian requiere que los elementos + # esten ordenados segun el anexo tecnico + self.fexml.placeholder_for('./Devengados/Basico') + self.fexml.placeholder_for('./Devengados/Transporte', optional=True) + + self.devengados = self.fexml.fragment('./Devengados') + self.deducciones = self.fexml.fragment('./Deducciones') + + 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 add_error(xpath, msg): + if not self.fexml.exist_element(xpath): + errors.append(DIANNominaIndividualError(msg)) + + add_error('/fe:NominaIndividual/Devengados/Basico', + 'se requiere DevengadoBasico') + + add_error('/fe:NominaIndividual/Deducciones/Salud', + 'se requiere DeduccionSalud') + + add_error('/fe:NominaIndividual/Deducciones/FondoPension', + 'se requiere DeduccionFondoPension') + + return errors + + def toFachoXML(self): + return self.fexml diff --git a/tests/test_facho.py b/tests/test_facho.py index 08ac90e..135b017 100644 --- a/tests/test_facho.py +++ b/tests/test_facho.py @@ -351,3 +351,14 @@ def test_facho_xml_placeholder_optional_and_fragment_with_set_element(): assert xml.tostring() == '' assert xml.get_element_attribute('/root/A/AA', 'prueba') == 'OK' + +def test_facho_xml_exist_element(): + xml = facho.FachoXML('root') + + xml.placeholder_for('./A') + assert xml.exist_element('/root/A') == False + assert xml.tostring() == '' + + xml.find_or_create_element('./A') + assert xml.exist_element('/root/A') == True + assert xml.tostring() == '' diff --git a/tests/test_nomina.py b/tests/test_nomina.py index f1432b1..007c5fc 100644 --- a/tests/test_nomina.py +++ b/tests/test_nomina.py @@ -60,3 +60,19 @@ def test_adicionar_deduccion_salud(): print(xml) assert str(xml) == """""" +def test_nomina_obligatorios_segun_anexo_tecnico(): + nomina = fe.nomina.DIANNominaIndividual() + + errors = nomina.validate() + + assert_error(errors, 'se requiere DevengadoBasico') + assert_error(errors, 'se requiere DeduccionSalud') + assert_error(errors, 'se requiere DeduccionFondoPension') + +def assert_error(errors, msg): + for error in errors: + if str(error) == msg: + return True + + raise "wants error: %s" % (msg) +