- Script sync-obra-estatus.py (idempotente, --dry-run, retry/backoff): parsea ESTATUS OBRA, SENDA-ACT-AGO2026, SENDA/SOLE DG, Diagonal 38 y las hojas de ventas; mapea a Unidades vía maestro-unidades.json - Board Unidades: +12 columnas (% avance obra, meses atraso, contratista, clave catastral, no. oficial, vendedor, broker, venc. autorización, fecha entrega obra, 4 checkboxes de hitos documentales) — 147 items actualizados (DIAGONAL 38/38, SENDA 109/142) - Board nuevo 'Flujo Escrituras' (18429252054): 32 items mayo–ago/2026 por proyecto/semana con escrituras e ingreso - Board nuevo 'Saldos a Favor' (18429252069): 7 pendientes (Estanza/Sole) - Receta nativa documentada: recordatorio 7 días antes de Venc. autorización al coordinador - SOLE y MUTUO no existen como inventario en Monday: sus datos de obra se reportan como saltados (no se crean unidades)
573 lines
24 KiB
Python
573 lines
24 KiB
Python
#!/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()
|