|
|
|
|
| """
|
| extraer_movimientos.py (extractor DETERMINISTA de movimientos por columnas)
|
| ============================================================================
|
| Lee un PDF de estado de cuenta y extrae CADA movimiento (fecha, concepto,
|
| deposito, retiro, saldo) leyendo las COLUMNAS por coordenadas (no con IA).
|
|
|
| COMPUERTA DE VALIDACION: al final compara la suma de los movimientos extraidos
|
| contra los TOTALES REPORTADOS por el banco (totales_banco.py). Solo marca
|
| ok=True si CUADRAN (tolerancia 1 peso). Si no cuadra, ok=False -> el flujo debe
|
| caer a Gemini para ese estado (y la verificacion lo delata). Asi nunca se
|
| emiten movimientos inventados/incompletos en silencio.
|
|
|
| Uso: python3 extraer_movimientos.py <pdf>
|
| Salida (stdout) JSON:
|
| {"banco","ok","cuadra","suma_dep","suma_ret","rep_dep","rep_ret","n",
|
| "transacciones":[{"fecha","concepto","deposito","retiro","saldo"}...]}
|
| """
|
| import json
|
| import re
|
| import sys
|
| import importlib.util
|
| import os
|
|
|
| _here = os.path.dirname(os.path.abspath(__file__))
|
| _spec = importlib.util.spec_from_file_location('tb', os.path.join(_here, 'totales_banco.py'))
|
| tb = importlib.util.module_from_spec(_spec)
|
| _spec.loader.exec_module(tb)
|
|
|
| MONEY = re.compile(r'^\d{1,3}(,\d{3})*\.\d{2}$')
|
| MONEY_NEG = re.compile(r'^\d{1,3}(,\d{3})*\.\d{2}-$')
|
| FECHA_PREFIX = re.compile(r'^(\d{2}-[A-Z]{3}-\d{4})')
|
| def _num(s): return float(s.replace(',', ''))
|
|
|
| _MESES = {'ENE': 1, 'FEB': 2, 'MAR': 3, 'ABR': 4, 'MAY': 5, 'JUN': 6,
|
| 'JUL': 7, 'AGO': 8, 'SEP': 9, 'OCT': 10, 'NOV': 11, 'DIC': 12}
|
|
|
|
|
| def _fecha_iso(f):
|
| """'02-MAR-2026' -> '2026-03-02'. Devuelve '' si no se reconoce."""
|
| m = re.match(r'^(\d{2})-([A-Z]{3})-(\d{4})$', f or '')
|
| if not m or m.group(2) not in _MESES:
|
| return ''
|
| return '%s-%02d-%s' % (m.group(3), _MESES[m.group(2)], m.group(1))
|
|
|
| DEP_KW = {'DEPOSITO', 'DEPOSITOS', 'DEPÓSITO', 'DEPÓSITOS', 'ABONO', 'ABONOS'}
|
| RET_KW = {'RETIRO', 'RETIROS', 'CARGO', 'CARGOS'}
|
| SAL_KW = {'SALDO', 'SALDOS'}
|
| EXCL = ('TOTAL', 'TOTALES', 'SALDOANTERIOR', 'SALDOFINAL', 'SALDOINICIAL',
|
| 'SALDOAL', 'SALDOPROMEDIO', 'SALDOMINIMO')
|
|
|
|
|
| def _cols_de_pagina(words):
|
| """Encuentra (depx, retx, salx) por el encabezado de la tabla (juntando
|
| renglones cercanos). Devuelve None si la pagina no tiene tabla de movimientos."""
|
| filas = {}
|
| for w in words:
|
| filas.setdefault(round(w['top']), []).append(w)
|
| tops = sorted(filas)
|
| mejor = None
|
| for top in tops:
|
| grupo = [x for t2 in tops if abs(t2 - top) <= 16 for x in filas[t2]]
|
| ups = {x['text'].upper() for x in grupo}
|
| if (ups & DEP_KW) and (ups & RET_KW) and (ups & SAL_KW):
|
| dx = rx = sx = None
|
| for x in grupo:
|
| u = x['text'].upper()
|
| if u in DEP_KW and dx is None: dx = x['x1']
|
| elif u in RET_KW and rx is None: rx = x['x1']
|
| elif u in SAL_KW and sx is None: sx = x['x1']
|
| if None not in (dx, rx, sx):
|
| mejor = (dx, rx, sx, top + 16)
|
| return mejor
|
|
|
|
|
|
|
|
|
|
|
|
|
| BANDAS = {
|
| 'SANTANDER': {'dep': (420, 436), 'ret': (488, 503), 'sal': (572, 586)},
|
| }
|
|
|
|
|
| def _undouble(s):
|
| """Colapsa tokens con glifos DUPLICADOS (aabbcc.. -> abc..). Algunos
|
| Santander (p.ej. ENERO) renderizan parte de la tabla con cada glifo dibujado
|
| dos veces, de modo que pdfplumber tokeniza '145,000.00' como '114455,,000000..0000'
|
| y el encabezado 'DEPOSITO' como 'DDEEPPOOSSIITTOO'. Solo colapsa cuando TODO
|
| el texto viene en pares identicos consecutivos (longitud par >=4); en cualquier
|
| otro caso devuelve el token intacto, asi los meses 'limpios' no se alteran y
|
| montos legitimos como '11,000.00' (cuyo 3er par ',0' no es identico) jamas se
|
| tocan."""
|
| if len(s) >= 4 and len(s) % 2 == 0 and all(s[i] == s[i + 1] for i in range(0, len(s), 2)):
|
| return s[::2]
|
| return s
|
|
|
|
|
| DEP_KW_H = {'DEPOSITO', 'DEPOSITOS', 'DEPÓSITO', 'DEPÓSITOS'}
|
|
|
|
|
|
|
| _SANT_FIN = ('SALDOFINALDELPERIODO', 'SALDOTOTALACUMULADO', 'COMISIONESPENDIENTES')
|
|
|
|
|
| def _santander_hdrs(words):
|
| """Filas que SON encabezado real de la tabla: DEPOSITO + RETIRO + SALDO en el
|
| MISMO renglon, en orden creciente de x y en la mitad derecha. Evita que el
|
| GLOSARIO ('DEP= DEPOSITO' / 'CGO= CARGO' / 'SDO= SALDO', en tres renglones
|
| distintos) se tome por tabla de movimientos: con la puerta anterior esa
|
| pagina se escaneaba entera y cualquier monto en banda se habria inyectado al
|
| ultimo movimiento real."""
|
| filas = {}
|
| for w in words:
|
| filas.setdefault(round(w['top']), []).append(w)
|
| out = []
|
| for top in sorted(filas):
|
| d = r = s = None
|
| for w in filas[top]:
|
| u = w['text'].upper()
|
| if u in DEP_KW_H and d is None: d = w['x1']
|
| elif u in RET_KW and r is None: r = w['x1']
|
| elif u in SAL_KW and s is None: s = w['x1']
|
| if None not in (d, r, s) and d < r < s and d > 300:
|
| out.append((top, d, r, s))
|
| return out
|
|
|
|
|
| def _santander_bandas(pages_words):
|
| """Bandas dep/ret/sal ancladas al ENCABEZADO (como ya hace _bbva_bandas): el
|
| encabezado da la columna y el CLUSTER de montos a su derecha da el borde
|
| exacto. Las bandas FIJAS dejaban solo 4.9-6.5 pt de margen (un caracter): un
|
| cambio de plantilla de Santander tiraba los montos fuera de banda EN SILENCIO
|
| (colm devuelve None -> el movimiento sale sin importe). Devuelve None si la
|
| pagina no trae encabezado, para caer a las bandas fijas calibradas."""
|
| from collections import Counter
|
| hx = Counter()
|
| for words in pages_words:
|
| for (_t, d, r, s) in _santander_hdrs(words):
|
| hx[(round(d, 1), round(r, 1), round(s, 1))] += 1
|
| if not hx:
|
| return None
|
| d, r, s = max(hx, key=lambda k: hx[k])
|
| money_x = [w['x1'] for words in pages_words for w in words if MONEY.match(w['text'])]
|
| bandas = {}
|
| for name, hxx in (('dep', d), ('ret', r), ('sal', s)):
|
| c = Counter(round(x, 1) for x in money_x if 2 <= (x - hxx) <= 45)
|
| if not c:
|
| return None
|
| mode = c.most_common(1)[0][0]
|
| near = [x for x in c.elements() if abs(x - mode) <= 4]
|
| bandas[name] = (sum(near) / len(near) - 8, sum(near) / len(near) + 8)
|
| return bandas
|
|
|
|
|
| def _por_bandas(pdf, bandas):
|
| """Extrae movimientos por bandas x1 (dep/ret/sal). Santander."""
|
| pages_words = []
|
| for page in pdf.pages:
|
| words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
|
|
|
|
| for w in words:
|
| w['text'] = _undouble(w['text'])
|
| pages_words.append(words)
|
|
|
| bandas = _santander_bandas(pages_words) or bandas
|
| dband, rband, sband = bandas['dep'], bandas['ret'], bandas['sal']
|
|
|
| def colm(x1):
|
| if dband[0] <= x1 <= dband[1]: return 'dep'
|
| if rband[0] <= x1 <= rband[1]: return 'ret'
|
| if sband[0] <= x1 <= sband[1]: return 'sal'
|
| return None
|
|
|
| movs = []
|
| for words in pages_words:
|
|
|
| hdrs = _santander_hdrs(words)
|
| if not hdrs:
|
| continue
|
| hdr_bottom = hdrs[0][0] + 2
|
| filas = {}
|
| for w in words:
|
| if w['top'] <= hdr_bottom:
|
| continue
|
| filas.setdefault(round(w['top']), []).append(w)
|
| for top in sorted(filas):
|
| row = sorted(filas[top], key=lambda x: x['x0'])
|
| if not row:
|
| continue
|
| mdate = re.match(r'^(\d{2}-[A-Z]{3}-\d{4})(\d*)(.*)$', row[0]['text'])
|
| primer = ''.join(c for c in row[0]['text'].upper() if c.isalpha())
|
|
|
|
|
|
|
|
|
|
|
| if not mdate and any(primer.startswith(k) for k in _SANT_FIN):
|
| break
|
| if not mdate and any(primer.startswith(k) for k in EXCL):
|
| continue
|
| dep = ret = sal = None
|
| desc = []
|
| for i, w in enumerate(row):
|
| if MONEY.match(w['text']):
|
| cm = colm(w['x1'])
|
| if cm == 'dep': dep = _num(w['text'])
|
| elif cm == 'ret': ret = _num(w['text'])
|
| elif cm == 'sal': sal = _num(w['text'])
|
| continue
|
| if i == 0 and mdate:
|
| if mdate.group(3): desc.append(mdate.group(3))
|
| elif 110 < w['x0'] < 330:
|
| desc.append(w['text'])
|
|
|
|
|
| if dep is not None and ret is not None:
|
| break
|
| if mdate:
|
| movs.append({'fecha': mdate.group(1), 'concepto': ' '.join(desc),
|
| 'deposito': dep, 'retiro': ret, 'saldo': sal})
|
| elif movs and row[0]['x0'] > 110:
|
| if desc: movs[-1]['concepto'] += ' ' + ' '.join(desc)
|
| if movs[-1]['deposito'] is None and dep is not None: movs[-1]['deposito'] = dep
|
| if movs[-1]['retiro'] is None and ret is not None: movs[-1]['retiro'] = ret
|
| if sal is not None: movs[-1]['saldo'] = sal
|
|
|
|
|
|
|
|
|
| for m in movs:
|
| m['concepto'] = m['concepto'][:300]
|
| return movs
|
|
|
|
|
| _MES_ABBR = {1: 'ENE', 2: 'FEB', 3: 'MAR', 4: 'ABR', 5: 'MAY', 6: 'JUN',
|
| 7: 'JUL', 8: 'AGO', 9: 'SEP', 10: 'OCT', 11: 'NOV', 12: 'DIC'}
|
|
|
|
|
| BANAMEX_DEP = (384, 404)
|
| BANAMEX_RET = (306, 328)
|
| BANAMEX_SAL = (462, 486)
|
|
|
|
|
|
|
|
|
| BANAMEX_DESC = (48, 262)
|
| _BX_FILA_FECHA = re.compile(r'^(\d{1,2})\s+(ENE|FEB|MAR|ABR|MAY|JUN|JUL|AGO|SEP|OCT|NOV|DIC)\b')
|
|
|
| _BX_RUIDO = re.compile(r'^(CAJA|HORA|SUC|AUT|CLAVE|RASTREO:?|REF\.?|\d{2}:\d{2}|0{4,}|\d{10,})$', re.I)
|
| _BX_MES_NOM = {'ENERO': 1, 'FEBRERO': 2, 'MARZO': 3, 'ABRIL': 4, 'MAYO': 5,
|
| 'JUNIO': 6, 'JULIO': 7, 'AGOSTO': 8, 'SEPTIEMBRE': 9,
|
| 'OCTUBRE': 10, 'NOVIEMBRE': 11, 'DICIEMBRE': 12}
|
|
|
|
|
| def _banamex_periodo(pdf):
|
| """(mes_corte, anio_corte) de 'ESTADO DE CUENTA AL DD DE <MES> DE AAAA'.
|
| Hace falta para fechar bien los periodos que cruzan de anio (ENERO 2026): el
|
| 'DE (\\d{4})' anterior podia agarrar cualquier ano suelto del encabezado."""
|
| t = ' '.join((p.extract_text() or '') for p in pdf.pages[:2]).upper()
|
| m = re.search(r'AL\s+\d{1,2}\s+DE\s+([A-ZÁÉÍÓÚ]+)\s+DE\s+(\d{4})', t)
|
| if m and _BX_MES_NOM.get(m.group(1)):
|
| return _BX_MES_NOM[m.group(1)], int(m.group(2))
|
| ys = re.findall(r'\b(20\d{2})\b', t)
|
| return None, (int(ys[0]) if ys else 2025)
|
|
|
|
|
| def _banamex(pdf):
|
| """Banamex: layout multilinea. Cada movimiento es un BLOQUE que ABRE con la
|
| fila 'DD MMM <concepto>' y CIERRA con las lineas 'CAJA .. / HORA ..'; el
|
| IMPORTE se imprime alineado con esa ULTIMA linea, es decir DEBAJO de su
|
| propia fecha y ARRIBA de la fecha del movimiento SIGUIENTE.
|
|
|
| Por eso la fecha y el concepto se toman del bloque ABIERTO (el ultimo
|
| 'DD MMM' visto) y NO del siguiente. La version anterior buscaba la fecha
|
| hacia adelante (rows i..i+3), asi que le colgaba a cada importe la fecha y el
|
| concepto del movimiento siguiente (prueba semantica sobre los 6 estados:
|
| 529 de 1432 quedaban con un concepto de signo contrario al importe), y los
|
| importes que caian al inicio de una pagina se quedaban SIN fecha ni concepto
|
| (24 de 1531 = $2,420,790.61 en depositos y $713,698.80 en retiros) y el flujo
|
| los tiraba en silencio. El bloque ahora se arrastra entre paginas."""
|
| mes_corte, anio = _banamex_periodo(pdf)
|
| movs = []
|
| bloque = None
|
|
|
| def _desc(row):
|
| return ' '.join(w['text'] for w in row
|
| if BANAMEX_DESC[0] <= w['x0'] < BANAMEX_DESC[1]
|
| and not MONEY.match(w['text'])
|
| and not _BX_RUIDO.match(w['text'])).strip()
|
|
|
| for page in pdf.pages:
|
| words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| ups = set(w['text'].upper() for w in words)
|
| if not ('DEPOSITOS' in ups and 'RETIROS' in ups and 'SALDO' in ups):
|
| continue
|
| filas = {}
|
| for w in words:
|
| filas.setdefault(round(w['top']), []).append(w)
|
| hdr = min((round(w['top']) for w in words
|
| if w['text'].upper() == 'CONCEPTO'), default=-1)
|
| for top in sorted(filas):
|
| if top <= hdr:
|
| continue
|
| row = sorted(filas[top], key=lambda x: x['x0'])
|
| txt = ' '.join(w['text'] for w in row)
|
| primer = ''.join(c for c in row[0]['text'].upper() if c.isalpha())
|
|
|
| dep = ret = sal = None
|
| for w in row:
|
| if MONEY.match(w['text']):
|
| v = _num(w['text'])
|
| if BANAMEX_DEP[0] <= w['x1'] <= BANAMEX_DEP[1]: dep = v
|
| elif BANAMEX_RET[0] <= w['x1'] <= BANAMEX_RET[1]: ret = v
|
| elif BANAMEX_SAL[0] <= w['x1'] <= BANAMEX_SAL[1]: sal = v
|
|
|
|
|
|
|
| if primer.startswith(('TOTAL', 'SALDO')) or (dep is not None and ret is not None):
|
| continue
|
|
|
| md = _BX_FILA_FECHA.match(txt)
|
| if md:
|
| bloque = {'dia': int(md.group(1)), 'mes': md.group(2),
|
| 'desc': [_desc(row)]}
|
| elif bloque is not None:
|
| cont = _desc(row)
|
| if cont:
|
| bloque['desc'].append(cont)
|
|
|
| if dep is None and ret is None:
|
| continue
|
| if bloque is None:
|
| movs.append({'fecha': '', 'concepto': '', 'deposito': dep,
|
| 'retiro': ret, 'saldo': sal})
|
| continue
|
| mn = _MESES[bloque['mes']]
|
|
|
| yr = anio if (mes_corte is None or mn <= mes_corte) else anio - 1
|
| movs.append({
|
| 'fecha': '%02d-%s-%d' % (bloque['dia'], bloque['mes'], yr),
|
| 'concepto': re.sub(r'\s{2,}', ' ', ' '.join(bloque['desc'])).strip()[:220],
|
| 'deposito': dep, 'retiro': ret, 'saldo': sal,
|
| })
|
| return movs
|
|
|
|
|
|
|
|
|
| BBVA_DEP = (454, 462)
|
| BBVA_RET = (413, 421)
|
| BBVA_PIE = 45
|
|
|
|
|
| BBVA_X_OPER = 60
|
|
|
|
|
| def _bbva_bandas(pages_words):
|
| """Detecta las columnas CARGOS/ABONOS de la tabla de detalle de BBVA leyendo
|
| el ENCABEZADO (CARGOS + ABONOS + SALDO en el mismo renglon, con SALDO a la
|
| derecha). Devuelve (cband, aband). Las bandas FIJAS son fragiles porque cada
|
| formato de BBVA mueve las columnas (Libreton cargos~417 vs Pyme cargos~398);
|
| el encabezado las da exactas. Fallback a bandas fijas si no hay encabezado."""
|
| from collections import Counter
|
|
|
| money_x = [w['x1'] for words in pages_words for w in words if MONEY.match(w['text'])]
|
|
|
| cands = Counter()
|
| for words in pages_words:
|
| filas = {}
|
| for w in words:
|
| filas.setdefault(round(w['top']), []).append(w)
|
| for top, grp in filas.items():
|
| cxs = [w['x1'] for w in grp if w['text'].upper() == 'CARGOS' and w['x1'] > 340]
|
| axs = [w['x1'] for w in grp if w['text'].upper() == 'ABONOS' and w['x1'] > 340]
|
| for cx in cxs:
|
| for ax in axs:
|
| if 35 <= (ax - cx) <= 75:
|
| cands[(round(cx, 1), round(ax, 1))] += 1
|
| if cands:
|
|
|
|
|
| def _puntaje(par):
|
| cx, ax = par
|
| sc = (sum(1 for x in money_x if abs(x - cx) <= 10) +
|
| sum(1 for x in money_x if abs(x - ax) <= 10))
|
| return (sc, cands[par])
|
| cx, ax = max(cands, key=_puntaje)
|
| if _puntaje((cx, ax))[0] > 0:
|
| return (cx - 10, cx + 10), (ax - 10, ax + 10)
|
| return BBVA_RET, BBVA_DEP
|
|
|
|
|
| def _bbva_anio(full):
|
| """Extrae (mes->anio) del periodo 'DEL DD/MM/AAAA AL DD/MM/AAAA' para fechar
|
| los movimientos (maneja periodos que cruzan anio, p.ej. DIC 2025 / ENE 2026)."""
|
| m = re.search(r'DEL\s+\d{1,2}/(\d{2})/(\d{4})\s+AL\s+\d{1,2}/(\d{2})/(\d{4})', full)
|
| a = {}
|
| if m:
|
| m1, y1, m2, y2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
|
| a[m1] = y1
|
| a[m2] = y2
|
|
|
|
|
|
|
|
|
|
|
| y, mth = y1, m1
|
| for _ in range(11):
|
| mth -= 1
|
| if mth == 0:
|
| mth, y = 12, y - 1
|
| a.setdefault(mth, y)
|
| ys = re.findall(r'\b(20\d{2})\b', full)
|
| return a, (int(ys[0]) if ys else 2025)
|
|
|
|
|
| def _bbva(pdf):
|
| """BBVA: extrae cada movimiento POR BLOQUE. Un bloque va de una fila-fecha
|
| ('DD/MMM ...') a la siguiente. De cada bloque saca: fecha, monto (banda
|
| CARGOS/ABONOS detectada del encabezado) y la DESCRIPCION COMPLETA del bloque
|
| (todas las lineas de la columna de descripcion, x0~106-315), que incluye el
|
| NOMBRE de la contraparte (p.ej. 'BNET 0478... clara vazquez', 'Kenya Cabrera',
|
| 'MARIO GUTIERREZ JIMENEZ' en sus lineas de continuacion). Asi el desglose por
|
| persona puede agrupar por el nombre real. Es determinista CON fecha."""
|
| pages_words = [p.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| for p in pdf.pages]
|
| cband, aband = _bbva_bandas(pages_words)
|
| full = ' '.join((p.extract_text() or '') for p in pdf.pages[:2])
|
| mes_anio, anio_def = _bbva_anio(full)
|
| fecha_ini = re.compile(r'^(\d{1,2})/([A-Z]{3})$')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| todas = []
|
| for page, words in zip(pdf.pages, pages_words):
|
| lim = page.height - BBVA_PIE
|
| filas = {}
|
| for w in words:
|
| if w['top'] > lim:
|
| continue
|
| filas.setdefault(round(w['top']), []).append(w)
|
| for top in sorted(filas):
|
| todas.append(sorted(filas[top], key=lambda x: x['x0']))
|
|
|
|
|
| bloques = []
|
| cur = None
|
| for row in todas:
|
| primer = ''.join(c for c in row[0]['text'].upper() if c.isalpha())
|
|
|
|
|
|
|
|
|
|
|
| if primer.startswith(('TOTAL', 'SALDO')) and not (100 < row[0]['x0'] < 300):
|
| if cur:
|
| bloques.append(cur)
|
| cur = None
|
| continue
|
| md = fecha_ini.match(row[0]['text'].upper())
|
|
|
|
|
|
|
|
|
|
|
|
|
| if md and md.group(2) in _MESES and row[0]['x0'] < BBVA_X_OPER:
|
| if cur:
|
| bloques.append(cur)
|
| cur = {'md': md, 'rows': [row]}
|
| elif cur:
|
| cur['rows'].append(row)
|
| if cur:
|
| bloques.append(cur)
|
|
|
|
|
|
|
| movs = []
|
| for blk in bloques:
|
| md = blk['md']
|
| yr = mes_anio.get(_MESES[md.group(2)], anio_def)
|
| fecha = '%02d-%s-%d' % (int(md.group(1)), md.group(2), yr)
|
| d = r = None
|
| desc = []
|
| for row in blk['rows']:
|
| primer = ''.join(c for c in row[0]['text'].upper() if c.isalpha())
|
|
|
|
|
|
|
| es_resumen = primer.startswith(('TOTAL', 'SALDO')) and not (100 < row[0]['x0'] < 300)
|
| if es_resumen:
|
| continue
|
| row_d = row_r = None
|
| for w in row:
|
| if MONEY.match(w['text']):
|
| if aband[0] <= w['x1'] <= aband[1]: row_d = _num(w['text'])
|
| elif cband[0] <= w['x1'] <= cband[1]: row_r = _num(w['text'])
|
| elif 106 <= w['x0'] < 315:
|
| desc.append(w['text'])
|
|
|
| if row_d is not None and row_r is not None:
|
| continue
|
| if d is None and r is None:
|
| if row_d is not None: d = row_d
|
| elif row_r is not None: r = row_r
|
| if d is None and r is None:
|
| continue
|
| movs.append({'fecha': fecha, 'concepto': ' '.join(desc)[:220],
|
| 'deposito': d, 'retiro': r, 'saldo': None})
|
| return movs
|
|
|
|
|
|
|
|
|
| BANORTE_DEP = (412, 423)
|
| BANORTE_RET = (482, 493)
|
| BANORTE_SAL = (554, 566)
|
|
|
|
|
| def _banorte(pdf):
|
| """Banorte: una fila = un movimiento. Fecha 'DD-MMM-YY' al inicio (pegada a la
|
| descripcion), monto en banda DEPOSITOS/RETIROS y SALDO corrido por fila. Las
|
| filas 'SALDO ANTERIOR'/'SIN MOVIMIENTOS' NO traen monto en dep/ret -> se ignoran
|
| solas (no se cuelan como movimientos, que es lo que Gemini hacia mal)."""
|
| movs = []
|
| fre = re.compile(r'^(\d{2})-([A-Z]{3})-(\d{2})(.*)$')
|
| for page in pdf.pages:
|
| words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| filas = {}
|
| for w in words:
|
| filas.setdefault(round(w['top']), []).append(w)
|
| for top in sorted(filas):
|
| row = sorted(filas[top], key=lambda x: x['x0'])
|
| md = fre.match(row[0]['text'])
|
| if md and md.group(2) in _MESES:
|
| fecha = '%s-%s-20%s' % (md.group(1), md.group(2), md.group(3))
|
| dep = ret = sal = None
|
| desc = [md.group(4)] if md.group(4) else []
|
| for w in row[1:]:
|
| t = w['text']
|
| neg = bool(MONEY_NEG.match(t))
|
| if neg or MONEY.match(t):
|
|
|
|
|
|
|
| x1 = w['x1'] - (w['x1'] - w['x0']) / len(t) if neg else w['x1']
|
| v = -_num(t[:-1]) if neg else _num(t)
|
| if BANORTE_DEP[0] <= x1 <= BANORTE_DEP[1]: dep = v
|
| elif BANORTE_RET[0] <= x1 <= BANORTE_RET[1]: ret = v
|
| elif BANORTE_SAL[0] <= x1 <= BANORTE_SAL[1]: sal = v
|
| elif w['x0'] > 80:
|
| desc.append(t)
|
| if dep is None and ret is None:
|
| continue
|
| movs.append({'fecha': fecha, 'concepto': ' '.join(desc).strip(),
|
| 'deposito': dep, 'retiro': ret, 'saldo': sal})
|
| elif movs and 80 <= row[0]['x0'] <= 130:
|
|
|
|
|
|
|
|
|
| cont = ' '.join(w['text'] for w in row if not MONEY.match(w['text']) and not MONEY_NEG.match(w['text']))
|
| if cont:
|
| movs[-1]['concepto'] = (movs[-1]['concepto'] + ' ' + cont).strip()
|
| for m in movs:
|
| m['concepto'] = m['concepto'][:300]
|
| return movs
|
|
|
|
|
|
|
| import unicodedata as _ud
|
|
|
|
|
| def _up(s):
|
| return ''.join(c for c in _ud.normalize('NFD', s or '')
|
| if _ud.category(c) != 'Mn').upper()
|
|
|
|
|
| _MESES_ES = {'ENERO': 1, 'FEBRERO': 2, 'MARZO': 3, 'ABRIL': 4, 'MAYO': 5,
|
| 'JUNIO': 6, 'JULIO': 7, 'AGOSTO': 8, 'SEPTIEMBRE': 9, 'OCTUBRE': 10,
|
| 'NOVIEMBRE': 11, 'DICIEMBRE': 12}
|
|
|
|
|
| def _amex_anios(t):
|
| """mes->anio del periodo de facturacion, para fechar los movimientos. El
|
| periodo puede cruzar el anio (p.ej. 'Del 14 deDiciembre al 13 deEnero de2026'):
|
| el mes final lleva el anio impreso y el inicial el anio anterior si mes1>mes2."""
|
| a = {}
|
| mp = re.search(r'Del\s+\d{1,2}\s*de\s*([A-Za-zÁÉÍÓÚáéíóú]+)\s*al\s*\d{1,2}\s*de\s*'
|
| r'([A-Za-zÁÉÍÓÚáéíóú]+)\s*de\s*(\d{4})', t, re.I)
|
| if mp:
|
| m1 = _MESES_ES.get(_up(mp.group(1)))
|
| m2 = _MESES_ES.get(_up(mp.group(2)))
|
| y2 = int(mp.group(3))
|
| if m2:
|
| a[m2] = y2
|
| if m1:
|
| a[m1] = y2 if (m2 and m1 <= m2) else y2 - 1
|
| return a
|
|
|
|
|
| def _amex(pdf, mes_anio):
|
| """American Express: cada operacion es una fila 'DD deMES <comercio> <importe>'.
|
| CARGO (consumo) = importe SIN marca; ABONO (pago/credito) = importe con marca
|
| 'CR'. La 'CR' va en un renglon APARTE, debajo del importe (no en el texto del
|
| comercio: 'STARBUCKS OLO CR MEXICO' es CARGO), por eso se detecta por POSICION:
|
| una 'CR' cuyo top esta 0-20px debajo del importe y a +-60px de su borde derecho."""
|
| movs = []
|
| anio_def = max(mes_anio.values()) if mes_anio else 2025
|
| fre = re.compile(r'^(\d{1,2})\s*de([A-Za-zÁÉÍÓÚáéíóú]+)')
|
| for page in pdf.pages:
|
| words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| crs = [w for w in words if w['text'].strip() == 'CR']
|
|
|
| def es_cr(a):
|
| return any(0 < (c['top'] - a['top']) < 20 and abs(c['x1'] - a['x1']) < 60
|
| for c in crs)
|
|
|
| filas = {}
|
| for w in words:
|
| filas.setdefault(round(w['top']), []).append(w)
|
| for top in sorted(filas):
|
| row = sorted(filas[top], key=lambda x: x['x0'])
|
| txt = ' '.join(w['text'] for w in row)
|
| md = fre.match(txt)
|
| if not md:
|
| continue
|
| mes = _MESES_ES.get(_up(md.group(2)))
|
| if not mes:
|
| continue
|
| ams = [w for w in row if MONEY.match(w['text'])]
|
| if not ams:
|
| continue
|
| a = ams[-1]
|
| yr = mes_anio.get(mes, anio_def)
|
| fecha = '%04d-%02d-%02d' % (yr, mes, int(md.group(1)))
|
| concepto = re.sub(r'^\d{1,2}\s*de[A-Za-zÁÉÍÓÚáéíóú]+\s*', '', txt)
|
| concepto = re.sub(r'\s*[\d,]+\.\d{2}(\s*CR)?\s*$', '', concepto).strip()
|
| tipo = 'ABONO' if es_cr(a) else 'CARGO'
|
| movs.append({'fecha': fecha, 'concepto': concepto[:220],
|
| 'importe': _num(a['text']), 'tipo': tipo})
|
| return movs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _MX_FECHA = re.compile(r'^(\d{1,2})/([A-Za-z]{3})$')
|
|
|
|
|
|
|
|
|
| _MX_TOL = 8.0
|
|
|
|
|
| _MX_SEP = 13.0
|
| _MX_PERIODO = re.compile(
|
| r'PERIODO\s*:?\s*Del\s+(\d{1,2})\s+([A-Za-zÁÉÍÓÚáéíóú]+)\s+(\d{4})\s+al\s+'
|
| r'(\d{1,2})\s+([A-Za-zÁÉÍÓÚáéíóú]+)\s+(\d{4})', re.I)
|
|
|
|
|
|
|
| _MX_RUIDO = [
|
| re.compile(r'Clave\s+de\s+Rastreo\s*:?\s*[\w/]*', re.I),
|
| re.compile(r'Referencia\s+Numerica\s*:?\s*\d*', re.I),
|
| re.compile(r'Cuenta\s+(?:beneficiaria|ordenante)\s*:?\s*\d*', re.I),
|
| re.compile(r'Fecha\s+Confirmaci\w*\s*n?\s*de\s+[Ll]iquidaci\w*\s*n?\s*:?\s*'
|
| r'[\d\-]*\s*[\d:]*', re.I),
|
| re.compile(r'Dato\s+no\s+verificado\s+por\s+esta\s+institu\S*', re.I),
|
| re.compile(r'Email:\S+', re.I),
|
| re.compile(r'\b\d{15,}\b'),
|
| ]
|
|
|
|
|
| def _monex_periodo(pdf):
|
| """mes->anio del PERIODO de la hoja 1 ('PERIODO: Del 1 Marzo 2026 al 31 marzo
|
| 2026'): meses en ESPANOL y con mayuscula inconsistente, y sin anio en la fecha
|
| de cada movimiento ('02/Mar'). Los meses FUERA del periodo se fechan hacia
|
| ATRAS desde su inicio (igual que en BBVA), para que una operacion rezagada de
|
| diciembre dentro del estado de enero no se vaya al anio SIGUIENTE."""
|
| t = ' '.join((p.extract_text() or '') for p in pdf.pages[:3])
|
| a = {}
|
| y1 = None
|
| m = _MX_PERIODO.search(t)
|
| if m:
|
| m1, y1 = _MESES_ES.get(_up(m.group(2))), int(m.group(3))
|
| m2, y2 = _MESES_ES.get(_up(m.group(5))), int(m.group(6))
|
| if m2:
|
| a[m2] = y2
|
| if m1:
|
| a[m1] = y1
|
| y, mth = y1, m1
|
| for _ in range(11):
|
| mth -= 1
|
| if mth == 0:
|
| mth, y = 12, y - 1
|
| a.setdefault(mth, y)
|
| else:
|
|
|
| m2 = re.search(r'\bal\s+\d{1,2}\s+([A-Za-zÁÉÍÓÚáéíóú]+)\s+(\d{4})', t, re.I)
|
| if m2 and _MESES_ES.get(_up(m2.group(1))):
|
| y1 = int(m2.group(2))
|
| a[_MESES_ES[_up(m2.group(1))]] = y1
|
| ys = re.findall(r'\b(20\d{2})\b', t)
|
| return a, (y1 or (int(ys[0]) if ys else 2025))
|
|
|
|
|
| def _monex_hdr(words):
|
| """Encabezado de la tabla de movimientos -> (bandas, zonas, top_fin), o None
|
| si la pagina no es de la tabla.
|
|
|
| Se exige la firma COMPLETA (Descripcion + Referencia + Abonos + Cargos +
|
| 'Saldo total'): la hoja del 'Resumen Divisas' tambien trae las palabras
|
| 'Abonos', 'Cargos' y 'Saldo total:', y con una puerta laxa se colaria.
|
|
|
| Como se distingue 'Saldo total'(x1~764) de las otras tres columnas numericas
|
| contiguas: por su ETIQUETA COMPLETA, no por su posicion. La etiqueta se
|
| reconstruye a partir del par de palabras SALDO + TOTAL, aceptando las dos
|
| formas en que MONEX la imprime -- CONTIGUAS en el mismo renglon
|
| ('Saldo'@723/745 + 'total'@747/764, hueco 1.9 pt) o APILADAS en dos renglones
|
| con las cajas solapadas en x ('Saldo'@742/763 arriba, 'total'@746/763 abajo,
|
| en la tabla en dolares). Al exigir que la 2a palabra sea literalmente 'total',
|
| 'Saldo disponible'(688), 'Saldo en garantia'(609) y 'Movimiento
|
| garantia'(533) quedan descartadas aunque esten pegadas."""
|
| anc = [w for w in words
|
| if _up(w['text']).rstrip(':') == 'DESCRIPCION' and w['x0'] < 220]
|
| if not anc:
|
| return None
|
| desc = anc[0]
|
| hdr = [w for w in words if abs(w['top'] - desc['top']) <= 18]
|
|
|
| def uno(nombre):
|
| c = [w for w in hdr if _up(w['text']) == nombre]
|
| return c[0] if c else None
|
|
|
| ref, ab, ca = uno('REFERENCIA'), uno('ABONOS'), uno('CARGOS')
|
| if not (ref and ab and ca):
|
| return None
|
| sal = None
|
| for t in [w for w in hdr if _up(w['text']) == 'TOTAL']:
|
| for s in [w for w in hdr if _up(w['text']) == 'SALDO']:
|
| contiguo = abs(s['top'] - t['top']) <= 2 and 0 <= (t['x0'] - s['x1']) <= 3.5
|
| apilado = (abs(s['top'] - t['top']) > 2
|
| and min(s['x1'], t['x1']) > max(s['x0'], t['x0']))
|
| if contiguo or apilado:
|
| x1 = max(s['x1'], t['x1'])
|
| sal = x1 if sal is None else max(sal, x1)
|
| if sal is None:
|
| return None
|
| bandas = {'dep': ab['x1'], 'ret': ca['x1'], 'sal': sal}
|
|
|
|
|
|
|
| zonas = {'d0': desc['x0'] - 4, 'd1': ref['x0'] - 2, 'f1': desc['x0'] - 6}
|
| return bandas, zonas, max(w['top'] for w in hdr) + 3
|
|
|
|
|
| def _monex(pdf):
|
| """MONEX: layout multilinea con la fila del IMPORTE en MEDIO del bloque de
|
| descripcion (hay lineas de descripcion ANTES y DESPUES), la fecha EN LA MISMA
|
| fila que los importes, y bloques que CRUZAN de pagina.
|
|
|
| Tres trampas propias de MONEX:
|
| 1) Abonos Y Cargos se imprimen LOS DOS en cada fila (uno en '0.00'), asi que
|
| la regla de los otros bancos ('trae deposito Y retiro a la vez -> es fila
|
| de totales, ignorar') borraria TODOS los movimientos. Aqui un movimiento
|
| real es abonos>0 XOR cargos>0. Las filas 0.00/0.00 (deposito de intereses
|
| que el ISR netea el mismo dia) no mueven el saldo y no son movimiento.
|
| 2) El estado trae al final una SUBCUENTA EN DOLARES con la MISMA tabla y
|
| otras coordenadas. Se corta la lectura al cerrar la cuenta en pesos.
|
| 3) La fecha viene 'DD/Mmm' SIN anio (ver _monex_periodo).
|
| """
|
| mes_anio, anio_def = _monex_periodo(pdf)
|
| filas = []
|
| for ip, page in enumerate(pdf.pages):
|
| words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| if not words:
|
| continue
|
| T = _up(' '.join(w['text'] for w in words))
|
|
|
|
|
|
|
|
|
|
|
|
|
| if filas and (re.search(r'RESUMEN\s+CUENTA', T)
|
| or re.search(r'MOVIMIENTOS\s+DE\s+\w+\s+EN\s+\S', T)):
|
| break
|
| h = _monex_hdr(words)
|
| if not h:
|
| continue
|
| bandas, zonas, hdr_fin = h
|
| pw = {}
|
| for w in words:
|
| if w['top'] > hdr_fin:
|
| pw.setdefault(round(w['top'], 1), []).append(w)
|
| fin_tabla = False
|
| for top in sorted(pw):
|
| row = sorted(pw[top], key=lambda x: x['x0'])
|
|
|
|
|
|
|
| etiq = _up(' '.join(w['text'] for w in row
|
| if w['x0'] >= zonas['d1'] and not MONEY.match(w['text'])))
|
| if etiq.startswith(('SALDO FINAL', 'TOTAL')):
|
| fin_tabla = True
|
| break
|
| if etiq.startswith('SALDO INICIAL') or etiq.startswith('HOJA'):
|
| continue
|
| filas.append((ip, top, row, bandas, zonas))
|
| if fin_tabla:
|
| break
|
|
|
|
|
|
|
| imp = {}
|
| for i, (_ip, _top, row, bandas, zonas) in enumerate(filas):
|
| fk = None
|
| for w in row:
|
| if w['x0'] < zonas['f1']:
|
| fk = fk or _MX_FECHA.match(w['text'])
|
| if not fk or _up(fk.group(2)) not in _MESES:
|
| continue
|
| d = r = s = None
|
| for w in row:
|
| if not MONEY.match(w['text']):
|
| continue
|
| v = _num(w['text'])
|
| if abs(w['x1'] - bandas['dep']) <= _MX_TOL: d = v
|
| elif abs(w['x1'] - bandas['ret']) <= _MX_TOL: r = v
|
| elif abs(w['x1'] - bandas['sal']) <= _MX_TOL: s = v
|
| if d is None and r is None:
|
| continue
|
| imp[i] = (fk, d or 0.0, r or 0.0, s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| idx = sorted(imp)
|
| corte = {}
|
| for a, b in zip(idx, idx[1:]):
|
| mejor = (-1.0, None)
|
| salto = None
|
| for j in range(a, b):
|
| if filas[j][0] != filas[j + 1][0]:
|
| salto = j
|
| continue
|
| hueco = filas[j + 1][1] - filas[j][1]
|
| if hueco > mejor[0]:
|
| mejor = (hueco, j)
|
| if mejor[0] >= _MX_SEP or salto is None:
|
| corte[a] = mejor[1] if mejor[1] is not None else a
|
| else:
|
| corte[a] = salto
|
|
|
| movs = []
|
| for k, i in enumerate(idx):
|
| ini = corte[idx[k - 1]] + 1 if k else 0
|
| fin = corte[i] if k + 1 < len(idx) else len(filas) - 1
|
| fk, d, r, s = imp[i]
|
| if (d > 0) == (r > 0):
|
| continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| desc = []
|
| for j in range(ini, fin + 1):
|
| z = filas[j][4]
|
| for w in filas[j][2]:
|
| if z['d0'] <= w['x0'] < z['d1']:
|
| desc.append(w['text'])
|
| txt = re.sub(r'\s{2,}', ' ', ' '.join(desc))
|
| for rx in _MX_RUIDO:
|
| txt = rx.sub(' ', txt)
|
| txt = re.sub(r'\s{2,}', ' ', txt).strip(' ,;:')
|
| mnum = _MESES[_up(fk.group(2))]
|
| movs.append({
|
| 'fecha': '%02d-%s-%d' % (int(fk.group(1)), _up(fk.group(2)),
|
| mes_anio.get(mnum, anio_def)),
|
| 'concepto': txt[:220],
|
| 'deposito': d if d > 0 else None,
|
| 'retiro': r if r > 0 else None,
|
| 'saldo': s,
|
| })
|
| return movs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _HSBC_PERIODO = re.compile(
|
| r'Per[ií]odo\s*del\s*(\d{2})/(\d{2})/(\d{4})\s*al\s*(\d{2})/(\d{2})/(\d{4})', re.I)
|
|
|
|
|
|
|
|
|
|
|
|
|
| _HSBC_TOL = 22.0
|
|
|
|
|
|
|
|
|
|
|
| _HSBC_FIN = re.compile(
|
| r'^(Informaci[oó]n|Emitido\s+por|RESUMEN|CIFRAS\s+EXPRESADAS|Estimado|'
|
| r'Otros\s+Datos|Tus\s+Movimientos|Comisiones\s+Cobradas)', re.I)
|
|
|
|
|
| def _hsbc_periodo(pdf):
|
| """(mes, anio) del periodo del estado. El detalle solo trae el DIA."""
|
| t = ''
|
| for pg in pdf.pages[:2]:
|
| t += (pg.extract_text() or '')
|
| m = _HSBC_PERIODO.search(t)
|
| if m:
|
| return int(m.group(2)), int(m.group(3))
|
| return None, None
|
|
|
|
|
| def _hsbc_hdr(filas, tops):
|
| """(top, bandas) del encabezado del DETALLE en esta pagina, o None."""
|
| for top in tops:
|
| tx = {w['text']: w for w in filas[top]}
|
| if 'Retiro/Cargo' in tx and 'Depósito/Abono' in tx:
|
| sal = None
|
| for w in filas[top]:
|
| if w['text'] == 'Saldo' and w['x1'] > 500:
|
| sal = w['x1']
|
| b = {'ret': tx['Retiro/Cargo']['x1'], 'dep': tx['Depósito/Abono']['x1']}
|
| if sal is not None:
|
| b['sal'] = sal
|
| return top, b
|
| return None
|
|
|
|
|
| def _hsbc(pdf):
|
| """HSBC: una fila = un movimiento. La columna 'Dia' trae SOLO el dia (el mes
|
| y el anio salen del periodo). Columnas Retiro/Cargo, Deposito/Abono y Saldo
|
| alineadas a la derecha. Las lineas siguientes sin dia son continuacion de la
|
| descripcion o de la referencia."""
|
| mes, anio = _hsbc_periodo(pdf)
|
| movs = []
|
| for page in pdf.pages:
|
| words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
| filas = {}
|
| for w in words:
|
| filas.setdefault(round(w['top']), []).append(w)
|
| tops = sorted(filas)
|
| h = _hsbc_hdr(filas, tops)
|
| if not h:
|
| continue
|
| hdr_top, banda = h
|
|
|
| def col(x1):
|
| for nom in ('ret', 'dep', 'sal'):
|
| if nom in banda and abs(x1 - banda[nom]) <= _HSBC_TOL:
|
| return nom
|
| return None
|
|
|
| for top in tops:
|
| if top <= hdr_top + 2:
|
| continue
|
| row = sorted(filas[top], key=lambda z: z['x0'])
|
| txt = ' '.join(w['text'] for w in row)
|
| ret = dep = sal = None
|
| for w in row:
|
| if MONEY.match(w['text']):
|
| c = col(w['x1'])
|
| if c == 'ret': ret = _num(w['text'])
|
| elif c == 'dep': dep = _num(w['text'])
|
| elif c == 'sal': sal = _num(w['text'])
|
|
|
| desc = ' '.join(w['text'] for w in row
|
| if 60 < w['x0'] < 310 and not MONEY.match(w['text']))
|
|
|
| md = re.fullmatch(r'(\d{1,2})', row[0]['text'])
|
| es_mov = bool(md) and row[0]['x1'] <= 60 and (ret is not None or dep is not None)
|
| if es_mov:
|
| fecha = ('%02d-%s-%d' % (int(md.group(1)), _MES_ABBR[mes], anio)
|
| if mes else '')
|
| movs.append({'fecha': fecha, 'concepto': desc.strip(),
|
| 'deposito': dep, 'retiro': ret, 'saldo': sal})
|
| elif _HSBC_FIN.match(txt.strip()):
|
| break
|
| elif movs and desc.strip():
|
| movs[-1]['concepto'] = (movs[-1]['concepto'] + ' ' + desc.strip()).strip()
|
| for m in movs:
|
| m['concepto'] = m['concepto'][:220]
|
| return movs
|
|
|
|
|
| def extraer(path):
|
| import pdfplumber
|
| rep = tb.extraer(path)
|
| banco = rep['banco']
|
| moneda = rep.get('moneda', 'MXN')
|
|
|
| if banco == 'AMEX':
|
|
|
|
|
| with pdfplumber.open(path) as pdf:
|
| t_full = '\n'.join((p.extract_text() or '') for p in pdf.pages[:2])
|
| movs = _amex(pdf, _amex_anios(t_full))
|
| sum_car = round(sum(m['importe'] for m in movs if m['tipo'] == 'CARGO'), 2)
|
| sum_abo = round(sum(m['importe'] for m in movs if m['tipo'] == 'ABONO'), 2)
|
| rep_car, rep_abo = rep.get('cargos'), rep.get('pagos_creditos')
|
| cuadra = (rep_car is not None and rep_abo is not None
|
| and abs(sum_car - rep_car) <= 1.0 and abs(sum_abo - rep_abo) <= 1.0)
|
| tx = [{'fecha': m['fecha'], 'descripcion': m['concepto'],
|
| 'cargos': m['importe'] if m['tipo'] == 'CARGO' else None,
|
| 'abonos': m['importe'] if m['tipo'] == 'ABONO' else None,
|
| 'saldo': None, 'moneda': moneda, 'tipo': m['tipo']} for m in movs]
|
| return {
|
| 'banco': 'AMEX', 'tipo': 'TARJETA_CREDITO', 'moneda': moneda,
|
| 'ok': bool(cuadra), 'cuadra': bool(cuadra),
|
| 'suma_cargos': sum_car, 'suma_abonos': sum_abo,
|
| 'rep_cargos': rep_car, 'rep_abonos': rep_abo,
|
| 'suma_dep': sum_abo, 'suma_ret': sum_car,
|
| 'rep_dep': rep_abo, 'rep_ret': rep_car,
|
| 'tarjeta': rep,
|
| 'n': len(movs), 'transacciones': tx,
|
| }
|
|
|
| bandas = BANDAS.get(banco)
|
| with pdfplumber.open(path) as pdf:
|
| if banco == 'BANAMEX':
|
| movs = _banamex(pdf)
|
| elif banco == 'BBVA':
|
| movs = _bbva(pdf)
|
| elif banco == 'BANORTE':
|
| movs = _banorte(pdf)
|
| elif banco == 'MONEX':
|
| movs = _monex(pdf)
|
| elif banco == 'HSBC':
|
| movs = _hsbc(pdf)
|
| elif bandas:
|
| movs = _por_bandas(pdf, bandas)
|
| else:
|
| movs = []
|
| suma_dep = round(sum(m['deposito'] or 0 for m in movs), 2)
|
| suma_ret = round(sum(m['retiro'] or 0 for m in movs), 2)
|
| rd, rr = rep['depositos'], rep['retiros']
|
| cuadra = (rd is not None and rr is not None
|
| and abs(suma_dep - rd) <= 1.0 and abs(suma_ret - rr) <= 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| cuadra_saldo = False
|
| si0, sf0 = rep.get('saldo_inicial'), rep.get('saldo_final')
|
| if (not cuadra and rd is not None and rr is not None
|
| and si0 is not None and sf0 is not None and movs):
|
| con_saldo = [m for m in movs if m.get('saldo') is not None]
|
| if len(con_saldo) >= 0.9 * len(movs):
|
| acc, mal = si0, 0
|
| for m in movs:
|
| acc = round(acc + (m['deposito'] or 0) - (m['retiro'] or 0), 2)
|
| if m.get('saldo') is not None and abs(acc - m['saldo']) > 0.01:
|
| mal += 1
|
| simetrico = abs((suma_dep - rd) - (suma_ret - rr)) <= 0.01
|
| if mal == 0 and abs(acc - sf0) <= 0.01 and simetrico:
|
| cuadra_saldo = True
|
| cuadra = cuadra or cuadra_saldo
|
|
|
|
|
| tx_flujo = []
|
| for m in movs:
|
| tx_flujo.append({
|
| 'fecha': _fecha_iso(m['fecha']),
|
| 'descripcion': m['concepto'],
|
| 'cargos': m['retiro'],
|
| 'abonos': m['deposito'],
|
| 'saldo': m['saldo'],
|
| 'moneda': moneda,
|
| })
|
| return {
|
| 'banco': banco, 'moneda': moneda, 'ok': bool(cuadra), 'cuadra': bool(cuadra),
|
| 'suma_dep': suma_dep, 'suma_ret': suma_ret, 'rep_dep': rd, 'rep_ret': rr,
|
| 'saldo_inicial': rep.get('saldo_inicial'), 'saldo_final': rep.get('saldo_final'),
|
| 'cuadra_por_saldo': bool(cuadra_saldo),
|
| 'n': len(movs), 'transacciones': tx_flujo,
|
| }
|
|
|
|
|
| def main():
|
| if len(sys.argv) < 2:
|
| print(json.dumps({'error': 'Uso: extraer_movimientos.py <pdf>', 'ok': False}))
|
| sys.exit(1)
|
| try:
|
| r = extraer(sys.argv[1])
|
| print(json.dumps(r, ensure_ascii=False))
|
| except Exception as e:
|
| print(json.dumps({'error': str(e), 'ok': False}))
|
| sys.exit(1)
|
|
|
|
|
| if __name__ == '__main__':
|
| main()
|
|
|