#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Extracción FULL read-only de ventas del legacy SKEEN por mes natural. Rango: 2022-11 → 2026-10 (un request /ventas/json/{ini}/{fin} por mes). Salida (en /root/migracion/extraccion/concentrado/): - ventas_full.json : lista de ventas crudas (dedup por id), tal cual las devuelve el endpoint + _meses_vistos. - ventas_full.csv : una fila por (venta, concepto), con campos de pago principales aplanados y el desglose completo de métodos de pago del concepto en JSON. IMPORTANTE sobre el endpoint (verificado con probes 2026-10-10): - /ventas/json/{inicio}/{fin} filtra por FECHA DE PAGO (fecha del comprobante), NO por fecha de creación de la venta. Una venta creada en mayo con pago en agosto aparece en el rango de agosto. Una venta con pagos parciales en 2 meses aparece en AMBOS (por eso el CSV viejo tenía 2,224 legacy_id duplicados). Aquí se dedup por id conservando la aparición del ÚLTIMO mes (el estado de pago más acumulado). - Campos que el endpoint SÍ expone (keys observadas 2022 y 2026, idénticas): tipo, id, created_at, status, expedientes_id, nombre, doctor, motivo, subtotal, pagado, adeudo, comprobantes, conceptos, consumibles, count_pagos, puede_ver_montos, cancelada, motivo_cancelacion, sucursal_externa, sucursal_titulo - Campos que el endpoint NO expone (sorpresas): folio de venta (solo folio de comprobante), cantidad/precio_unitario en conceptos (el concepto trae titulo + subtotal + desglose de pagos, NO cantidad ni precio unitario), sucursal en la mayoría (sucursal_titulo suele venir vacío), y los montos a nivel venta (subtotal/pagado) suelen venir null — el dinero está a nivel concepto y comprobante. - conceptos[] keys: status, titulo, moneda (0=MN 1=USD), subtotal, pagado, pagado_dolares, pagado_pesos, pagado_tarjeta_mn, pagado_tarjeta_usd, pagado_metodos{pesos,dolares,tarjeta_mn,tarjeta_usd,metodos_pago{...}} - comprobantes: dict {id: {id, created_at, status, cancelado, folio}} Uso: /root/odoo-venv/bin/python /root/migracion/x_full_ventas.py """ import csv import json import sys import time from collections import Counter from datetime import date from pathlib import Path import requests sys.path.insert(0, '/root/migracion') import extraer_skeen as ex # login(), fetch_json(), dt_params() OUTDIR = Path('/root/migracion/extraccion/concentrado') OUTDIR.mkdir(parents=True, exist_ok=True) JSON_PATH = OUTDIR / 'ventas_full.json' CSV_PATH = OUTDIR / 'ventas_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.0 CSV_FIELDS = ['legacy_id', 'expedientes_id', 'paciente_nombre', 'doctor', 'fecha_creacion', 'mes_pago', 'cancelada', 'motivo_cancelacion', 'tipo', 'status_venta', 'subtotal_venta', 'pagado_venta', 'adeudo_usd', 'adeudo_mn', 'count_pagos', 'comprobantes', 'concepto_idx', 'concepto_status', 'concepto_titulo', 'concepto_moneda', 'concepto_subtotal', 'concepto_pagado', 'concepto_pagado_dolares', 'concepto_pagado_pesos', 'concepto_pagado_tarjeta_usd', 'concepto_pagado_tarjeta_mn', 'concepto_pagado_metodos_json', 'motivo', 'sucursal_titulo'] def last_day(ym): y, m = map(int, ym.split('-')) nxt = date(y + (m == 12), 1 if m == 12 else m + 1, 1) return (nxt.toordinal() - 1) def fetch_month(ym, max_attempts=5): y, m = map(int, ym.split('-')) ini = date(y, m, 1) fin = date.fromordinal(last_day(ym)) params = ex.dt_params(0, 10000) url = f'/ventas/json/{ini.isoformat()}/{fin.isoformat()}' for attempt in range(1, max_attempts + 1): try: data = ex.fetch_json(url, params) return data.get('data', []), data.get('recordsTotal') except Exception as e: msg = str(e) if 'login' in msg.lower(): ex.log(f'{ym}: sesión expirada, re-login...') ex.login() continue 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 main(): ex.login() seen = {} # id -> (orden de mes, record) ; el último mes gana order = [] # ids en orden de primera aparición month_counts = {} failed = [] for ym in MESES: try: rows, total = fetch_month(ym) except Exception as e: with open(ERROR_PATH, 'a', encoding='utf-8') as f: f.write(f'[ventas] {ym}: {e}\n') ex.log(f'ERROR {ym}: {e} → registrado, continúo') failed.append(ym) time.sleep(SLEEP) continue month_counts[ym] = len(rows) for r in rows: rid = r.get('id') if rid not in seen: order.append(rid) rec = dict(r) rec['_mes_pago'] = ym rec['_meses_vistos'] = sorted(set(seen[rid][1].get('_meses_vistos', [seen[rid][1]['_mes_pago']]) + [ym])) if rid in seen else [ym] seen[rid] = (len(order), rec) ex.log(f'{ym}: {len(rows)} ventas (recordsTotal={total})') time.sleep(SLEEP) sales = [seen[rid][1] for rid in order] # JSON crudo dedup with open(JSON_PATH, 'w', encoding='utf-8') as f: json.dump(sales, f, ensure_ascii=False, indent=1) ex.log(f'JSON: {JSON_PATH} ({len(sales)} ventas únicas)') # CSV aplanado por concepto n_rows = 0 with open(CSV_PATH, 'w', newline='', encoding='utf-8') as f: w = csv.DictWriter(f, fieldnames=CSV_FIELDS, extrasaction='ignore') w.writeheader() for r in sales: conceptos = r.get('conceptos') or [] if not conceptos: conceptos = [{}] adeudo = r.get('adeudo') or [0, 0] comps = r.get('comprobantes') or {} comp_txt = '; '.join( f"{c.get('folio','')}|{str(c.get('created_at',''))[:10]}|cancelado={c.get('cancelado')}" for c in comps.values()) base = { 'legacy_id': r.get('id', ''), 'expedientes_id': r.get('expedientes_id') or '', 'paciente_nombre': r.get('nombre') or '', 'doctor': r.get('doctor') or '', 'fecha_creacion': (r.get('created_at') or '')[:10], 'mes_pago': r.get('_mes_pago', ''), 'cancelada': r.get('cancelada') or 0, 'motivo_cancelacion': r.get('motivo_cancelacion') or '', 'tipo': r.get('tipo') or '', 'status_venta': r.get('status') or 0, 'subtotal_venta': r.get('subtotal') if r.get('subtotal') is not None else '', 'pagado_venta': r.get('pagado') if r.get('pagado') is not None else '', 'adeudo_usd': adeudo[0] if len(adeudo) > 0 else '', 'adeudo_mn': adeudo[1] if len(adeudo) > 1 else '', 'count_pagos': r.get('count_pagos') or 0, 'comprobantes': comp_txt, 'motivo': r.get('motivo') or '', 'sucursal_titulo': r.get('sucursal_titulo') or '', } for i, c in enumerate(conceptos): row = dict(base) row.update({ 'concepto_idx': i, 'concepto_status': c.get('status') if c else '', 'concepto_titulo': c.get('titulo') or '', 'concepto_moneda': c.get('moneda') if c is not None and 'moneda' in c else '', 'concepto_subtotal': c.get('subtotal') if c else '', 'concepto_pagado': c.get('pagado') if c else '', 'concepto_pagado_dolares': c.get('pagado_dolares') if c else '', 'concepto_pagado_pesos': c.get('pagado_pesos') if c else '', 'concepto_pagado_tarjeta_usd': c.get('pagado_tarjeta_usd') if c else '', 'concepto_pagado_tarjeta_mn': c.get('pagado_tarjeta_mn') if c else '', 'concepto_pagado_metodos_json': json.dumps(c.get('pagado_metodos'), ensure_ascii=False) if c else '', }) w.writerow(row) n_rows += 1 ex.log('========== RESUMEN VENTAS ==========') ex.log(f'Meses pedidos: {len(MESES)} | ok: {len(month_counts)} | fallados: {failed}') ex.log(f'Ventas únicas (dedup por id): {len(sales)}') ex.log(f'Filas CSV (venta×concepto): {n_rows}') canc = Counter(r.get('cancelada') for r in sales) ex.log(f'cancelada distribution: {dict(canc)}') ex.log(f'CSV: {CSV_PATH}') if __name__ == '__main__': main()