feat(monday): obra, atrasos, hitos, saldos y flujo de escrituras desde Reporte_EstatusVenta
- 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)
This commit is contained in:
572
api-gateway/scripts/sync-obra-estatus.py
Normal file
572
api-gateway/scripts/sync-obra-estatus.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user