diff --git a/.env.example b/.env.example index 4d37dcd..4c7f2e7 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ MONDAY_BOARD_VENTAS="18427678520" # Tableros creados por api-gateway/scripts/setup-crm.ts (npm run setup:crm) MONDAY_BOARD_MOTIVOS="18427702505" MONDAY_BOARD_ACTIVIDADES="18427702517" +# Tableros de obra/escrituras alimentados por api-gateway/scripts/sync-obra-estatus.py +# (referencia; el script los trae como constantes) +MONDAY_BOARD_FLUJO_ESCRITURAS="18429252054" +MONDAY_BOARD_SALDOS_FAVOR="18429252069" # 'false' desactiva la creación de actividades en Monday (default true) ACTIVIDADES_ENABLED="true" diff --git a/README.md b/README.md index 447206c..15471a5 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,23 @@ Backup: `scripts/backup.sh` (cron sugerido `17 2 * * *`). ## Monday.com -Workspace `Frasa-CAS`. Tableros: Contactos, Desarrollos, Unidades (inventario importado), Leads, Oportunidades, Apartado, Ventas. Los board IDs están en `.env.example`. Webhook: `POST /api/webhooks/monday` (responde el challenge de activación). +Workspace `Frasa-CAS`. Tableros: Contactos, Desarrollos, Unidades (inventario importado), Leads, Oportunidades, Apartado, Ventas, Flujo Escrituras, Saldos a Favor. Los board IDs están en `.env.example`. Webhook: `POST /api/webhooks/monday` (responde el challenge de activación). + +### Board Unidades — columnas de obra/estatus + +Alimentadas por `api-gateway/scripts/sync-obra-estatus.py` desde el `Reporte_EstatusVenta.xlsx` del cliente (idempotente, `--dry-run` disponible; ver docstring del script): + +- `% Avance obra`, `Meses atraso` (numbers), `Contratista`, `Clave catastral`, `No. oficial`, `Vendedor`, `Broker` (text), `Venc. autorización` (date). +- `Fecha entrega obra` (date): la columna original `Fecha entrega` ya traía datos de habitabilidad, por eso se creó una nueva. +- Hitos documentales (checkbox): `Cert. Gas`, `TO / C4`, `Régimen inscrito`, `Lista avalúo`. Se encienden cuando la fecha del hito en el Excel ya pasó; el script nunca los apaga. +- Nota: en `No. oficial` / `Clave catastral` el Excel del cliente trae fechas programadas (no números oficiales); se cargan como texto ISO tal cual. + +### Boards operativos nuevos + +- **Flujo Escrituras**: un item por mes/semana/proyecto con `Escrituras` (numbers) e `Ingreso` (numbers) del Flujo Calendario del cliente. Idempotencia por nombre único (`AGOSTO SEM 3 AL 7 - SOLE`). +- **Saldos a Favor**: name `CLIENTE / UNIDAD`; columnas `Desarrollo` (status), `Unidad` (text), `Monto` (numbers), `Estado` (Pendiente/Devuelto), `Comentario`. Idempotencia por Unidad+Desarrollo. + +Las unidades SOLE y MUTUO no existen en el board Unidades: el script las reporta como saltadas (sus vendedor/broker/venc. autorización no se cargan por ahora). ## Pendientes conocidos diff --git a/api-gateway/scripts/sync-obra-estatus.py b/api-gateway/scripts/sync-obra-estatus.py new file mode 100644 index 0000000..bc950e8 --- /dev/null +++ b/api-gateway/scripts/sync-obra-estatus.py @@ -0,0 +1,572 @@ +#!/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': 'text_mm6ehqqb', + '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() diff --git a/docs/AUTOMATIZACIONES-MONDAY.md b/docs/AUTOMATIZACIONES-MONDAY.md index a47c44c..8f57772 100644 --- a/docs/AUTOMATIZACIONES-MONDAY.md +++ b/docs/AUTOMATIZACIONES-MONDAY.md @@ -117,6 +117,22 @@ tablero mantiene el orden sin trabajo manual: > Si el tablero no tiene grupos, omite esta receta: no afecta nada del servidor. +## 5. Tablero Unidades — recordatorio de vencimiento de autorización de crédito + +La columna **Venc. autorización** (date) del board Unidades la llena +`sync-obra-estatus.py` cuando el desarrollo exista en el board (hoy solo la +fuente MUTUO la trae, y MUTUO aún no está en Unidades). La receta queda lista +para cuando la columna tenga datos. + +- Board: **Unidades** +- Receta: *"When a date arrives, notify someone"* (categoría **Date + reminders**; en español: "Cuando llegue una fecha, notificar a alguien") +- Parámetros: + - date column: **Venc. autorización** + - when: **7 días antes**, a las **9:00** + - notify: **el coordinador de ventas** (elegir la persona en la receta) + - mensaje: `La autorización de crédito de {item.name} vence en 7 días` + ## Verificación rápida (2 min) 1. Crea un item de prueba en **Leads**, asígnate como Vendedor → debe llegarte