diff --git a/cl_sii/cte/f29/parse_datos_obj.py b/cl_sii/cte/f29/parse_datos_obj.py index 3a8a52bf..dde6aa62 100644 --- a/cl_sii/cte/f29/parse_datos_obj.py +++ b/cl_sii/cte/f29/parse_datos_obj.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +import json from datetime import datetime from decimal import Decimal from pathlib import Path @@ -23,10 +25,17 @@ ) CTE_F29_DATOS_OBJ_SCHEMA = read_json_schema(_CTE_F29_DATOS_OBJ_SCHEMA_PATH) +_CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES_PATH = ( + Path(__file__).parent.parent.parent / 'data' / 'cte' / 'f29_datos_obj_missing_key_fixes.json' +) +CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES: SiiCteF29DatosObjType = json.load( + open(_CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES_PATH) +) + def parse_sii_cte_f29_datos_obj( datos_obj: SiiCteF29DatosObjType, - schema_validator: Optional[Callable[[SiiCteF29DatosObjType], None]] = None, + schema_validator: Optional[Callable[[SiiCteF29DatosObjType], SiiCteF29DatosObjType]] = None, campo_deserializer: Optional[Callable[[object, str], object]] = None, ) -> CteForm29: """ @@ -55,7 +64,7 @@ def parse_sii_cte_f29_datos_obj( def _parse_sii_cte_f29_datos_obj_to_dict( datos_obj: SiiCteF29DatosObjType, - schema_validator: Callable[[SiiCteF29DatosObjType], None], + schema_validator: Callable[[SiiCteF29DatosObjType], SiiCteF29DatosObjType], campo_deserializer: Callable[[object, str], object], ) -> Mapping[str, object]: """ @@ -67,17 +76,17 @@ def _parse_sii_cte_f29_datos_obj_to_dict( :param campo_deserializer: :raises JsonSchemaValidationError: If schema validation fails. """ - schema_validator(datos_obj) + validated_datos_obj = schema_validator(datos_obj) datos_obj_campos: Mapping[int, str] = { - int(code): str(value) for code, value in datos_obj['campos'].items() + int(code): str(value) for code, value in validated_datos_obj['campos'].items() } - datos_obj_extras: Mapping[str, object] = datos_obj['extras'] + datos_obj_extras: Mapping[str, object] = validated_datos_obj['extras'] datos_obj_glosa: Mapping[int, str] = { # noqa: F841 - int(code): str(value) for code, value in datos_obj['glosa'].items() + int(code): str(value) for code, value in validated_datos_obj['glosa'].items() } datos_obj_tipos: Mapping[int, str] = { - int(code): str(value) for code, value in datos_obj['tipos'].items() + int(code): str(value) for code, value in validated_datos_obj['tipos'].items() } deserialized_datos_obj_campos = { @@ -156,12 +165,14 @@ def cte_f29_datos_obj_campo_best_effort_deserializer(campo_value: object, tipo: return deserialized_value -def cte_f29_datos_schema_default_validator(datos_obj: SiiCteF29DatosObjType) -> None: +def cte_f29_datos_schema_default_validator( + datos_obj: SiiCteF29DatosObjType, +) -> SiiCteF29DatosObjType: """ Validate the ``datos`` object against the schema. :raises JsonSchemaValidationError: If schema validation fails. - :returns: ``None`` if schema validation passed. + :returns: Validated ``datos`` object if schema validation passed. """ try: jsonschema.validate(datos_obj, schema=CTE_F29_DATOS_OBJ_SCHEMA) @@ -172,3 +183,82 @@ def cte_f29_datos_schema_default_validator(datos_obj: SiiCteF29DatosObjType) -> raise JsonSchemaValidationError("The keys of 'campos' and 'tipos' are not exactly the same") if datos_obj['campos'].keys() != datos_obj['glosa'].keys(): raise JsonSchemaValidationError("The keys of 'campos' and 'tipos' are not exactly the same") + + return datos_obj + + +def cte_f29_datos_schema_best_effort_validator( + datos_obj: SiiCteF29DatosObjType, +) -> SiiCteF29DatosObjType: + """ + Validate the ``datos`` object against the schema. + + If there are missing keys in the `tipos` or `glosa` dicts, it will try to get them + from a list of default values. + + :raises JsonSchemaValidationError: If schema validation fails. + :returns: Validated ``datos`` object if schema validation passed. + """ + try: + validated_datos_obj = cte_f29_datos_schema_default_validator(datos_obj) + except JsonSchemaValidationError as exc: + if exc.__cause__ is jsonschema.exceptions.ValidationError: + # We will not try to fix this kind of error. + raise + elif exc.__cause__ is None: + # Let's try to fix this. + new_datos_obj = try_fix_cte_f29_datos(datos_obj) + + # Let's try again. + cte_f29_datos_schema_default_validator(new_datos_obj) + return new_datos_obj + else: + raise + else: + return validated_datos_obj + + +def try_fix_cte_f29_datos(datos_obj: SiiCteF29DatosObjType) -> SiiCteF29DatosObjType: + """ + Try to fix the ``datos`` object. + + If there are missing keys in the `tipos` or `glosa` dicts, it will try to get them + from a list of default values. + + :raises JsonSchemaValidationError: If an unfixable issue is found. + :returns: A possibly fixed ``datos`` object. + """ + new_datos_obj: Mapping[str, MutableMapping[str, object]] + new_datos_obj = copy.deepcopy(datos_obj) # type: ignore[arg-type] + + campos_tipos_keys_diff = datos_obj['campos'].keys() - datos_obj['tipos'].keys() + remaining_campos_tipos_diff = ( + campos_tipos_keys_diff - CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES['tipos'].keys() + ) + if remaining_campos_tipos_diff: + raise JsonSchemaValidationError( + "The keys of 'campos' and 'tipos' differ for the following codes: " + f"{remaining_campos_tipos_diff}" + ) + else: + for missing_key in campos_tipos_keys_diff: + new_datos_obj['tipos'][missing_key] = CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES['tipos'][ + missing_key + ] + + campos_glosa_keys_diff = datos_obj['campos'].keys() - datos_obj['glosa'].keys() + remaining_campos_glosa_diff = ( + campos_glosa_keys_diff - CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES['glosa'].keys() + ) + if remaining_campos_glosa_diff: + raise JsonSchemaValidationError( + "The keys of 'campos' and 'glosa' differ for the following codes: " + f"{remaining_campos_glosa_diff}" + ) + else: + for missing_key in campos_glosa_keys_diff: + new_datos_obj['glosa'][missing_key] = CTE_F29_DATOS_OBJ_MISSING_KEY_FIXES['glosa'][ + missing_key + ] + + return new_datos_obj diff --git a/cl_sii/data/cte/f29_datos_obj_missing_key_fixes.json b/cl_sii/data/cte/f29_datos_obj_missing_key_fixes.json new file mode 100644 index 00000000..41fd122d --- /dev/null +++ b/cl_sii/data/cte/f29_datos_obj_missing_key_fixes.json @@ -0,0 +1,8 @@ +{ + "glosa": { + "049": "(Desconocido)" + }, + "tipos": { + "049": "M" + } +} diff --git a/tests/test_cte_f29_parse_datos_obj.py b/tests/test_cte_f29_parse_datos_obj.py index a567ebe2..8e34bd86 100644 --- a/tests/test_cte_f29_parse_datos_obj.py +++ b/tests/test_cte_f29_parse_datos_obj.py @@ -216,3 +216,110 @@ def test_full_2(self) -> None: "fecha_presentacion": datetime.date(2019, 1, 22), } self.assertEqual(dict(obj), expected_dict) + + def test_full_3_file_with_missing_value(self) -> None: + datos_obj: Mapping[str, Any] = read_test_file_json_dict( + 'test_data/sii-cte/f29/cte--61002000-3--f29-6600000016-datos-obj-fake-missing-code-49.json', # noqa: E501 + ) + obj = parse_datos_obj.parse_sii_cte_f29_datos_obj( + datos_obj=datos_obj, + schema_validator=parse_datos_obj.cte_f29_datos_schema_best_effort_validator, + ) + + self.assertIsInstance(obj, data_models.CteForm29) + self.assertEqual(obj.contribuyente_rut, Rut('61002000-3')) + self.assertEqual(obj.periodo_tributario, PeriodoTributario(year=2017, month=7)) + self.assertEqual(obj.folio, 6600000016) + + expected_codes_dict = { + 1: "SERVICIO DE REGISTRO CIVIL E IDENTIFICACION", + 3: Rut('61002000-3'), + 6: "HUERFANOS 1570", + 7: 6600000016, + 8: "SANTIAGO", + 15: PeriodoTributario(year=2017, month=7), + 30: 723122062, + 48: 1409603, + 49: 211345, + 60: 70, + 89: 0, + 77: 11429763, + 91: 1536269, + 92: 0, + 93: 224400, + 94: 1603589, + 151: 126666, + 315: datetime.date(2017, 8, 28), + 502: 6588397, + 503: 9, + 504: 17037344, + 511: 980816, + 519: 30, + 520: 1481178, + 527: 2, + 528: 512805, + 537: 18018160, + 538: 6588397, + 547: 1536269, + 562: 17296416, + 584: 10, + 595: 1536269, + 761: 1, + 762: 12443, + 795: 157080, + 915: datetime.date(2017, 10, 31), + 922: "013-2015", + } + self.assertEqual(obj.as_codes_dict(include_none=False), expected_codes_dict) + + expected_dict = { + "contribuyente_rut": Rut('61002000-3'), + "periodo_tributario": PeriodoTributario(year=2017, month=7), + "folio": 6600000016, + "apellido_paterno_o_razon_social": "SERVICIO DE REGISTRO CIVIL E IDENTIFICACION", + "apellido_materno": None, + "nombres": None, + "calle_direccion": "HUERFANOS 1570", + "numero_direccion": None, + "comuna_direccion": "SANTIAGO", + "telefono": None, + "correo_electronico": None, + "representante_legal_rut": None, + "extra": { + 30: 723122062, + 48: 1409603, + 49: 211345, + 77: 11429763, + 89: 0, + 92: 0, + 93: 224400, + 151: 126666, + 502: 6588397, + 503: 9, + 504: 17037344, + 511: 980816, + 519: 30, + 520: 1481178, + 527: 2, + 528: 512805, + 537: 18018160, + 538: 6588397, + 547: 1536269, + 562: 17296416, + 584: 10, + 595: 1536269, + 761: 1, + 762: 12443, + 795: 157080, + }, + "total_a_pagar_en_plazo_legal": 1536269, + "total_a_pagar_con_recargo": 1603589, + "pct_condonacion": 70, + "num_res_condonacion": "013-2015", + "fecha_condonacion": datetime.date(2017, 10, 31), + "tipo_declaracion": "Primitiva", + "banco": "Banco del Patito Amarillo", + "medio_pago": "PEL", + "fecha_presentacion": datetime.date(2017, 8, 28), + } + self.assertEqual(dict(obj), expected_dict) diff --git a/tests/test_data/sii-cte/f29/cte--61002000-3--f29-6600000016-datos-obj-fake-missing-code-49.json b/tests/test_data/sii-cte/f29/cte--61002000-3--f29-6600000016-datos-obj-fake-missing-code-49.json new file mode 100644 index 00000000..238c32d8 --- /dev/null +++ b/tests/test_data/sii-cte/f29/cte--61002000-3--f29-6600000016-datos-obj-fake-missing-code-49.json @@ -0,0 +1,221 @@ +{ + "campos": { + "001": "SERVICIO DE REGISTRO CIVIL E IDENTIFICACION", + "003": "61002000-3", + "006": "HUERFANOS 1570", + "007": "6600000016", + "008": "SANTIAGO", + "015": "201707", + "030": "723122062", + "048": "1409603", + "049": "211345", + "060": "70", + "077": "11429763", + "089": "0", + "091": "1536269", + "092": "0", + "093": "224400", + "094": "1603589", + "151": "126666", + "315": "28/08/2017", + "502": "6588397", + "503": "9", + "504": "17037344", + "511": "980816", + "519": "30", + "520": "1481178", + "527": "2", + "528": "512805", + "537": "18018160", + "538": "6588397", + "547": "1536269", + "562": "17296416", + "584": "10", + "595": "1536269", + "761": "1", + "762": "12443", + "795": "157080", + "915": "31/10/2017", + "922": "013-2015" + }, + "extras": { + "ADM8703": "", + "ADM8721": "", + "BANCO": "Banco del Patito Amarillo", + "CALIDADDECLARANTE": "Contribuyente", + "CARTRIB": "1", + "CLASE": "Primitiva", + "CODINT": "580000019", + "COD_FORM": "F29", + "DV_ORIGEN": "3", + "FECHA_HOY": "16/10/2019", + "FECHA_INGRESO": "28082017", + "FOLIO": "6600000016", + "MEDIO_INGRESO": "INTERNET", + "MEDIO_PAGO": "PEL", + "MOSTRAR_FOLIO": "1", + "NOMBRES": " SERVICIO DE REGISTRO CIVIL E IDENTIFICACION ", + "NOMBRE_FUN": " ", + "OPCION": "VC", + "PERIODO": "201707", + "RUT_ORIGEN": "61002000", + "TIPO_MOVIMIENTO": "1", + "TOKEN": "VFC", + "USUARIO": "0", + "cant_anuladas": 0 + }, + "folioF": {}, + "glosa": { + "001": "APELLIDO PATERNO O RAZ\u00d3N SOCIAL", + "003": "N DE RUT", + "006": "DIRECCION", + "007": "FOLIO", + "008": "COMUNA", + "015": "PERIODO TRIBUTARIO", + "030": "PPM ART. 84, A) PERD. ART. 90", + "048": "RET. IMP. \u00daNICO TRAB. ART. 74 N 1 LIR", + "060": "PORCENTAJE CONDONACION TGR", + "077": "REMANENTE DE CR\u00c9DITO FISC.", + "089": "IMP. DETERM. IVA DETERM.", + "091": "TOTAL A PAGAR DENTRO DEL PLAZO", + "092": "REAJUSTES", + "093": "Intereses y multas", + "094": "Total a pagar con recargo", + "151": "RET, TASAS DE 10 % SOBRE LAS RENT. ", + "315": "FECHA TIMBRE CAJA", + "502": "D\u00c9BITOS FACTURAS EMITIDAS", + "503": "CANTIDAD FACTURAS EMITIDAS", + "504": "REMANENTE CREDITO MES ANTERIOR", + "511": "CR\u00c9D. IVA POR DCTOS. ELECTRONICOS", + "519": "CANT. DE DCTOS. FACT. RECIB. DEL GIRO", + "520": "CR\u00c9DITO REC. Y REINT./FACT. DEL GIRO", + "527": "CANT. NOTAS DE CR\u00c9DITO RECIBIDAS", + "528": "CR\u00c9DITO RECUP. Y REINT NOTAS DE CR\u00c9D", + "537": "TOTAL CR\u00c9DITOS", + "538": "TOTAL D\u00c9BITOS", + "547": "TOTAL DETERMINADO", + "562": "MONTO SIN DER. A CRED. FISCAL", + "584": "CANT.INT.EX.NO GRAV.SIN DER. CRED.FISCAL", + "595": "SUB TOTAL IMP. DETERMINADO ANVERSO", + "761": "CANT. FACT. SUPERMERCADOS Y SIMILARES", + "762": "CR\u00c9D. FACT. SUPERMERCADOS Y SIMILARES", + "795": "MONTO DE CONDONACION SII", + "915": "Fecha de Vigencia de Condonacion", + "922": "NUMERO RESOLUCION SII AUTO. CONDONACION" + }, + "justif": { + "001": "I", + "003": "I", + "006": "I", + "007": "I", + "008": "I", + "015": "I", + "030": "I", + "048": "I", + "060": "I", + "077": "I", + "089": "D", + "091": "-", + "092": "-", + "093": "-", + "094": "-", + "151": "I", + "315": "I", + "502": "D", + "503": "I", + "504": "D", + "511": "I", + "519": "I", + "520": "D", + "527": "I", + "528": "D", + "537": "D", + "538": "D", + "547": "D", + "562": "D", + "584": "I", + "595": "D", + "761": "I", + "762": "D", + "795": "I", + "915": "-", + "922": "-" + }, + "linea": { + "001": "0", + "003": "0", + "006": "0", + "007": "0", + "008": "0", + "015": "0", + "030": "56", + "048": "49", + "049": "0", + "060": "0", + "077": "47", + "089": "47", + "091": "119", + "092": "120", + "093": "121", + "094": "122", + "151": "50", + "315": "0", + "502": "7", + "503": "7", + "504": "33", + "511": "23", + "519": "27", + "520": "27", + "527": "29", + "528": "29", + "537": "46", + "538": "22", + "547": "104", + "562": "26", + "584": "26", + "595": "74", + "761": "27", + "762": "27", + "795": "0", + "915": "0", + "922": "0" + }, + "tipos": { + "001": "C", + "003": "R", + "006": "C", + "007": "N", + "008": "C", + "015": "N", + "030": "M", + "048": "M", + "060": "N", + "077": "M", + "089": "M", + "091": "M", + "092": "M", + "093": "M", + "094": "M", + "151": "M", + "315": "F", + "502": "M", + "503": "N", + "504": "M", + "511": "M", + "519": "N", + "520": "M", + "527": "N", + "528": "M", + "537": "M", + "538": "M", + "547": "M", + "562": "M", + "584": "N", + "595": "M", + "761": "N", + "762": "M", + "795": "M", + "915": "F", + "922": "C" + } + }