importacion historico legacy completo: citas con estados reales + 24,497 ventas con pagos (cuadre al centavo 48/48 meses); method points en skeen_pagos + sin acumular al canjear; scripts y doc de migracion

This commit is contained in:
2026-10-10 10:27:29 +00:00
parent 2523635716
commit 4842552574
7 changed files with 1772 additions and 1 deletions

197
migracion/x_full_ventas.py Normal file
View File

@@ -0,0 +1,197 @@
#!/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()