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