198 lines
8.8 KiB
Python
198 lines
8.8 KiB
Python
#!/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()
|