From 839a2d43de1855fe06b9d511995459bc355ba3e3 Mon Sep 17 00:00:00 2001 From: Samuel Villegas Date: Fri, 10 Oct 2025 13:12:05 -0300 Subject: [PATCH] fix(rcv): Refactor and extend parsing for "Otros Impuestos" field - This change addresses cases where multiple CSV rows with empty fields appeared, representing additional impuestos linked to the same folio as the main row. - Replaced individual fields (`codigo_otro_impuesto`, `valor_otro_impuesto`, `tasa_otro_impuesto`) with unified `otros_impuestos` structure. - Updated schemas, data models, and deserializers to handle "Otros Impuestos". - Adjusted tests and test data to validate new structure and edge cases. Ref: https://app.shortcut.com/cordada/story/16788/ --- src/cl_sii/rcv/data_models.py | 50 ++- src/cl_sii/rcv/parse_csv.py | 300 +++++++++++++----- .../sii-rcv/RCV-compra-reclamado.csv | 2 +- .../RCV-venta-extra-empty-impuestos-rows.csv | 5 + src/tests/test_rcv_data_models.py | 20 +- src/tests/test_rcv_parse_csv.py | 225 ++++++++++--- 6 files changed, 418 insertions(+), 184 deletions(-) create mode 100644 src/tests/test_data/sii-rcv/RCV-venta-extra-empty-impuestos-rows.csv diff --git a/src/cl_sii/rcv/data_models.py b/src/cl_sii/rcv/data_models.py index c389563e..0934669b 100644 --- a/src/cl_sii/rcv/data_models.py +++ b/src/cl_sii/rcv/data_models.py @@ -8,12 +8,13 @@ from __future__ import annotations import logging +from collections.abc import Sequence from datetime import date, datetime from decimal import Decimal from typing import ClassVar, Optional import pydantic -from typing_extensions import Self +from typing_extensions import Self, TypedDict import cl_sii.dte.data_models from cl_sii.base.constants import SII_OFFICIAL_TZ @@ -102,6 +103,23 @@ def as_datetime(self) -> datetime: ) +class OtrosImpuestos(TypedDict): + codigo_otro_impuesto: Optional[str] + """ + Codigo Otro Imp. + """ + + valor_otro_impuesto: Optional[int] + """ + Valor Otro Imp. + """ + + tasa_otro_impuesto: Optional[Decimal] + """ + Tasa Otro Imp. + """ + + @pydantic.dataclasses.dataclass( frozen=True, config=pydantic.ConfigDict( @@ -414,20 +432,7 @@ class RvDetalleEntry(RcvDetalleEntry): NCE o NDE sobre Fact. de Compra """ - codigo_otro_imp: Optional[str] - """ - Codigo Otro Imp. - """ - - valor_otro_imp: Optional[int] - """ - Valor Otro Imp. - """ - - tasa_otro_imp: Optional[float] - """ - Tasa Otro Imp. - """ + otros_impuestos: Optional[Sequence[OtrosImpuestos]] ########################################################################### # Validators @@ -537,20 +542,7 @@ class RcDetalleEntry(RcvDetalleEntry): NCE o NDE sobre Fact. de Compra """ - codigo_otro_impuesto: Optional[str] - """ - Codigo Otro Impuesto - """ - - valor_otro_impuesto: Optional[int] - """ - Valor Otro Impuesto - """ - - tasa_otro_impuesto: Optional[Decimal] - """ - Tasa Otro Impuesto - """ + otros_impuestos: Optional[Sequence[OtrosImpuestos]] ########################################################################### # Validators diff --git a/src/cl_sii/rcv/parse_csv.py b/src/cl_sii/rcv/parse_csv.py index 9cc469f4..0169b399 100644 --- a/src/cl_sii/rcv/parse_csv.py +++ b/src/cl_sii/rcv/parse_csv.py @@ -7,8 +7,8 @@ import csv import logging +from collections.abc import MutableMapping from datetime import date, datetime -from decimal import Decimal from typing import Any, Callable, Dict, Iterable, Optional, Sequence, Tuple, TypedDict, TypeVar import marshmallow @@ -653,21 +653,29 @@ class RcvVentaCsvRowSchema(_RcvCsvRowSchemaBase): allow_none=True, data_key='NCE o NDE sobre Fact. de Compra', ) - codigo_otro_imp = marshmallow.fields.String( - required=False, - allow_none=True, - data_key='Codigo Otro Imp.', - ) - valor_otro_imp = marshmallow.fields.Integer( - required=False, - allow_none=True, - data_key='Valor Otro Imp.', - ) - tasa_otro_imp = marshmallow.fields.Decimal( + + otros_impuestos = marshmallow.fields.List( required=False, allow_none=True, - data_key='Tasa Otro Imp.', + data_key='Otros Impuestos', + cls_or_instance=marshmallow.fields.Dict( + keys=marshmallow.fields.String(), + values=marshmallow.fields.Raw( + required=True, + allow_none=True, + ), + ), ) + """ + Represents the 'Otros Impuestos' group in the CSV as three separate fields: + 'Codigo Otro Impuesto', 'Valor Otro Impuesto', and 'Tasa Otro Impuesto'. + These fields are stored as a list of mappings, each mapping corresponding to a related invoice + with the following keys: + - Codigo Otro Imp.: String + - Valor Otro Imp.: Integer + - Tasa Otro Imp.: Decimal + """ + ########################################################################### # fields whose value is set using data passed in the schema context ########################################################################### @@ -704,6 +712,38 @@ def preprocess(self, in_data: dict, **kwargs: Any) -> dict: if 'RUT Emisor Liquid. Factura' in in_data: if in_data['RUT Emisor Liquid. Factura'] in (None, '', '-'): in_data['RUT Emisor Liquid. Factura'] = None + + # Remove Otros Impuestos individual fields if they exist + in_data.pop('Codigo Otro Imp.', None) + in_data.pop('Valor Otro Imp.', None) + in_data.pop('Tasa Otro Imp.', None) + + if 'Otros Impuestos' in in_data and in_data['Otros Impuestos']: + otros_impuestos_list = [ + { + 'codigo_otro_impuesto': item.get('codigo_otro_impuesto') or None, + 'valor_otro_impuesto': ( + item['valor_otro_impuesto'] + if item.get('valor_otro_impuesto') not in (None, '', '-') + else None + ), + 'tasa_otro_impuesto': ( + item['tasa_otro_impuesto'] + if item.get('tasa_otro_impuesto') not in (None, '', '-') + else None + ), + } + for item in in_data['Otros Impuestos'] + if any( + [ + item.get('codigo_otro_impuesto') not in (None, ''), + item.get('valor_otro_impuesto') not in (None, '', '-'), + item.get('tasa_otro_impuesto') not in (None, '', '-'), + ] + ) + ] + in_data['Otros Impuestos'] = otros_impuestos_list or None + return in_data @marshmallow.post_load @@ -777,9 +817,7 @@ def to_detalle_entry(self, data: dict) -> RvDetalleEntry: numero_interno = data['numero_interno'] codigo_sucursal = data['codigo_sucursal'] nce_o_nde_sobre_factura_de_compra = data['nce_o_nde_sobre_factura_de_compra'] - codigo_otro_imp = data['codigo_otro_imp'] - valor_otro_imp = data['valor_otro_imp'] - tasa_otro_imp = data['tasa_otro_imp'] + otros_impuestos = data.get('otros_impuestos', None) except KeyError as exc: raise ValueError("Programming error: a referenced field is missing.") from exc @@ -825,9 +863,7 @@ def to_detalle_entry(self, data: dict) -> RvDetalleEntry: numero_interno=numero_interno, codigo_sucursal=codigo_sucursal, nce_o_nde_sobre_factura_de_compra=nce_o_nde_sobre_factura_de_compra, - codigo_otro_imp=codigo_otro_imp, - valor_otro_imp=valor_otro_imp, - tasa_otro_imp=tasa_otro_imp, + otros_impuestos=otros_impuestos, ) except (TypeError, ValueError): raise @@ -880,9 +916,6 @@ class RcvCompraCsvRowSchema(_RcvCsvRowSchemaBase): required=True, data_key='Razon Social', ) - receptor_rut = mm_fields.RutField( - required=True, - ) fecha_recepcion_dt = marshmallow.fields.DateTime( format='%d/%m/%Y %H:%M:%S', required=True, @@ -941,21 +974,27 @@ class RcvCompraCsvRowSchema(_RcvCsvRowSchemaBase): allow_none=True, data_key='NCE o NDE sobre Fact. de Compra', ) - codigo_otro_impuesto = marshmallow.fields.String( + otros_impuestos = marshmallow.fields.List( required=False, allow_none=True, - data_key='Codigo Otro Impuesto', - ) - valor_otro_impuesto = marshmallow.fields.Integer( - required=False, - allow_none=True, - data_key='Valor Otro Impuesto', - ) - tasa_otro_impuesto = marshmallow.fields.Decimal( - required=False, - allow_none=True, - data_key='Tasa Otro Impuesto', + data_key='Otros Impuestos', + cls_or_instance=marshmallow.fields.Dict( + keys=marshmallow.fields.String(), + values=marshmallow.fields.Raw( + required=True, + allow_none=True, + ), + ), ) + """ + Represents the 'Otros Impuestos' group in the CSV as three separate fields: + 'Codigo Otro Impuesto', 'Valor Otro Impuesto', and 'Tasa Otro Impuesto'. + These fields are stored as a list of mappings, each mapping corresponding to a related invoice + with the following keys: + - Codigo Otro Impuesto: String + - Valor Otro Impuesto: Integer + - Tasa Otro Impuesto: Decimal + """ ########################################################################### # fields whose value is set using data passed in the schema context @@ -991,6 +1030,36 @@ def preprocess(self, in_data: dict, **kwargs: Any) -> dict: elif in_data['Tipo Compra'] == 'No Corresp. Incluir': in_data['Tipo Compra'] = RcTipoCompra.NO_CORRESPONDE_INCLUIR.value + # remove Otros Impuestos individual fields if they exist + in_data.pop('Codigo Otro Impuesto', None) + in_data.pop('Valor Otro Impuesto', None) + in_data.pop('Tasa Otro Impuesto', None) + + if 'Otros Impuestos' in in_data and in_data['Otros Impuestos']: + otros_impuestos_list = [ + { + 'codigo_otro_impuesto': item.get('codigo_otro_impuesto') or None, + 'valor_otro_impuesto': ( + item['valor_otro_impuesto'] + if item.get('valor_otro_impuesto') not in (None, '', '-') + else None + ), + 'tasa_otro_impuesto': ( + item['tasa_otro_impuesto'] + if item.get('tasa_otro_impuesto') not in (None, '', '-') + else None + ), + } + for item in in_data['Otros Impuestos'] + if any( + [ + item.get('codigo_otro_impuesto') not in (None, ''), + item.get('valor_otro_impuesto') not in (None, '', '-'), + item.get('tasa_otro_impuesto') not in (None, '', '-'), + ] + ) + ] + in_data['Otros Impuestos'] = otros_impuestos_list or None return in_data @marshmallow.post_load @@ -1105,9 +1174,7 @@ def to_detalle_entry(self, data: dict) -> RcRegistroDetalleEntry: nce_o_nde_sobre_factura_de_compra: Optional[str] = data.get( 'nce_o_nde_sobre_factura_de_compra' ) - codigo_otro_impuesto: Optional[str] = data.get('codigo_otro_impuesto') - valor_otro_impuesto: Optional[int] = data.get('valor_otro_impuesto') - tasa_otro_impuesto: Optional[Decimal] = data.get('tasa_otro_impuesto') + otros_impuestos = data.get('otros_impuestos', None) fecha_acuse_dt: Optional[datetime] = data.get('fecha_acuse_dt') tabacos_puros: Optional[int] = data.get('tabacos_puros') tabacos_cigarrillos: Optional[int] = data.get('tabacos_cigarrillos') @@ -1137,9 +1204,7 @@ def to_detalle_entry(self, data: dict) -> RcRegistroDetalleEntry: impto_sin_derecho_a_credito=impto_sin_derecho_a_credito, iva_no_retenido=iva_no_retenido, nce_o_nde_sobre_factura_de_compra=nce_o_nde_sobre_factura_de_compra, - codigo_otro_impuesto=codigo_otro_impuesto, - valor_otro_impuesto=valor_otro_impuesto, - tasa_otro_impuesto=tasa_otro_impuesto, + otros_impuestos=otros_impuestos, fecha_acuse_dt=fecha_acuse_dt, tabacos_puros=tabacos_puros, tabacos_cigarrillos=tabacos_cigarrillos, @@ -1225,9 +1290,7 @@ def to_detalle_entry(self, data: dict) -> RcNoIncluirDetalleEntry: nce_o_nde_sobre_factura_de_compra: Optional[str] = data.get( 'nce_o_nde_sobre_factura_de_compra' ) - codigo_otro_impuesto: Optional[str] = data.get('codigo_otro_impuesto') - valor_otro_impuesto: Optional[int] = data.get('valor_otro_impuesto') - tasa_otro_impuesto: Optional[Decimal] = data.get('tasa_otro_impuesto') + otros_impuestos = data.get('otros_impuestos', None) fecha_acuse_dt: Optional[datetime] = data.get('fecha_acuse_dt') except KeyError as exc: raise ValueError("Programming error: a referenced field is missing.") from exc @@ -1254,9 +1317,7 @@ def to_detalle_entry(self, data: dict) -> RcNoIncluirDetalleEntry: impto_sin_derecho_a_credito=impto_sin_derecho_a_credito, iva_no_retenido=iva_no_retenido, nce_o_nde_sobre_factura_de_compra=nce_o_nde_sobre_factura_de_compra, - codigo_otro_impuesto=codigo_otro_impuesto, - valor_otro_impuesto=valor_otro_impuesto, - tasa_otro_impuesto=tasa_otro_impuesto, + otros_impuestos=otros_impuestos, fecha_acuse_dt=fecha_acuse_dt, ) except (TypeError, ValueError): @@ -1346,9 +1407,7 @@ def to_detalle_entry(self, data: dict) -> RcReclamadoDetalleEntry: nce_o_nde_sobre_factura_de_compra: Optional[str] = data.get( 'nce_o_nde_sobre_factura_de_compra' ) - codigo_otro_impuesto: Optional[str] = data.get('codigo_otro_impuesto') - valor_otro_impuesto: Optional[int] = data.get('valor_otro_impuesto') - tasa_otro_impuesto: Optional[Decimal] = data.get('tasa_otro_impuesto') + otros_impuestos = data.get('otros_impuestos') fecha_reclamo_dt: Optional[datetime] = data.get('fecha_reclamo_dt') except KeyError as exc: raise ValueError("Programming error: a referenced field is missing.") from exc @@ -1375,9 +1434,7 @@ def to_detalle_entry(self, data: dict) -> RcReclamadoDetalleEntry: impto_sin_derecho_a_credito=impto_sin_derecho_a_credito, iva_no_retenido=iva_no_retenido, nce_o_nde_sobre_factura_de_compra=nce_o_nde_sobre_factura_de_compra, - codigo_otro_impuesto=codigo_otro_impuesto, - valor_otro_impuesto=valor_otro_impuesto, - tasa_otro_impuesto=tasa_otro_impuesto, + otros_impuestos=otros_impuestos, fecha_reclamo_dt=fecha_reclamo_dt, ) except (TypeError, ValueError): @@ -1431,9 +1488,7 @@ def to_detalle_entry(self, data: dict) -> RcPendienteDetalleEntry: nce_o_nde_sobre_factura_de_compra: Optional[str] = data.get( 'nce_o_nde_sobre_factura_de_compra' ) - codigo_otro_impuesto: Optional[str] = data.get('codigo_otro_impuesto') - valor_otro_impuesto: Optional[int] = data.get('valor_otro_impuesto') - tasa_otro_impuesto: Optional[Decimal] = data.get('tasa_otro_impuesto') + otros_impuestos = data.get('otros_impuestos') except KeyError as exc: raise ValueError("Programming error: a referenced field is missing.") from exc @@ -1459,9 +1514,7 @@ def to_detalle_entry(self, data: dict) -> RcPendienteDetalleEntry: impto_sin_derecho_a_credito=impto_sin_derecho_a_credito, iva_no_retenido=iva_no_retenido, nce_o_nde_sobre_factura_de_compra=nce_o_nde_sobre_factura_de_compra, - codigo_otro_impuesto=codigo_otro_impuesto, - valor_otro_impuesto=valor_otro_impuesto, - tasa_otro_impuesto=tasa_otro_impuesto, + otros_impuestos=otros_impuestos, ) except (TypeError, ValueError): raise @@ -1537,35 +1590,106 @@ def _parse_rcv_csv_file( expected_field_names=expected_input_field_names, ) - g = rows_processing.csv_rows_mm_deserialization_iterator( - csv_reader, - row_schema=input_csv_row_schema, - n_rows_offset=n_rows_offset, - max_n_rows=max_n_rows, - fields_to_remove_names=fields_to_remove_names, - ) - - for row_ix, row_data, deserialized_row_data, validation_errors in g: - entry: Optional[RcvDetalleEntry] = None - row_errors: Dict[str, object] = {} - conversion_error = None + # Group rows by folio and handle "Otros Impuestos" logic + folio_groups: MutableMapping[str, Any] = {} - if not validation_errors: + # Otros Impuestos field names + if isinstance(input_csv_row_schema, RcvVentaCsvRowSchema): + codigo_otro_impuesto_key = "Codigo Otro Imp." + valor_otro_impuesto_key = "Valor Otro Imp." + tasa_otro_impuesto_key = "Tasa Otro Imp." + else: + codigo_otro_impuesto_key = "Codigo Otro Impuesto" + valor_otro_impuesto_key = "Valor Otro Impuesto" + tasa_otro_impuesto_key = "Tasa Otro Impuesto" + + # First pass: collect all rows and group by folio + for row_ix, row_data in enumerate(csv_reader, start=1): + if max_n_rows is not None and row_ix > max_n_rows + n_rows_offset: + raise rows_processing.MaxRowsExceeded(f"Exceeded 'max_n_rows' limit: {max_n_rows}.") + + if row_ix <= n_rows_offset: + continue + + for _field_name in fields_to_remove_names: + row_data.pop(_field_name, None) + + folio = str(row_data.get('Folio')) if row_data.get('Folio') is not None else '' + + # If both fields are None, it's an "otros impuestos" row + is_main = not ( + ( + row_data.get("Nro") in (None, "") + and row_data.get("Fecha Recepcion") in (None, "") + ) + and (row_data.get("Monto Total") in (None, "")) + ) + otros_impuestos_data = { + 'codigo_otro_impuesto': ( + None + if row_data.get(codigo_otro_impuesto_key) == '' + else row_data.get(codigo_otro_impuesto_key) + ), + 'valor_otro_impuesto': ( + None + if row_data.get(valor_otro_impuesto_key) == '' + else row_data.get(valor_otro_impuesto_key) + ), + 'tasa_otro_impuesto': ( + None + if row_data.get(tasa_otro_impuesto_key) == '' + else row_data.get(tasa_otro_impuesto_key) + ), + } + + if folio not in folio_groups: + folio_groups[folio] = { + 'row': (row_ix, row_data), + 'otros_impuestos': [otros_impuestos_data], + } + if not is_main and folio in folio_groups: + if any(otros_impuestos_data.values()): + folio_groups[folio]['otros_impuestos'].append(otros_impuestos_data) + + # Second pass: yield grouped rows + for folio, group in folio_groups.items(): + if group['row']: + row_ix, row_data = group['row'] + row_data['Otros Impuestos'] = group['otros_impuestos'] + + # Remove individual "Otros Impuestos" fields + row_data.pop(codigo_otro_impuesto_key, None) + row_data.pop(tasa_otro_impuesto_key, None) + row_data.pop(valor_otro_impuesto_key, None) + + # Deserialize the row try: - entry = input_csv_row_schema.to_detalle_entry(deserialized_row_data) - except Exception as exc: - conversion_error = str(exc) - logger.exception( - "Deserialized row data conversion failed for row %d: %s", - row_ix, - conversion_error, - extra={'deserialized_row_data': deserialized_row_data}, - ) - - # Instead of empty dicts, lists, str, etc, we want to have None. - if validation_errors: - row_errors['validation'] = validation_errors - if conversion_error: - row_errors['conversion_errors'] = conversion_error - - yield entry, row_ix, row_data, row_errors + deserialized_row_data: dict = input_csv_row_schema.load(row_data) + validation_errors: dict = {} + except marshmallow.ValidationError as exc: + deserialized_row_data = {} + validation_errors = dict(exc.normalized_messages()) + + entry: Optional[RcvDetalleEntry] = None + row_errors: Dict[str, object] = {} + conversion_error = None + + if not validation_errors: + try: + entry = input_csv_row_schema.to_detalle_entry(deserialized_row_data) + except Exception as exc: + conversion_error = str(exc) + logger.exception( + "Deserialized row data conversion failed for row %d: %s", + row_ix, + conversion_error, + extra={'deserialized_row_data': deserialized_row_data}, + ) + + # Instead of empty dicts, lists, str, etc, we want to have None. + if validation_errors: + row_errors['validation'] = validation_errors + if conversion_error: + row_errors['conversion_errors'] = conversion_error + + yield entry, row_ix, row_data, row_errors diff --git a/src/tests/test_data/sii-rcv/RCV-compra-reclamado.csv b/src/tests/test_data/sii-rcv/RCV-compra-reclamado.csv index 5ebdcea7..bce9e88b 100644 --- a/src/tests/test_data/sii-rcv/RCV-compra-reclamado.csv +++ b/src/tests/test_data/sii-rcv/RCV-compra-reclamado.csv @@ -1,5 +1,5 @@ Nro;Tipo Doc;Tipo Compra;RUT Proveedor;Razon Social;Folio;Fecha Docto;Fecha Recepcion;Fecha Reclamo;Monto Exento;Monto Neto;Monto IVA Recuperable;Monto Iva No Recuperable;Codigo IVA No Rec.;Monto Total;Monto Neto Activo Fijo;IVA Activo Fijo;IVA uso Comun;Impto. Sin Derecho a Credito;IVA No Retenido;NCE o NDE sobre Fact. de Compra;Codigo Otro Impuesto;Valor Otro Impuesto;Tasa Otro Impuesto -1;33;Del Giro;12345678-5;Fake Company S.A. ;1000055;05/06/2019;05/06/2019 21:58:49;12/06/2019 09:47:23;0;970894;184470;;;1155364;;;;;0;0;;;2.967999999; +1;33;Del Giro;12345678-5;Fake Company S.A. ;1000055;05/06/2019;05/06/2019 21:58:49;12/06/2019 09:47:23;0;970894;184470;;;1155364;;;;;0;0;23;12000;2.967999999; 2;61;Del Giro;12345678-5; Fake Company S.A.;70013;24/06/2019;24/06/2019 15:24:41;null;0;1652840;314040;;;1966880;;;;;0;0;;;; 3;33;Del Giro;76354771-K;Fake Company S.A. ;789456;05/06/2019;05/06/2019 21:58:49;;0;970894;184470;;;1155364;;;;;0;0;;;; 4;33;Del Giro;INVALID-RUT;Fake Company S.A.;notanumber;invalid-date;invalid-datetime;invalid-datetime;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber;notanumber diff --git a/src/tests/test_data/sii-rcv/RCV-venta-extra-empty-impuestos-rows.csv b/src/tests/test_data/sii-rcv/RCV-venta-extra-empty-impuestos-rows.csv new file mode 100644 index 00000000..5e9f99e9 --- /dev/null +++ b/src/tests/test_data/sii-rcv/RCV-venta-extra-empty-impuestos-rows.csv @@ -0,0 +1,5 @@ +Nro;Tipo Doc;Tipo Venta;Rut cliente;Razon Social;Folio;Fecha Docto;Fecha Recepcion;Fecha Acuse Recibo;Fecha Reclamo;Monto Exento;Monto Neto;Monto IVA;Monto total;IVA Retenido Total;IVA Retenido Parcial;IVA no retenido;IVA propio;IVA Terceros;RUT Emisor Liquid. Factura;Neto Comision Liquid. Factura;Exento Comision Liquid. Factura;IVA Comision Liquid. Factura;IVA fuera de plazo;Tipo Docto. Referencia;Folio Docto. Referencia;Num. Ident. Receptor Extranjero;Nacionalidad Receptor Extranjero;Credito empresa constructora;Impto. Zona Franca (Ley 18211);Garantia Dep. Envases;Indicador Venta sin Costo;Indicador Servicio Periodico;Monto No facturable;Total Monto Periodo;Venta Pasajes Transporte Nacional;Venta Pasajes Transporte Internacional;Numero Interno;Codigo Sucursal;NCE o NDE sobre Fact. de Compra;Codigo Otro Imp.;Valor Otro Imp.;Tasa Otro Imp. +1;33;Del Giro;54213736-3;CHILE SPA;6541;01/09/2025;01/09/2025 10:09:00;08/09/2025 14:15:23;;0;7217280;1371283;9565862;0;0;0;0;0;-;0;0;0;0;0;;;;0;;0;2;0;0;0;;;;12354;;27;275904;10;;;;;; +;33;Del Giro;54213736-3;CHILE SPA;6541;01/09/2025;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;271;701395;18;;;;;; +2;33;Del Giro;42509414-9;COMERCIAL SPA;9874;01/09/2025;01/09/2025 09:53:17;;;0;8879040;1687018;13136156;0;0;0;0;0;-;0;0;0;0;0;;;;0;;0;2;0;0;0;;;;12354;;24;2570098;31.5 +3;33;Del Giro;68840666-8;TEXAS SPA;3210;01/09/2025;01/09/2025 10:58:51;08/09/2025 14:15:23;;0;20522880;3899347;30471437;0;0;0;0;0;-;0;0;0;0;0;;;;0;;0;2;0;0;0;;;;12354;;24;6049210;31.5; diff --git a/src/tests/test_rcv_data_models.py b/src/tests/test_rcv_data_models.py index 870d959a..514abbd0 100644 --- a/src/tests/test_rcv_data_models.py +++ b/src/tests/test_rcv_data_models.py @@ -261,9 +261,7 @@ def setUp(self) -> None: numero_interno=None, codigo_sucursal=None, nce_o_nde_sobre_factura_de_compra=None, - codigo_otro_imp=None, - valor_otro_imp=None, - tasa_otro_imp=None, + otros_impuestos=None, ) def test_constants_match(self) -> None: @@ -445,9 +443,7 @@ def setUp(self) -> None: tabacos_cigarrillos=0, tabacos_elaborados=0, nce_o_nde_sobre_factura_de_compra=None, - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, ) def test_constants_match(self) -> None: @@ -569,9 +565,7 @@ def setUp(self) -> None: impto_sin_derecho_a_credito=0, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra=None, - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, ) @@ -607,9 +601,7 @@ def setUp(self) -> None: impto_sin_derecho_a_credito=0, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra=None, - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, ) def test_constants_match(self) -> None: @@ -727,9 +719,7 @@ def setUp(self) -> None: impto_sin_derecho_a_credito=0, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra=None, - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, ) def test_validate_emisor_razon_social_empty(self) -> None: diff --git a/src/tests/test_rcv_parse_csv.py b/src/tests/test_rcv_parse_csv.py index 746ec8f4..c1264194 100644 --- a/src/tests/test_rcv_parse_csv.py +++ b/src/tests/test_rcv_parse_csv.py @@ -11,6 +11,7 @@ from cl_sii.base.constants import SII_OFFICIAL_TZ from cl_sii.libs.tz_utils import convert_naive_dt_to_tz_aware from cl_sii.rcv.data_models import ( + OtrosImpuestos, RcNoIncluirDetalleEntry, RcPendienteDetalleEntry, RcReclamadoDetalleEntry, @@ -85,9 +86,13 @@ def test_parse_rcv_ventas_row(self) -> None: 'Numero Interno': '', 'Codigo Sucursal': '0', 'NCE o NDE sobre Fact. de Compra': '', - 'Codigo Otro Imp.': '', - 'Valor Otro Imp.': '', - 'Tasa Otro Imp.': '', + 'Otros Impuestos': [ + { + 'codigo_otro_impuesto': '', + 'valor_otro_impuesto': '', + 'tasa_otro_impuesto': '', + } + ], } with _RcvVentaCsvRowSchemaContext(schema_context): @@ -137,9 +142,7 @@ def test_parse_rcv_ventas_row(self) -> None: numero_interno=None, codigo_sucursal='0', nce_o_nde_sobre_factura_de_compra=None, - codigo_otro_imp=None, - valor_otro_imp=None, - tasa_otro_imp=None, + otros_impuestos=None, ) self.assertEqual(result, expected_result) @@ -209,9 +212,7 @@ def test_parse_rcv_compra_registro_row(self) -> None: impto_sin_derecho_a_credito=None, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra='0', - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, fecha_acuse_dt=convert_naive_dt_to_tz_aware( datetime.datetime(2019, 6, 30, 9, 55, 53), tz=SII_OFFICIAL_TZ ), @@ -250,9 +251,9 @@ def test_parse_rcv_compra_no_incluir_row(self) -> None: 'Impto. Sin Derecho a Credito': '', 'IVA No Retenido': '0', 'NCE o NDE sobre Fact. de Compra': '0', - 'Codigo Otro Impuesto': '', - 'Valor Otro Impuesto': '', - 'Tasa Otro Impuesto': '', + 'Codigo Otro Impuesto': '23', + 'Valor Otro Impuesto': '1200', + 'Tasa Otro Impuesto': '31.5', } with _RcvCompraCsvRowSchemaContext(schema_context): @@ -282,9 +283,7 @@ def test_parse_rcv_compra_no_incluir_row(self) -> None: impto_sin_derecho_a_credito=None, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra='0', - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, fecha_acuse_dt=None, ) @@ -351,9 +350,7 @@ def test_parse_rcv_compra_reclamado_row(self) -> None: impto_sin_derecho_a_credito=None, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra='0', - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, fecha_reclamo_dt=convert_naive_dt_to_tz_aware( datetime.datetime(2019, 6, 12, 9, 47, 23), tz=SII_OFFICIAL_TZ ), @@ -421,9 +418,7 @@ def test_parse_rcv_compra_pendiente_row(self) -> None: impto_sin_derecho_a_credito=None, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra='0', - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, ) self.assertEqual(result, expected_result) @@ -487,9 +482,7 @@ def test_parse_rcv_venta_csv_file(self) -> None: numero_interno=None, codigo_sucursal='0', nce_o_nde_sobre_factura_de_compra=None, - codigo_otro_imp=None, - valor_otro_imp=None, - tasa_otro_imp=None, + otros_impuestos=None, ) # First row: entry_struct, row_ix, row_data, row_parsing_errors = next(items) @@ -591,7 +584,7 @@ def test_parse_rcv_venta_csv_file_conversion_error(self) -> None: rut=Rut('1-9'), input_file_path=rcv_file_path, n_rows_offset=0, - max_n_rows=1, + max_n_rows=2, ) assert isinstance(items, Iterable) and isinstance(items, Iterator) entry_struct, row_ix, row_data, row_parsing_errors = next(items) @@ -606,6 +599,136 @@ def test_parse_rcv_venta_csv_file_conversion_error(self) -> None: ), ) + def test_parse_rcv_venta_csv_file_empty_otros_impuestos_rows(self) -> None: + rcv_file_path = get_test_file_path( + 'test_data/sii-rcv/RCV-venta-extra-empty-impuestos-rows.csv', + ) + + items = parse_rcv_venta_csv_file( + rut=Rut('1-9'), + input_file_path=rcv_file_path, + ) + assert isinstance(items, Iterable) and isinstance(items, Iterator) + entry_struct, row_ix, row_data, row_parsing_errors = next(items) + + expected_entry_struct = RvDetalleEntry( + emisor_rut=Rut('1-9'), + tipo_docto=cl_sii.rcv.constants.RcvTipoDocto.FACTURA_ELECTRONICA, + folio=6541, + fecha_emision_date=datetime.date(2025, 9, 1), + receptor_rut=Rut('54213736-3'), + monto_total=9565862, + fecha_recepcion_dt=convert_naive_dt_to_tz_aware( + dt=datetime.datetime(2025, 9, 1, 10, 9), + tz=SII_OFFICIAL_TZ, + ), + tipo_venta='DEL_GIRO', + receptor_razon_social='CHILE SPA', + fecha_acuse_dt=convert_naive_dt_to_tz_aware( + dt=datetime.datetime(2025, 9, 8, 14, 15, 23), + tz=SII_OFFICIAL_TZ, + ), + fecha_reclamo_dt=None, + monto_exento=0, + monto_neto=7217280, + monto_iva=1371283, + iva_retenido_total=0, + iva_retenido_parcial=0, + iva_no_retenido=0, + iva_propio=0, + iva_terceros=0, + liquidacion_factura_emisor_rut=None, + neto_comision_liquidacion_factura=0, + exento_comision_liquidacion_factura=0, + iva_comision_liquidacion_factura=0, + iva_fuera_de_plazo=0, + tipo_documento_referencia=0, + folio_documento_referencia=None, + num_ident_receptor_extranjero=None, + nacionalidad_receptor_extranjero=None, + credito_empresa_constructora=0, + impuesto_zona_franca_ley_18211=None, + garantia_dep_envases=0, + indicador_venta_sin_costo=2, + indicador_servicio_periodico=0, + monto_no_facturable=0, + total_monto_periodo=0, + venta_pasajes_transporte_nacional=None, + venta_pasajes_transporte_internacional=None, + numero_interno=None, + codigo_sucursal='12354', + nce_o_nde_sobre_factura_de_compra=None, + otros_impuestos=[ + OtrosImpuestos( + codigo_otro_impuesto='27', + valor_otro_impuesto=275904, + tasa_otro_impuesto=Decimal('10'), + ), + OtrosImpuestos( + codigo_otro_impuesto='271', + valor_otro_impuesto=701395, + tasa_otro_impuesto=Decimal('18'), + ), + ], + ) + expected_row_data = { + 'Tipo Doc': '33', + 'Tipo Venta': 'DEL_GIRO', + 'Rut cliente': '54213736-3', + 'Razon Social': 'CHILE SPA', + 'Folio': '6541', + 'Fecha Docto': '01/09/2025', + 'Fecha Recepcion': '01/09/2025 10:09:00', + 'Fecha Acuse Recibo': '08/09/2025 14:15:23', + 'Fecha Reclamo': None, + 'Monto Exento': '0', + 'Monto Neto': '7217280', + 'Monto IVA': '1371283', + 'Monto total': '9565862', + 'IVA Retenido Total': '0', + 'IVA Retenido Parcial': '0', + 'IVA no retenido': '0', + 'IVA propio': '0', + 'IVA Terceros': '0', + 'RUT Emisor Liquid. Factura': None, + 'Neto Comision Liquid. Factura': '0', + 'Exento Comision Liquid. Factura': '0', + 'IVA Comision Liquid. Factura': '0', + 'IVA fuera de plazo': '0', + 'Tipo Docto. Referencia': '0', + 'Folio Docto. Referencia': None, + 'Num. Ident. Receptor Extranjero': None, + 'Nacionalidad Receptor Extranjero': None, + 'Credito empresa constructora': '0', + 'Impto. Zona Franca (Ley 18211)': None, + 'Garantia Dep. Envases': '0', + 'Indicador Venta sin Costo': '2', + 'Indicador Servicio Periodico': '0', + 'Monto No facturable': '0', + 'Total Monto Periodo': '0', + 'Venta Pasajes Transporte Nacional': None, + 'Venta Pasajes Transporte Internacional': None, + 'Numero Interno': None, + 'Codigo Sucursal': '12354', + 'NCE o NDE sobre Fact. de Compra': None, + 'Otros Impuestos': [ + { + 'codigo_otro_impuesto': '27', + 'valor_otro_impuesto': '275904', + 'tasa_otro_impuesto': '10', + }, + { + 'codigo_otro_impuesto': '271', + 'valor_otro_impuesto': '701395', + 'tasa_otro_impuesto': '18', + }, + ], + 'emisor_rut': Rut('1-9'), + } + + self.assertEqual(row_data, expected_row_data) + self.assertEqual(entry_struct, expected_entry_struct) + def test_parse_rcv_compra_registro_csv_file(self) -> None: # TODO: implement for 'parse_rcv_compra_registro_csv_file'. pass @@ -670,9 +793,13 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: impto_sin_derecho_a_credito=None, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra='0', - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=Decimal('2.967999999'), + otros_impuestos=[ + OtrosImpuestos( + codigo_otro_impuesto='23', + valor_otro_impuesto=12000, + tasa_otro_impuesto=Decimal('2.967999999'), + ) + ], fecha_reclamo_dt=convert_naive_dt_to_tz_aware( dt=datetime.datetime(2019, 6, 12, 9, 47, 23), tz=SII_OFFICIAL_TZ, @@ -700,9 +827,13 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: 'Impto. Sin Derecho a Credito': None, 'IVA No Retenido': '0', 'NCE o NDE sobre Fact. de Compra': '0', - 'Codigo Otro Impuesto': None, - 'Valor Otro Impuesto': None, - 'Tasa Otro Impuesto': '2.967999999', + 'Otros Impuestos': [ + { + 'codigo_otro_impuesto': '23', + 'valor_otro_impuesto': '12000', + 'tasa_otro_impuesto': '2.967999999', + }, + ], 'receptor_rut': Rut('1-9'), }, {}, @@ -732,9 +863,7 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: impto_sin_derecho_a_credito=None, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra='0', - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, fecha_reclamo_dt=None, ), 2, @@ -759,9 +888,7 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: 'Impto. Sin Derecho a Credito': None, 'IVA No Retenido': '0', 'NCE o NDE sobre Fact. de Compra': '0', - 'Codigo Otro Impuesto': None, - 'Valor Otro Impuesto': None, - 'Tasa Otro Impuesto': None, + 'Otros Impuestos': None, 'receptor_rut': Rut('1-9'), }, {}, @@ -791,9 +918,7 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: impto_sin_derecho_a_credito=None, iva_no_retenido=0, nce_o_nde_sobre_factura_de_compra='0', - codigo_otro_impuesto=None, - valor_otro_impuesto=None, - tasa_otro_impuesto=None, + otros_impuestos=None, fecha_reclamo_dt=None, ), 3, @@ -818,9 +943,7 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: 'Impto. Sin Derecho a Credito': None, 'IVA No Retenido': '0', 'NCE o NDE sobre Fact. de Compra': '0', - 'Codigo Otro Impuesto': None, - 'Valor Otro Impuesto': None, - 'Tasa Otro Impuesto': None, + 'Otros Impuestos': None, 'receptor_rut': Rut('1-9'), }, {}, @@ -849,9 +972,13 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: 'Impto. Sin Derecho a Credito': 'notanumber', 'IVA No Retenido': 'notanumber', 'NCE o NDE sobre Fact. de Compra': 'notanumber', - 'Codigo Otro Impuesto': 'notanumber', - 'Valor Otro Impuesto': 'notanumber', - 'Tasa Otro Impuesto': 'notanumber', + 'Otros Impuestos': [ + { + 'codigo_otro_impuesto': 'notanumber', + 'tasa_otro_impuesto': 'notanumber', + 'valor_otro_impuesto': 'notanumber', + } + ], 'receptor_rut': Rut('1-9'), }, { @@ -870,8 +997,6 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: 'IVA uso Comun': ['Not a valid integer.'], 'Impto. Sin Derecho a Credito': ['Not a valid integer.'], 'IVA No Retenido': ['Not a valid integer.'], - 'Valor Otro Impuesto': ['Not a valid integer.'], - 'Tasa Otro Impuesto': ['Not a valid number.'], 'Fecha Reclamo': ['Not a valid datetime.'], } }, @@ -890,9 +1015,7 @@ def test_parse_rcv_compra_reclamado_csv_file(self) -> None: 'Impto. Sin Derecho a Credito': None, 'IVA No Retenido': None, 'NCE o NDE sobre Fact. de Compra': None, - 'Codigo Otro Impuesto': None, - 'Valor Otro Impuesto': None, - 'Tasa Otro Impuesto': None, + 'Otros Impuestos': None, 'receptor_rut': Rut('1-9'), }, {