#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Descarga mensual del reporte concentrado del legacy SKEEN (SOLO LECTURA). Por cada mes 2022-11 → 2026-10 hace POST a /reportes/concentrado/excel (mismo formulario que la vista HTML del reporte; no modifica datos) y guarda el Excel en extraccion/concentrado/concentrado_mensual/concentrado_YYYY-MM.xlsx. Luego combina todo en concentrado_full.csv (17 columnas del reporte + mes). Quirk del Excel generado por el legacy: con cadenas vacías → openpyxl 3.1.5 revienta ("expected "). Se parchea el XML al guardar (márgenes por defecto), quedando el .xlsx en disco ya abrible. El Excel trae 4 filas de encabezado (título, rango, vacía, cabeceras de las 17 columnas) y desde la fila 5 los datos. No trae fila de totales (las totales solo salen en el HTML, tabla resumen). Uso: /root/odoo-venv/bin/python /root/migracion/x_full_concentrado.py """ import csv import io import json import re import sys import time import zipfile from collections import defaultdict from datetime import datetime from pathlib import Path import openpyxl import requests from bs4 import BeautifulSoup sys.path.insert(0, '/root/migracion') import extraer_skeen as ex # login(), MESES_ES OUTDIR = Path('/root/migracion/extraccion/concentrado') MENSUAL = OUTDIR / 'concentrado_mensual' MENSUAL.mkdir(parents=True, exist_ok=True) CSV_PATH = OUTDIR / 'concentrado_full.csv' ERROR_PATH = OUTDIR / 'errores.txt' MESES = ['2022-11', '2022-12'] + [f'{y}-{m:02d}' for y in range(2023, 2027) for m in range(1, 13)] MESES = [m for m in MESES if '2022-11' <= m <= '2026-10'] SLEEP = 1.2 COLS = ['folio', 'fecha', 'nombre_paciente', 'folio_dr', 'atendido_por', 'vendedor', 'concepto', 'efectivo_usd', 'efectivo_mn', 'tarjeta_usd', 'tarjeta_mn', 'transferencia_usd', 'transferencia_mn', 'saldo_a_favor_usd', 'saldo_a_favor_mn', 'monedero_usd', 'monedero_mn', 'mes'] MARG_RE = re.compile(r' bytes: zin = zipfile.ZipFile(io.BytesIO(data)) out = io.BytesIO() with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zout: for item in zin.infolist(): buf = zin.read(item.filename) if item.filename.startswith('xl/worksheets/') and buf.lstrip().startswith(b'<'): txt = buf.decode('utf-8') txt = MARG_RE.sub('= 500: raise requests.HTTPError(f'{r.status_code}') ct = r.headers.get('Content-Type') or '' if 'spreadsheetml' not in ct and 'octet-stream' not in ct: raise ValueError(f'respuesta no-Excel: ct={ct} url={r.url} bytes={len(r.content)}') return patch_xlsx(r.content) except Exception as e: wait = 2 ** attempt ex.log(f'{ym}: intento {attempt}/{max_attempts} falló: {e} (espera {wait}s)') time.sleep(wait) raise RuntimeError(f'{ym}: imposible descargar tras {max_attempts} intentos') def parse_xlsx(path, ym): wb = openpyxl.load_workbook(path, read_only=True) ws = wb.active rows = list(ws.iter_rows(values_only=True)) wb.close() # localizar fila de cabecera (col A == 'Folio') hdr_idx = None for i, row in enumerate(rows[:10]): if row and str(row[0]).strip() == 'Folio': hdr_idx = i break if hdr_idx is None: raise ValueError(f'{path}: fila de cabecera no encontrada') header = [str(h).strip() if h is not None else '' for h in rows[hdr_idx]] out = [] for row in rows[hdr_idx + 1:]: if not row or row[0] is None or str(row[0]).strip() == '': continue vals = list(row) + [None] * (17 - len(row)) d = {COLS[i]: vals[i] for i in range(17)} a = str(d['folio']).strip().lower() if a.startswith('total'): continue # por si algún mes trae fila de totales d['mes'] = ym out.append(d) return out def parse_fecha_es(s): m = re.match(r'(\d{1,2})/([A-Za-zÁÉÍÓÚáéíóúñ]+)/(\d{4})', str(s or '').strip()) if not m: return None dia, mes, anio = m.groups() mn = ex.MESES_ES.get(mes.lower()) if not mn: return None return f'{anio}-{mn}-{int(dia):02d}' def main(): ex.login() token = get_token() failed = [] row_counts = {} all_rows = [] for ym in MESES: dest = MENSUAL / f'concentrado_{ym}.xlsx' if dest.exists() and dest.stat().st_size > 2000: ex.log(f'{ym}: ya existe {dest.name} ({dest.stat().st_size} bytes), reutilizo') else: try: data = download_month(ym, token) except Exception as e: with open(ERROR_PATH, 'a', encoding='utf-8') as f: f.write(f'[concentrado] {ym}: {e}\n') ex.log(f'ERROR {ym}: {e} → registrado en errores.txt, continúo') failed.append(ym) time.sleep(SLEEP) continue dest.write_bytes(data) ex.log(f'{ym}: guardado {dest.name} ({len(data)} bytes)') time.sleep(SLEEP) try: rows = parse_xlsx(dest, ym) row_counts[ym] = len(rows) all_rows.extend(rows) except Exception as e: with open(ERROR_PATH, 'a', encoding='utf-8') as f: f.write(f'[concentrado-parse] {ym}: {e}\n') ex.log(f'ERROR parse {ym}: {e}') failed.append(ym) with open(CSV_PATH, 'w', newline='', encoding='utf-8') as f: w = csv.DictWriter(f, fieldnames=COLS, extrasaction='ignore') w.writeheader() for d in all_rows: w.writerow({k: ('' if d.get(k) is None else d.get(k)) for k in COLS}) ex.log('========== RESUMEN CONCENTRADO ==========') ex.log(f'Meses pedidos: {len(MESES)} | descargados/parseados: {len(row_counts)} | fallados: {failed}') ex.log(f'Filas totales concentrado_full.csv: {len(all_rows)}') by_year = defaultdict(int) for d in all_rows: by_year[str(d['mes'])[:4]] += 1 ex.log(f'Filas por año: {dict(sorted(by_year.items()))}') ex.log(f'CSV: {CSV_PATH}') # ---- Verificación cruzada: semana 2026-08-01..07 del Excel vs HTML ya guardado ---- probe = OUTDIR / 'probe_concentrado.html' if probe.exists(): soup = BeautifulSoup(probe.read_text(encoding='utf-8'), 'html.parser') tabs = soup.find_all('table') sums_html = {} if len(tabs) >= 1: for cell in tabs[0].find_all('tr')[1].find_all('td'): txt = cell.get_text(strip=True) mm = re.match(r'(Efectivo|Tarjeta|Transferencia|Saldo a favor|Monedero electrónico)\$\s*([\d,]+\.\d{2})\s*USD', txt) if mm: sums_html[mm.group(1)] = float(mm.group(2).replace(',', '')) for cell in tabs[0].find_all('tr')[2].find_all('td'): txt = cell.get_text(strip=True) mm = re.match(r'(Efectivo|Tarjeta|Transferencia|Saldo a favor|Monedero electrónico)\$\s*([\d,]+\.\d{2})\s*M\.N\.', txt) if mm: sums_html[mm.group(1) + ' MN'] = float(mm.group(2).replace(',', '')) mine = defaultdict(float) for d in all_rows: f = parse_fecha_es(d['fecha']) if f and '2026-08-01' <= f <= '2026-08-07': mine['Efectivo'] += float(d['efectivo_usd'] or 0) mine['Efectivo MN'] += float(d['efectivo_mn'] or 0) mine['Tarjeta'] += float(d['tarjeta_usd'] or 0) mine['Tarjeta MN'] += float(d['tarjeta_mn'] or 0) mine['Transferencia'] += float(d['transferencia_usd'] or 0) mine['Transferencia MN'] += float(d['transferencia_mn'] or 0) mine['Saldo a favor'] += float(d['saldo_a_favor_usd'] or 0) mine['Saldo a favor MN'] += float(d['saldo_a_favor_mn'] or 0) mine['Monedero electrónico'] += float(d['monedero_usd'] or 0) mine['Monedero electrónico MN'] += float(d['monedero_mn'] or 0) ex.log('---- Cross-check semana 2026-08-01..07 (Excel mensual vs HTML) ----') ok = True for k in sorted(set(sums_html) | set(mine)): a, b = sums_html.get(k, 0.0), round(mine.get(k, 0.0), 2) match = abs(a - b) < 0.01 ok = ok and match ex.log(f' {k}: HTML={a:>12,.2f} Excel={b:>12,.2f} {"OK" if match else "DIFF"}') ex.log(f' RESULTADO: {"TODO COINCIDE" if ok else "HAY DIFERENCIAS"}') if __name__ == '__main__': main()