Files
SKEEN-Proyecto/migracion/x_full_ventas.py

198 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()