#!/usr/bin/env python3 """Sync de obra/estatus del Reporte_EstatusVenta.xlsx -> Monday (workspace Frasa-CAS). Fuentes (hojas del Excel) y destino: - ESTATUS OBRA -> Unidades: % Avance obra, Contratista, Fecha entrega obra (SENDA; SOLE se salta) - SENDA-ACT-AGO2026. -> Unidades SENDA: Fecha entrega obra, hitos (checkbox por fecha <= hoy), No. oficial / Clave catastral (texto; el Excel trae fechas programadas) - SENDA DG -> Unidades SENDA: Meses atraso, % Avance obra (fallback si ESTATUS OBRA no cubrio) - Diagonal 38 -> Unidades DIAGONAL: hitos, No. oficial, Clave catastral, Fecha entrega obra - Mutuo -> solo reporte (MUTUO no esta en el board Unidades) - Solana/Atelier/Estanza/Senda/Sole -> board "Saldos a Favor" (saldo > 0) - Flujo Calendario -> board "Flujo Escrituras" (item por mes/semana/proyecto) Idempotente: lee valores actuales y solo muta lo que cambio; en boards nuevos la llave es Unidad+Desarrollo (saldos) o el name unico (flujo). Checkboxes solo se encienden (nunca se apagan). Nada de PII se versiona: los datos viven solo en Monday. Uso: python3 sync-obra-estatus.py # aplica cambios python3 sync-obra-estatus.py --dry-run # solo reporta La API key se toma de MONDAY_API_KEY o de frasa-ecosystem/.env. Rate limit: ~0.4s entre mutaciones, retry con backoff ante 429/5xx. """ import json, os, re, sys, time, urllib.request, urllib.error from datetime import date, datetime # ---------------- IDs de Monday (creados 2026-09-01 via API) ---------------- BOARD_UNIDADES = 18427678487 COL = { 'desarrollo': 'color_mm6s39s9', # status 'Desarrollo' (antes texto libre), 'avance': 'numeric_mm6sdh3c', # % Avance obra 'atraso': 'numeric_mm6s6bkz', # Meses atraso 'contratista': 'text_mm6s3992', 'catastral': 'text_mm6s5tb6', # Clave catastral 'noficial': 'text_mm6s7zan', # No. oficial 'vendedor': 'text_mm6s4m51', 'broker': 'text_mm6sxzn8', 'venc_aut': 'date_mm6s3pyx', # Venc. autorización 'fentrega_obra': 'date_mm6sw443', # Fecha entrega obra (date_mm6empjv ya tenia datos) 'gas': 'boolean_mm6s2fze', # Cert. Gas 'toc4': 'boolean_mm6s8crm', # TO / C4 'regimen': 'boolean_mm6sktee', # Régimen inscrito 'avaluo': 'boolean_mm6spj1y', # Lista avalúo } BOARD_FLUJO = 18429252054 COL_FLUJO = { 'proyecto': 'color_mm6scbgh', 'mes': 'text_mm6s19pw', 'semana': 'text_mm6sa5t7', 'fecha': 'date_mm6ssf9s', 'escrituras': 'numeric_mm6sqraa', 'ingreso': 'numeric_mm6swy2f', } BOARD_SALDOS = 18429252069 COL_SALDOS = { 'desarrollo': 'color_mm6smmyz', 'unidad': 'text_mm6sr7d2', 'monto': 'numeric_mm6spv2d', 'estado': 'color_mm6sbebg', 'comentario': 'text_mm6sa9hz', } API = 'https://api.monday.com/v2' DRY = '--dry-run' in sys.argv HOY = date.today() ROOT = os.path.dirname(os.path.abspath(__file__)) EXCEL = '/root/Reporte_EstatusVenta.xlsx' MAESTRO_JSON = os.path.join(ROOT, '../../reference/maestro/maestro-unidades.json') PROYECTOS = ['ATELIER', 'ESTANZA', 'SOLE', 'SOLANA', 'SENDA', 'MUTUO'] MESES = {'ene': 1, 'feb': 2, 'mar': 3, 'abr': 4, 'may': 5, 'jun': 6, 'jul': 7, 'ago': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dic': 12} MESES_FLOJO = {'MAYO': 5, 'JUNIO': 6, 'JULIO': 7, 'AGOSTO': 8} saltadas = [] # (unidad, fuente, motivo) 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 pausa(): if not DRY: time.sleep(0.4) # ---------------- parsers tolerantes ---------------- def parse_fecha(v): """datetime/str ('22-jun.-26', 'vie 09/04/27', '17/07/206') -> date o None.""" if isinstance(v, datetime): return v.date() if v.date().year > 2000 else None if isinstance(v, date): return v if not isinstance(v, str): return None s = v.strip().lower().replace('\xa0', ' ') m = re.search(r'(\d{1,2})\s*[/-]\s*([a-záé.]+|\d{1,2})\s*[/-]\s*(\d{2,4})', s) if not m: return None d, mes, y = m.group(1), m.group(2).rstrip('.'), m.group(3) try: mes = MESES[mes[:3]] if not mes.isdigit() else int(mes) y = int(y) y = 2000 + (y % 100) if y < 1000 else y return date(y, mes, int(d)) except (KeyError, ValueError): return None def parse_numero(v): """int/float o texto '$4,306,500.00' -> float o None (#REF!, #VALUE!, vacio).""" if isinstance(v, (int, float)): return float(v) if not isinstance(v, str): return None s = v.replace('\xa0', '').strip() if not s or s.startswith('#'): return None s = s.replace('$', '').replace(',', '').strip() try: return float(s) except ValueError: return None def parse_pct(v): """'48.6%' o 0.486 o 48.6 -> 48.6.""" if isinstance(v, str): v = parse_numero(v.replace('%', '')) if v is None: return None elif not isinstance(v, (int, float)): return None v = float(v) return v * 100 if 0 < v <= 1 else v def norm_clave(v): """Normaliza mza/lote/interior: 26.0 -> '26', '01' -> '1', ' 7A ' -> '7A'.""" if v is None: return None if isinstance(v, float) and v.is_integer(): v = int(v) s = str(v).strip().replace('\xa0', '') return s.lstrip('0') or '0' if s.isdigit() else s def hito(v): """Checkbox por fecha del hito: True si la fecha ya llego; None si no aplica.""" f = parse_fecha(v) return True if f and f <= HOY else None # ---------------- Monday: lectura ---------------- def fetch_items(board_id, col_ids): items, cursor = [], None sel = ' column_values(ids: [%s]) { id text }' % ','.join('"%s"' % c for c in col_ids) if col_ids else '' while True: q = ('query($cursor: String) { boards(ids: %d) { items_page(limit: 200, cursor: $cursor) ' '{ cursor items { id name%s } } } }' % (board_id, sel)) 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 [{'id': i['id'], 'name': i['name'], 'cols': {c['id']: c['text'] for c in i.get('column_values', [])}} for i in items] def cambiar_valores(item_id, board_id, valores): q = ('mutation($b: ID!, $i: ID!, $v: JSON!) { change_multiple_column_values(' 'board_id: $b, item_id: $i, column_values: $v, create_labels_if_missing: true) { id } }') gql(q, {'b': str(board_id), 'i': str(item_id), 'v': json.dumps(valores)}) def crear_item(board_id, nombre, valores): q = ('mutation($b: ID!, $n: String!, $v: JSON!) { create_item(' 'board_id: $b, item_name: $n, column_values: $v, create_labels_if_missing: true) { id } }') return gql(q, {'b': str(board_id), 'n': nombre, 'v': json.dumps(valores)})['create_item']['id'] # ---------------- staging de Unidades ---------------- def valor_monday(col_key, v): if col_key in ('fentrega_obra', 'venc_aut'): return {'date': v.isoformat()} if col_key in ('gas', 'toc4', 'regimen', 'avaluo'): return {'checked': True} if col_key in ('avance', 'atraso'): return v return str(v) def igual(col_key, actual, deseado): if col_key in ('avance', 'atraso'): try: return abs(float(actual) - float(deseado)) < 1e-6 except (TypeError, ValueError): return False if col_key in ('fentrega_obra', 'venc_aut'): return actual == deseado.isoformat() if col_key in ('gas', 'toc4', 'regimen', 'avaluo'): return (actual or '').lower() in ('true', '✓', 'v', '1') return (actual or '') == str(deseado) class SyncUnidades: """Acumula cambios por item y aplica solo lo que difiere del board.""" def __init__(self): self.items = {} # name -> {'id', 'cols'} self.cambios = {} # item_id -> {col_key: valor} self.avance_cubierto = set() # items con avance desde ESTATUS OBRA def cargar(self, items): self.items = {i['name']: i for i in items} def set(self, codigo, col_key, valor, fuente, sobrescribir=True): if isinstance(valor, str): valor = valor.replace('\xa0', ' ').strip() if valor is None or valor == '': return item = self.items.get(codigo) if not item: saltadas.append((codigo, fuente, 'no existe en board Unidades')) return if col_key == 'avance': if not sobrescribir and codigo in self.avance_cubierto: return if sobrescribir: self.avance_cubierto.add(codigo) staged = self.cambios.setdefault(item['id'], {}) if igual(col_key, item['cols'].get(COL[col_key]), valor): # la fuente mas reciente coincide con el board: descarta lo ya acumulado staged.pop(col_key, None) if not staged: del self.cambios[item['id']] return staged[col_key] = valor def aplicar(self): for item_id, cols in sorted(self.cambios.items()): valores = {COL[k]: valor_monday(k, v) for k, v in cols.items()} nombre = next(i['name'] for i in self.items.values() if i['id'] == item_id) print(' %s: %s%s' % (nombre, ', '.join(sorted(cols)), ' [dry-run]' if DRY else '')) if not DRY: cambiar_valores(item_id, BOARD_UNIDADES, valores) pausa() # ---------------- fuentes: hojas del Excel ---------------- def leer_estatus_obra(wb, sync): ws = wb['ESTATUS OBRA'] proyecto = None for i, r in enumerate(ws.iter_rows(min_row=3, max_row=54, max_col=19, values_only=True), 3): marca = next((str(c).upper() for c in r[:3] if isinstance(c, str) and 'PROYECTO' in c.upper()), None) if marca: proyecto = 'SOLE' if 'SOLE' in marca else 'SENDA' if 'SENDA' in marca else proyecto continue viv = r[3] if viv is None or not str(viv).strip(): continue if proyecto == 'SOLE': saltadas.append(('SOLE-%s' % norm_clave(viv), 'ESTATUS OBRA', 'SOLE no esta en board Unidades')) continue m = re.match(r'M-(\d+)-L(\d+)-(\w+)', str(viv).strip()) if not m: saltadas.append((str(viv), 'ESTATUS OBRA fila %d' % i, 'clave no reconocida')) continue codigo = 'SENDA-%d-%d-%s' % (int(m.group(1)), int(m.group(2)), norm_clave(m.group(3))) sync.set(codigo, 'avance', parse_pct(r[8]), 'ESTATUS OBRA') sync.set(codigo, 'contratista', str(r[6]).strip() if r[6] else None, 'ESTATUS OBRA') sync.set(codigo, 'fentrega_obra', parse_fecha(r[12]), 'ESTATUS OBRA') def leer_senda_act(wb, sync): ws = wb['SENDA-ACT-AGO2026.'] for i, r in enumerate(ws.iter_rows(min_row=4, max_row=126, max_col=12, values_only=True), 4): unidad = r[3] if unidad is None or not str(unidad).strip(): continue m = re.match(r'\s*Mza\s*(\w+)\s*L(?:ote)?\s*-?\s*(\w+)(?:\s*int\s*(\w+))?', str(unidad), re.I) if not m: saltadas.append((str(unidad), 'SENDA-ACT fila %d' % i, 'clave no reconocida')) continue mza, lote, interior = norm_clave(m.group(1)), norm_clave(m.group(2)), norm_clave(m.group(3)) or '1' codigo = resolver_senda(mza, lote, interior, preferir_directo=True) if not codigo: saltadas.append((str(unidad), 'SENDA-ACT fila %d' % i, 'sin mapeo a codigo_nuestro')) continue sync.set(codigo, 'fentrega_obra', parse_fecha(r[5]), 'SENDA-ACT') sync.set(codigo, 'gas', hito(r[6]), 'SENDA-ACT') sync.set(codigo, 'toc4', hito(r[7]), 'SENDA-ACT') sync.set(codigo, 'regimen', hito(r[8]), 'SENDA-ACT') f_no, f_cc = parse_fecha(r[9]), parse_fecha(r[10]) sync.set(codigo, 'noficial', f_no.isoformat() if f_no else r[9], 'SENDA-ACT') sync.set(codigo, 'catastral', f_cc.isoformat() if f_cc else r[10], 'SENDA-ACT') sync.set(codigo, 'avaluo', hito(r[11]), 'SENDA-ACT') def leer_senda_dg(wb, sync): ws = wb['SENDA DG'] for i, r in enumerate(ws.iter_rows(min_row=3, max_row=143, max_col=14, values_only=True), 3): ubic, mz, lt = norm_clave(r[3]), norm_clave(r[4]), norm_clave(r[5]) if not mz or not lt or not ubic: continue if '#' in mz + lt + ubic: saltadas.append(('%s-%s-%s' % (mz, lt, ubic), 'SENDA DG fila %d' % i, '#REF! en clave')) continue codigo = resolver_senda(mz, lt, ubic) if not codigo: saltadas.append(('SENDA-%s-%s-%s' % (mz, lt, ubic), 'SENDA DG fila %d' % i, 'sin mapeo')) continue atraso = parse_numero(r[9]) sync.set(codigo, 'atraso', atraso, 'SENDA DG') sync.set(codigo, 'avance', parse_pct(r[10]), 'SENDA DG', sobrescribir=False) def leer_diagonal(wb, sync): ws = wb['Diagonal 38'] for i, r in enumerate(ws.iter_rows(min_row=3, max_row=40, max_col=12, values_only=True), 3): if r[2] is None: continue codigo = 'U-%s' % norm_clave(r[2]) sync.set(codigo, 'gas', hito(r[5]), 'Diagonal 38') sync.set(codigo, 'toc4', hito(r[6]), 'Diagonal 38') sync.set(codigo, 'regimen', hito(r[7]), 'Diagonal 38') f_no, f_cc = parse_fecha(r[8]), parse_fecha(r[9]) sync.set(codigo, 'noficial', f_no.isoformat() if f_no else r[8], 'Diagonal 38') sync.set(codigo, 'catastral', f_cc.isoformat() if f_cc else r[9], 'Diagonal 38') sync.set(codigo, 'avaluo', hito(r[10]), 'Diagonal 38') sync.set(codigo, 'fentrega_obra', parse_fecha(r[11]), 'Diagonal 38') def leer_mutuo(wb): ws = wb['Mutuo'] n = 0 for i, r in enumerate(ws.iter_rows(min_row=9, max_row=32, max_col=29, values_only=True), 9): if r[25] is not None or r[27] is not None: n += 1 print('Mutuo: %d filas con venc. autorizacion/vendedor; MUTUO no esta en board Unidades ' '-> vendedor/broker/venc. no se cargan esta corrida' % n) # ---------------- maestro ---------------- def resolver_senda(mza, lote, interior, preferir_directo=False): directo = 'SENDA-%s-%s-%s' % (mza, lote, interior) if preferir_directo and directo in BOARD_NAMES: return directo codigo = MAESTRO.get((mza, lote, interior)) if codigo and codigo in BOARD_NAMES: return codigo if directo in BOARD_NAMES: return directo # caso invertido del maestro (ej. Excel mza/lote/int=410/1/B201 -> maestro 410/B201/1) codigo = MAESTRO.get((mza, interior, lote)) if codigo and codigo in BOARD_NAMES: return codigo return None # ---------------- Saldos a Favor ---------------- def sync_saldos(wb): hojas = {'Solana I y II': 'SOLANA', 'Atelier': 'ATELIER', 'Estanza': 'ESTANZA', 'Senda': 'SENDA', 'Sole': 'SOLE'} existentes = fetch_items(BOARD_SALDOS, list(COL_SALDOS.values())) por_llave = {(e['cols'].get(COL_SALDOS['desarrollo']), e['cols'].get(COL_SALDOS['unidad'])): e for e in existentes} creados = actualizados = sin_cambio = 0 for hoja, desarrollo in hojas.items(): ws = wb[hoja] rows = list(ws.iter_rows(min_row=4, max_row=min(ws.max_row, 500), max_col=min(ws.max_column, 40), values_only=True)) if not rows: continue hdr = {str(v).strip().upper(): j for j, v in enumerate(rows[0]) if v is not None} c_saldo = next((j for k, j in hdr.items() if k.startswith('SALDO A FAVOR')), None) c_nombre = hdr.get('NOMBRE') c_unidad = hdr.get('UNIDADES', hdr.get('UNIDAD')) c_com = next((j for k, j in hdr.items() if k.startswith('COMENTARIO')), None) if c_saldo is None or c_nombre is None: continue for i, r in enumerate(rows[1:], 5): monto = parse_numero(r[c_saldo]) if not monto or monto <= 0: continue nombre = str(r[c_nombre]).strip() if r[c_nombre] else '?' if c_unidad is not None and r[c_unidad] is not None: unidad = str(r[c_unidad]).strip() elif desarrollo == 'SENDA': unidad = '%s-%s-%s' % (norm_clave(r[hdr.get('MZA', -1)]), norm_clave(r[hdr.get('LOTE', -1)]), norm_clave(r[hdr.get('UBICACIÓN', -1)])) else: unidad = '?' comentario = str(r[c_com]).strip() if c_com is not None and r[c_com] else None llave = (desarrollo, unidad) if llave in por_llave: e = por_llave[llave] dif = {} if not igual('monto', e['cols'].get(COL_SALDOS['monto']), monto): dif[COL_SALDOS['monto']] = monto if comentario and (e['cols'].get(COL_SALDOS['comentario']) or '') != comentario: dif[COL_SALDOS['comentario']] = comentario if dif: print(' saldo %s/%s: actualiza %s%s' % (desarrollo, unidad, sorted(dif), ' [dry-run]' if DRY else '')) if not DRY: cambiar_valores(e['id'], BOARD_SALDOS, dif) pausa() actualizados += 1 else: sin_cambio += 1 continue valores = {COL_SALDOS['desarrollo']: {'label': desarrollo}, COL_SALDOS['unidad']: unidad, COL_SALDOS['monto']: monto, COL_SALDOS['estado']: {'label': 'Pendiente'}} if comentario: valores[COL_SALDOS['comentario']] = comentario print(' saldo %s / %s: %s -> crear%s' % (nombre, unidad, monto, ' [dry-run]' if DRY else '')) if not DRY: crear_item(BOARD_SALDOS, '%s / %s' % (nombre, unidad), valores) pausa() creados += 1 print('Saldos a Favor: %d creados, %d actualizados, %d sin cambio' % (creados, actualizados, sin_cambio)) # ---------------- Flujo Escrituras ---------------- def sync_flujo(wb): ws = wb['Flujo Calendario'] rows = list(ws.iter_rows(min_row=1, max_row=160, max_col=15, values_only=True)) mes = None semana = None resumen = {} # mes -> {proyecto: (escrituras, ingreso)} detalle = [] # (mes, semana, fecha, proyecto, escrituras, ingreso) for r in rows: c = list(r) + [None] * (15 - len(r)) v5 = str(c[4]).strip().upper() if isinstance(c[4], str) else None if v5 in MESES_FLOJO: mes, semana = v5, None continue if mes is None: continue v1 = str(c[0]).strip().upper() if isinstance(c[0], str) else None if v1 in PROYECTOS: # resumen mensual A:C resumen.setdefault(mes, {})[v1] = (parse_numero(c[1]), parse_numero(c[2])) dias = [int(v) for v in c[5:12] if isinstance(v, (int, float)) and float(v).is_integer() and 1 <= int(v) <= 31] v13 = c[12] es_encabezado = (isinstance(v13, str) and v13.strip().upper().startswith('SEM')) or \ isinstance(v13, datetime) or (dias and c[1] is None and c[4] is None) if es_encabezado and dias: if isinstance(v13, str) and v13.strip().upper().startswith('SEM'): semana = re.sub(r'\s+%s\s*$' % mes, '', v13.strip().upper()) else: semana = '%d AL %d' % (min(dias), max(dias)) if len(dias) > 1 else 'DIA %d' % dias[0] semana = (semana, date(2026, MESES_FLOJO[mes], min(dias))) continue if semana and v5 in PROYECTOS: esc = parse_numero(c[2]) if isinstance(c[1], str) and c[1].strip().upper() == v5 else None detalle.append((mes, semana[0], semana[1], v5, esc, parse_numero(v13))) items = {} for mes_n, sem, fecha, proy, esc, ing in detalle: if not esc and not ing: continue items[(mes_n, sem, proy)] = {'mes': mes_n, 'semana': sem, 'fecha': fecha, 'proyecto': proy, 'escrituras': esc, 'ingreso': ing} for mes_n, proys in resumen.items(): # meses sin desglose semanal -> resumen if any(k[0] == mes_n for k in items): continue for proy, (esc, ing) in proys.items(): if not esc and not ing: continue items[(mes_n, 'Mes completo', proy)] = {'mes': mes_n, 'semana': 'Mes completo', 'fecha': date(2026, MESES_FLOJO[mes_n], 1), 'proyecto': proy, 'escrituras': esc, 'ingreso': ing} existentes = {i['name'] for i in fetch_items(BOARD_FLUJO, [])} creados = ya = 0 for d in sorted(items.values(), key=lambda x: (x['fecha'], x['proyecto'])): nombre = '%s %s - %s' % (d['mes'], d['semana'], d['proyecto']) if nombre in existentes: ya += 1 continue valores = {COL_FLUJO['proyecto']: {'label': d['proyecto']}, COL_FLUJO['mes']: d['mes'], COL_FLUJO['semana']: d['semana'], COL_FLUJO['fecha']: {'date': d['fecha'].isoformat()}} if d['escrituras'] is not None: valores[COL_FLUJO['escrituras']] = d['escrituras'] if d['ingreso'] is not None: valores[COL_FLUJO['ingreso']] = d['ingreso'] print(' flujo %s: esc=%s ing=%s%s' % (nombre, d['escrituras'], d['ingreso'], ' [dry-run]' if DRY else '')) if not DRY: crear_item(BOARD_FLUJO, nombre, valores) pausa() creados += 1 print('Flujo Escrituras: %d creados, %d ya existian' % (creados, ya)) # ---------------- main ---------------- def main(): import openpyxl global BOARD_NAMES, MAESTRO wb = openpyxl.load_workbook(EXCEL, data_only=True, read_only=True) maestro = json.load(open(MAESTRO_JSON)) MAESTRO = {(norm_clave(r['clave_maestro']['mza']), norm_clave(r['clave_maestro']['lote']), norm_clave(r['clave_maestro']['interior'])): r['codigo_nuestro'] for r in maestro if r['desarrollo'] == 'SENDA'} sync = SyncUnidades() sync.cargar(fetch_items(BOARD_UNIDADES, list(COL.values()))) BOARD_NAMES = set(sync.items) print('Board Unidades: %d items' % len(BOARD_NAMES)) leer_estatus_obra(wb, sync) leer_senda_act(wb, sync) leer_senda_dg(wb, sync) leer_diagonal(wb, sync) leer_mutuo(wb) from collections import Counter print('\n== Unidades: %d items con cambios ==' % len(sync.cambios)) stats = Counter() for item_id, cols in sync.cambios.items(): for k in cols: stats[k] += 1 for k, n in stats.most_common(): print(' %s: %d' % (k, n)) sync.aplicar() print('\n== Saldos a Favor ==') sync_saldos(wb) print('\n== Flujo Escrituras ==') sync_flujo(wb) print('\n== Unidades saltadas (%d) ==' % len(saltadas)) for codigo, fuente, motivo in saltadas: print(' %s [%s]: %s' % (codigo, fuente, motivo)) if __name__ == '__main__': main()