#!/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 = 'color_mm6s39s9' # status 'Desarrollo' (antes text_mm6ehqqb, texto libre) 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()