Files
FRASA-CAS/api-gateway/scripts/sync-estatus-maestro.py
Consult AS Technology 04941e9f80 feat(api): webhooks Monday en tiempo real + alertas de inventario cableadas
- Webhook Oportunidades: cambio de etapa dispara escaneo (Aparto→Apartado,
  Ganado→Venta) además del round-robin de leads por create_item
- Acepta los nombres reales de los eventos de Monday: create_pulse/pulseId
  y update_column_value (verificados en vivo)
- Token interno (X-Internal-Token) para que los syncs locales llamen
  POST /cotizaciones/alertas-inventario sin JWT de usuario
- sync-estatus-maestro.py notifica los cambios aplicados al endpoint de
  alertas (actividad al vendedor con cotizaciones recientes)
- Fix P1008 SQLite: escaneos del poll cron secuenciales +
  connection_limit=1 en DATABASE_URL
- Webhooks registrados en Monday sobre https://frasa.consultoria-as.com
  (Leads create_item, Oportunidades change_column_value); crons quedan
  como red de seguridad
- Docs: sección 0 en AUTOMATIZACIONES-MONDAY.md con la matriz de eventos
  y los quirks de Monday
2026-08-28 16:46:08 +00:00

158 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Sync de estatus del Reporte Maestro (Enkontrol) -> board Unidades de Monday (18427678487).
Idempotente: lee reference/maestro/maestro-unidades.json, consulta el board y solo
muta items cuyo label de 'Disponibilidad' difiera del status_maestro. Excluye:
- unidades sin codigo_nuestro (sin match en nuestro inventario)
- unidades con baja_confianza_status (apartado sin fec_liberacion)
- items ya en el status correcto
Uso:
MONDAY_API_KEY=... python3 sync-estatus-maestro.py # aplica cambios
MONDAY_API_KEY=... python3 sync-estatus-maestro.py --dry-run # solo reporta
La API key también se toma de frasa-ecosystem/.env si no está en el entorno.
Rate limit: ~0.4s entre mutaciones, retry con backoff ante 429/5xx.
SIN PII: solo códigos de unidad y status.
"""
import json, os, sys, time, urllib.request, urllib.error
BOARD_ID = 18427678487
COL_STATUS = 'color_mm6edzbb' # 'Disponibilidad'
COL_DESARROLLO = 'text_mm6ehqqb'
API = 'https://api.monday.com/v2'
DRY = '--dry-run' in sys.argv
ROOT = os.path.dirname(os.path.abspath(__file__))
MAESTRO_JSON = os.path.join(ROOT, '../../reference/maestro/maestro-unidades.json')
def load_key():
key = os.environ.get('MONDAY_API_KEY')
if key:
return key
env = os.path.join(ROOT, '../../.env')
for line in open(env):
if line.startswith('MONDAY_API_KEY='):
return line.strip().split('=', 1)[1].strip().strip('"').strip("'")
sys.exit('MONDAY_API_KEY no encontrada')
KEY = load_key()
def gql(query, variables=None, reintentos=3):
body = json.dumps({'query': query, 'variables': variables or {}}).encode()
for intento in range(reintentos + 1):
if intento:
time.sleep(0.5 * 2 ** (intento - 1))
req = urllib.request.Request(API, data=body, headers={
'Content-Type': 'application/json', 'Authorization': KEY, 'API-Version': '2024-10'})
try:
res = json.load(urllib.request.urlopen(req))
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503) and intento < reintentos:
continue
raise
if res.get('errors'):
if intento < reintentos:
continue
raise RuntimeError(res['errors'])
return res['data']
raise RuntimeError('Monday no respondió')
def fetch_items():
items, cursor = [], None
while True:
q = ('query($cursor: String) { boards(ids: %d) { items_page(limit: 200, cursor: $cursor) '
'{ cursor items { id name column_values(ids: ["%s", "%s"]) { id text } } } } }'
% (BOARD_ID, COL_STATUS, COL_DESARROLLO))
page = gql(q, {'cursor': cursor})['boards'][0]['items_page']
items += page['items']
cursor = page['cursor']
if not cursor or not page['items']:
break
time.sleep(0.4)
return {(next(c['text'] for c in i['column_values'] if c['id'] == COL_DESARROLLO), i['name']): {
'id': i['id'],
'status': next(c['text'] for c in i['column_values'] if c['id'] == COL_STATUS)}
for i in items}
def set_status(item_id, label):
q = ('mutation($b: ID!, $i: ID!, $v: JSON!) { '
'change_column_value(board_id: $b, item_id: $i, column_id: "%s", value: $v) { id } }' % COL_STATUS)
gql(q, {'b': str(BOARD_ID), 'i': str(item_id), 'v': json.dumps({'label': label})})
def main():
maestro = json.load(open(MAESTRO_JSON))
board = fetch_items()
cambios, omitidos = [], []
for r in maestro:
code = r['codigo_nuestro']
if not code or r['baja_confianza_status']:
continue
item = board.get((r['desarrollo'], code))
if not item:
omitidos.append((code, 'no existe en el board'))
continue
if item['status'] != r['status_maestro']:
cambios.append({'item_id': item['id'], 'codigo': code,
'de': item['status'], 'a': r['status_maestro'],
'etapa': r['etapa_maestro'], 'fec': r['fec_ultima_etapa']})
print(f'{len(cambios)} cambios pendientes, {len(omitidos)} omitidos')
aplicados = 0
for c in cambios:
print(f" {c['codigo']}: {c['de']} -> {c['a']} ({c['etapa']} {c['fec'] or ''})"
+ (' [dry-run]' if DRY else ''))
if not DRY:
set_status(c['item_id'], c['a'])
aplicados += 1
time.sleep(0.4)
if not DRY:
print(f'{aplicados} items actualizados en Monday')
for code, motivo in omitidos:
print(f' omitido {code}: {motivo}')
if not DRY and aplicados:
notificar_alertas(cambios)
def notificar_alertas(cambios):
"""Avisa al API local de los cambios aplicados (alertas de inventario al
vendedor con cotizaciones recientes de la unidad). Best-effort: un fallo
aquí no revierte lo ya escrito en Monday."""
token = os.environ.get('INTERNAL_SYNC_TOKEN') or lee_env('INTERNAL_SYNC_TOKEN')
if not token:
print(' (sin INTERNAL_SYNC_TOKEN: se omiten las alertas de inventario)')
return
payload = json.dumps({'cambios': [
{'unidadCodigo': c['codigo'], 'statusNuevo': c['a']} for c in cambios
]}).encode()
req = urllib.request.Request(
'http://localhost:3000/api/cotizaciones/alertas-inventario',
data=payload,
headers={'Content-Type': 'application/json', 'X-Internal-Token': token})
try:
res = json.load(urllib.request.urlopen(req, timeout=15))
print(f" alertas-inventario: {res.get('alertasCreadas', 0)} actividad(es) creada(s), "
f"{res.get('omitidas', 0)} omitida(s), {res.get('fallidas', 0)} fallida(s)")
except Exception as e:
print(f' AVISO: no se pudieron enviar las alertas de inventario: {e}')
def lee_env(nombre):
env = os.path.join(ROOT, '../../.env')
try:
for line in open(env):
if line.startswith(nombre + '='):
return line.strip().split('=', 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return None
if __name__ == '__main__':
main()