diff --git a/.gitignore b/.gitignore index 24e3e8c..e9578f1 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ dmypy.json /satcfdi/create/*/*_z.py /docs/generated/ /satcfdi.svg +/tests/test_contabilidad_electronica/out/ diff --git a/satcfdi/accounting/contabilidad.py b/satcfdi/accounting/contabilidad.py new file mode 100644 index 0000000..68afa4a --- /dev/null +++ b/satcfdi/accounting/contabilidad.py @@ -0,0 +1,195 @@ +import os +from typing import Sequence + +from satcfdi.create.contabilidad.AuxiliarCtas13 import AuxiliarCtas, Cuenta, DetalleAux +from satcfdi.create.contabilidad.BCE13 import Balanza +from satcfdi.create.contabilidad.PLZ13 import Polizas, CompNal, Poliza +from satcfdi.create.contabilidad.RepAux13 import RepAuxFol, DetAuxFol +from satcfdi.create.contabilidad.catalogocuentas13 import Catalogo, Ctas +from .contabilidad_print import imprimir_contablidad + +from .. import render + +from ..models import DatePeriod + + +def filename(file): + if file.tag == '{http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/BalanzaComprobacion}Balanza': + return file["RFC"] + str(file["Anio"]) + file["Mes"] + "B" + file["TipoEnvio"] + ".xml" + if file.tag == '{http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas}Catalogo': + return file["RFC"] + str(file["Anio"]) + file["Mes"] + "CT.xml" + if file.tag == '{http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/AuxiliarCtas}AuxiliarCtas': + return file["RFC"] + str(file["Anio"]) + file["Mes"] + "XC.xml" + if file.tag == '{http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/PolizasPeriodo}Polizas': + return file["RFC"] + str(file["Anio"]) + file["Mes"] + "PL.xml" + if file.tag == '{http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/AuxiliarFolios}RepAuxFol': + return file["RFC"] + str(file["Anio"]) + file["Mes"] + "XF.xml" + raise ValueError(f"Unknown file type: {file.tag}") + + +def output_file(file, folder, fiel=None, generate_pdf=False): + if fiel: + file.sign(fiel) + + output_file = os.path.join(folder, filename(file)) + file.xml_write( + output_file, + pretty_print=True, + xml_declaration=True + ) + if generate_pdf: + # render.html_write(file, output_file[:-4] + ".html") + render.pdf_write(file, output_file[:-4] + ".pdf") + else: + # delete file + try: + os.remove(output_file[:-4] + ".pdf") + except FileNotFoundError: + pass + + return output_file + + +def generar_contabilidad( + dp: DatePeriod, + rfc_emisor: str, + ctas: dict, + polizas: Sequence[Poliza], + tipo_envio='N', + fecha_mod_bal=None, + tipo_solicitud='', + numero_orden=None, + numero_tramite=None, + folder=None, + fiel=None, + generate_pdf=False): + + plz = Polizas( + rfc=rfc_emisor, + mes=str(dp.month).zfill(2), + anio=dp.year, + tipo_solicitud=tipo_solicitud, + num_orden=numero_orden, + num_tramite=numero_tramite, + poliza=polizas + ) + output_file(plz, folder, fiel, generate_pdf=generate_pdf) + + cat = Catalogo( + rfc=rfc_emisor, + mes=str(dp.month).zfill(2), + anio=dp.year, + ctas=[ + Ctas( + cod_agrup=v["CodAgrup"].split("_")[0], + num_cta=k, + desc=v["Desc"], + nivel=v["Nivel"], + natur=v["Natur"], + sub_cta_de=v['SubCtaDe'], + ) for k, v in ctas.items() + ] + ) + output_file(cat, folder, fiel) + + ban = Balanza( + rfc=rfc_emisor, + mes=str(dp.month).zfill(2), + anio=dp.year, + tipo_envio=tipo_envio, + fecha_mod_bal=fecha_mod_bal, + ctas=[{ + "NumCta": k, + **v, + } for k, v in ctas.items() if v["SaldoIni"] or v["Debe"] or v["Haber"] or v["SaldoFin"]], + ) + output_file(ban, folder, fiel) + + aux_detalles = group_aux_cuentas(polizas) + aux = AuxiliarCtas( + rfc=rfc_emisor, + mes=str(dp.month).zfill(2), + anio=dp.year, + tipo_solicitud=tipo_solicitud, + num_orden=numero_orden, + num_tramite=numero_tramite, + cuenta=[ + Cuenta( + num_cta=k, + des_cta=v["Desc"], + saldo_ini=v["SaldoIni"], + saldo_fin=v["SaldoFin"], + detalle_aux=aux_detalles[k] + ) for k, v in ctas.items() if k in aux_detalles + ] + ) + output_file(aux, folder, fiel, generate_pdf=generate_pdf) + + auxf = RepAuxFol( + rfc=rfc_emisor, + mes=str(dp.month).zfill(2), + anio=dp.year, + tipo_solicitud=tipo_solicitud, + num_orden=numero_orden, + num_tramite=numero_tramite, + det_aux_fol=list(group_aux_folios(polizas)) + ) + output_file(auxf, folder, fiel, generate_pdf=generate_pdf) + + imprimir_contablidad( + catalogo_cuentas=cat, + balanza_comprobacion=ban, + archivo_excel=filename(ban)[:-4] + ".xlsx" + ) + + validate_saldos(ctas) + + +def group_aux_cuentas(polizas): + cta_polizas = {} + for p in polizas: + for t in p["Transaccion"]: + detalles = cta_polizas.setdefault(t["NumCta"], []) + detalles.append( + DetalleAux( + fecha=p["Fecha"], + num_un_iden_pol=p["NumUnIdenPol"], + concepto=t["Concepto"], + debe=t["Debe"], + haber=t["Haber"], + ) + ) + return cta_polizas + + +def group_aux_folios(polizas): + for p in polizas: + yield DetAuxFol( + num_un_iden_pol=p["NumUnIdenPol"], + fecha=p["Fecha"], + compr_nal=p.comp_nal, + ) + + +def validate_saldos(cuentas): + total = 0 + totales = {} + for k, v in cuentas.items(): + if v['Nivel'] == 1: + if v['Natur'] == 'D': + total += v['SaldoFin'] + else: + total -= v['SaldoFin'] + else: + totales.setdefault(v['SubCtaDe'], 0) + if v['Natur'] == 'D': + totales[v['SubCtaDe']] += v['SaldoFin'] + else: + totales[v['SubCtaDe']] -= v['SaldoFin'] + + assert total == 0 + for k, v in totales.items(): + if cuentas[k]['Natur'] == 'D': + assert v == cuentas[k]['SaldoFin'] + else: + assert v == -cuentas[k]['SaldoFin'] diff --git a/satcfdi/accounting/contabilidad_print.py b/satcfdi/accounting/contabilidad_print.py new file mode 100644 index 0000000..1013d37 --- /dev/null +++ b/satcfdi/accounting/contabilidad_print.py @@ -0,0 +1,51 @@ +import xlsxwriter +from satcfdi.accounting.process import excel_export +from satcfdi.cfdi import CFDI + +EXCEL_COLUMNS = { + 'NumCta': (12, False, lambda i: i['NumCta']), + 'Desc': (62, False, lambda i: ' ' * (i['Nivel'] - 1) + i['Desc']), + 'Natur': (5, False, lambda i: i['Natur']), + + 'SaldoIni': (12, False, lambda i: i.get('SaldoIni')), + 'Debe': (12, False, lambda i: i.get('Debe')), + 'Haber': (12, False, lambda i: i.get('Haber')), + 'SaldoFin': (12, False, lambda i: i.get('SaldoFin')), + + 'CodAgrup': (12, False, lambda i: i['CodAgrup'].code), + 'CodDesc': (120, False, lambda i: i['CodAgrup'].description), +} + + +def imprimir_contablidad( + catalogo_cuentas, + balanza_comprobacion, + archivo_excel +): + # ct = CFDI.from_file(catalogo_cuentas) + # bc = CFDI.from_file(balanza_comprobacion) + ct = catalogo_cuentas + bc = balanza_comprobacion + + ctas = { + c['NumCta']: { + 'Desc': c['Desc'], + 'Natur': c['Natur'], + 'CodAgrup': c['CodAgrup'], + 'Nivel': c['Nivel'], + } + for c in ct['Ctas'] + } + for r in bc['Ctas']: + ctv = ctas[r['NumCta']] + r.update(ctv) + + workbook = xlsxwriter.Workbook(archivo_excel) + excel_export( + workbook=workbook, + name=bc['RFC'] + str(bc['Anio']) + str(bc['Mes']), + invoices=bc['Ctas'], + columns=EXCEL_COLUMNS, + row_height=1 + ) + workbook.close() diff --git a/satcfdi/accounting/process.py b/satcfdi/accounting/process.py index 88553b4..f2f6c24 100644 --- a/satcfdi/accounting/process.py +++ b/satcfdi/accounting/process.py @@ -248,7 +248,7 @@ def num2col(n): return name -def excel_export(workbook: xlsxwriter.workbook.Workbook, name, invoices, columns): +def excel_export(workbook: xlsxwriter.workbook.Workbook, name, invoices, columns, row_heigth=3): worksheet = workbook.add_worksheet(name) for n, (w, s, f) in enumerate(columns.values()): @@ -275,7 +275,7 @@ def excel_export(workbook: xlsxwriter.workbook.Workbook, name, invoices, columns number_format = workbook.add_format({'num_format': '0.00'}) number_format.set_align('top') - row_height = 20 * 3 + row_height = 20 * row_heigth r = 0 for r, i in enumerate(invoices): worksheet.set_row_pixels(1 + r, row_height) diff --git a/satcfdi/create/contabilidad/__init__.py b/satcfdi/create/contabilidad/__init__.py index e69de29..8b13789 100644 --- a/satcfdi/create/contabilidad/__init__.py +++ b/satcfdi/create/contabilidad/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/MOLE870717DRA202402BN.xlsx b/tests/MOLE870717DRA202402BN.xlsx new file mode 100644 index 0000000..3c70e29 Binary files /dev/null and b/tests/MOLE870717DRA202402BN.xlsx differ diff --git a/tests/test_contabilidad.py b/tests/test_contabilidad.py index ab6a1c9..61b9695 100644 --- a/tests/test_contabilidad.py +++ b/tests/test_contabilidad.py @@ -2,11 +2,13 @@ import os import pytest +from satcfdi.models import DatePeriod from satcfdi import render +from satcfdi.accounting.contabilidad import generar_contabilidad from satcfdi.cfdi import CFDI from tests.constants import CFDI_FILES, CONTABILIDAD_FILES, SPEI_FILES -from tests.utils import verify_result, XElementPrettyPrinter +from tests.utils import verify_result, XElementPrettyPrinter, compare_directories module = 'satcfdi' current_dir = os.path.dirname(__file__) @@ -44,3 +46,18 @@ def test_generate_spei_pdf(caplog, xml_file): ) verify_invoice(cfdi, xml_file) + + +def test_generate_contabilidad(): + generar_contabilidad( + dp=DatePeriod(2024, 2), + rfc_emisor="MOLE870717DRA", + ctas={}, + polizas=[], + folder=os.path.join(current_dir, 'test_contabilidad_electronica/out'), + ) + + assert compare_directories( + os.path.join(current_dir, 'test_contabilidad_electronica/ref'), + os.path.join(current_dir, 'test_contabilidad_electronica/out') + ) diff --git a/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402BN.xml b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402BN.xml new file mode 100644 index 0000000..4c7ee07 --- /dev/null +++ b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402BN.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402CT.xml b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402CT.xml new file mode 100644 index 0000000..b6389c5 --- /dev/null +++ b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402CT.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402PL.xml b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402PL.xml new file mode 100644 index 0000000..7864ada --- /dev/null +++ b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402PL.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402XC.xml b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402XC.xml new file mode 100644 index 0000000..cdcacfd --- /dev/null +++ b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402XC.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402XF.xml b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402XF.xml new file mode 100644 index 0000000..e7f251d --- /dev/null +++ b/tests/test_contabilidad_electronica/ref/MOLE870717DRA202402XF.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/utils.py b/tests/utils.py index 25658a3..bfd1c8b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,4 @@ +import filecmp import inspect import os import uuid @@ -97,6 +98,35 @@ class XElementPrettyPrinter(PrettyPrinter): _dispatch[defaultdict.__repr__] = PrettyPrinter._pprint_dict +def compare_directories(dir1, dir2): + # Check if both paths are directories + if not (os.path.isdir(dir1) and os.path.isdir(dir2)): + return False + + # Use filecmp to compare directories + dcmp = filecmp.dircmp(dir1, dir2) + + # Check for common files that are different + if dcmp.diff_files: + print(f"Files that are different: {dcmp.diff_files} in {dir2}") + return False + + # Check for files present in one directory but not in the other + if dcmp.left_only or dcmp.right_only: + print(f"Files present only in one directory: {dcmp.left_only + dcmp.right_only} in {dir2}") + return False + + # Check for subdirectories that are not present in both directories + if dcmp.subdirs: + for subdir in dcmp.subdirs: + subdir1 = os.path.join(dir1, subdir) + subdir2 = os.path.join(dir2, subdir) + if not compare_directories(subdir1, subdir2): + return False + + return True + + if __name__ == "__main__": import os