#!/usr/bin/env python3 """Siembra inicial del 13-oct-2026: plan de producción, los 4 sitios, los ítems de inventario, el equipo (14-oct) y los pendientes del tablero (brief §4). Idempotente: lo que ya existe (mismo id) se deja como está; en pendientes viejos solo se completa el responsable si estaba vacío y se calcula responsable_ids. Uso (en el servidor, como el usuario del servicio, normalmente leo): cd /srv/projects/drogheda/hbox-docs ./backend/venv/bin/python3 backend/seed_1013.py # usa los mismos valores por omisión que app.py TAREAS_DIR=/otra/ruta ./backend/venv/bin/python3 backend/seed_1013.py --usuario leo ./backend/venv/bin/python3 backend/seed_1013.py --extra tareas-notion.json # importa más pendientes ./backend/venv/bin/python3 backend/seed_1013.py --solo plan,sitios # una parte Escribe directo en disco con las mismas funciones del backend (no pasa por nginx), así que no necesita contraseña. El servicio puede seguir corriendo mientras se siembra. --extra espera un JSON con una lista de pendientes con los mismos campos que POST /api/tareas (cada uno con su "id" para que la importación también sea idempotente). """ import os, sys, json, argparse, datetime AQUI = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, AQUI) import app as backend # noqa: E402 (usa ACTAS_DIR/TAREAS_DIR/INVENTARIO_DIR/SITIOS_DIR/PLAN_FILE del entorno) HOY = datetime.date.today().isoformat() def marca(u): t = backend.ahora() return {"creado_por": u, "creado_en": t, "modificado_por": u, "modificado_en": t} # ------------------------------------------------------------------ datos # Miembros del equipo (14-oct). NO crea accesos al sitio: eso se hace desde Equipo (admin) o /api/usuarios. EQUIPO = [ ("leo", "Leopoldo Rodríguez", "Dirección / ingeniería", "direccion", ""), ("adriana", "Adriana", "Operaciones; lleva el PM del proyecto", "operaciones", ""), ("michelle", "Michelle Márquez", "Layout de PCB", "layout", ""), ("jesus", "Jesús", "Técnico de pruebas de tarjetas", "pruebas", ""), ("alan", "Alán Basurto", "Técnico", "produccion", ""), ("daniel", "Daniel Martínez", "Técnico", "produccion", ""), ("aldair", "Aldair", "Fabricación / ensamble de tarjetas (rol por confirmar)", "fabricacion", ""), ("gibranlp", "Gibran", "Software htracker (monitor)", "software", ""), ("claude", "Claude", "Asistente de ingeniería", "asistente", ""), # Cuentas que ya existían en el sitio (14-oct): Leo las asigna todas a Producción. ("abraham", "Abraham", "Producción", "produccion", ""), ("eduardo", "Eduardo", "Producción", "produccion", ""), ("jacobo", "Jacobo", "Producción", "produccion", ""), ("jose", "José", "Producción", "produccion", ""), ("juan", "Juan", "Producción", "produccion", ""), ] # Cualquier otra cuenta del htpasswd que no tenga miembro se da de alta como Producción (regla de Leo, 14-oct). # Excepciones: cuentas que ya no son del equipo (no se crean como miembro). LOGINS_OMITIDOS = {"luz"} # luz = cuenta duplicada de michelle (se borra con htpasswd -D) SITIOS = [ {"id": "icave", "nombre": "ICAVE", "cliente": "Hutchison Ports", "ubicacion": "Veracruz, Ver.", "alias": ["ICAVE (Veracruz)", "Oficina ICAVE"], "orden": 10, "contactos": [{"nombre": "por confirmar", "rol": "supervisor de patio (autoriza acceso a máquinas)", "telefono": "", "correo": ""}], "notas": "Terminal de contenedores. El acceso a cada máquina se pide por correo (Operaciones). " "Compara el HBOX RTK con un CalAmp legado (viajes en Wialon)."}, {"id": "lzc", "nombre": "LZC", "cliente": "Hutchison Ports", "ubicacion": "Lázaro Cárdenas, Mich.", "alias": ["LCT", "Lázaro Cárdenas"], "orden": 20, "contactos": [{"nombre": "por confirmar", "rol": "", "telefono": "", "correo": ""}], "notas": "En notas antiguas aparece como LCT."}, {"id": "eit", "nombre": "EIT", "cliente": "Hutchison Ports", "ubicacion": "por confirmar", "alias": [], "orden": 30, "contactos": [{"nombre": "por confirmar", "rol": "", "telefono": "", "correo": ""}], "notas": ""}, {"id": "timsa", "nombre": "TIMSA", "cliente": "Hutchison Ports", "ubicacion": "por confirmar", "alias": [], "orden": 40, "contactos": [{"nombre": "por confirmar", "rol": "", "telefono": "", "correo": ""}], "notas": ""}, ] # stock 0: Leo y Jesús lo llenan con el conteo físico (movimiento 'ajuste'). INVENTARIO = [ ("EB-V55", "EinarBoard V5.5 (tarjeta probada o por probar)", "tarjeta", ["V5.5"], 0, "JLCPCB", "", "Lote de 15 (22-ago). Inspeccionar U6 con lupa antes de energizar."), ("EB-V56", "EinarBoard V5.6", "tarjeta", ["V5.6"], 0, "Aldair (por confirmar)", "", "Pedido 113. Pasta EP U6 ≤50 %, vías tapadas, X-ray/AOI en U6."), ("EB-V57", "EinarBoard V5.7 (fuente a bordo)", "tarjeta", ["V5.7"], 0, "Aldair (por confirmar)", "", "Pedido 327."), ("EB-V6", "EinarBoard v6 (GNSS + 4G a bordo)", "tarjeta", ["v6"], 0, "Aldair (por confirmar)", "", "Pedido 130 + reemplazos."), ("ARD-SRTK2B", "ArduSimple simpleRTK2B Pro (ZED-F9P)", "modulo", ["V5.5", "V5.6", "V5.7"], 0, "ArduSimple", "", "500 pedidos (26-sep)."), ("MODEM-4G-NTRIP", "4G NTRIP Master", "modulo", ["V5.5", "V5.6", "V5.7"], 0, "ArduSimple", "", ""), ("SD-15B-05", "Fuente Mean Well SD-15B-05 (24 V → 5 V)", "fuente", ["V5.5", "V5.6"], 113, "Mouser / Digikey", "709-SD15B05", "Solo V5.6: 113 para el pedido. V5.7 y v6 no la usan."), ("PACK-LFP-26650-1S4P", "Paquete LFP 26650 1S4P (alta temperatura)", "bateria", ["V5.6", "V5.7", "v6"], 0, "por confirmar", "", "JP2 = 0 (LiFePO4). Flotación 3.50 V (R55 5.6 k)."), ("GAB-IP67", "Gabinete IP67 (máquina)", "gabinete", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", ""), ("CAJA-HBOX", "Caja plástica HBOX", "caja", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "Pendiente: el cable Ethernet no cabe (tablero, pista Mecánica)."), ("ANT-GNSS", "Antena GNSS (SMA, con cable)", "antena", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "~4 semanas de entrega."), ("ANT-LTE", "Antena LTE (2 por unidad)", "antena", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "Dos por unidad."), ("CABLE-ETH", "Cable Ethernet (exterior)", "cable", ["V5.6", "V5.7", "v6"], 0, "por confirmar", "", "Ver pendiente de la caja HBOX."), ("CONN-GX16", "Conector GX16 (aéreo + panel) y arnés", "conector", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", "1 = +24 V, 2 = GND, 3 = ignición, 4 = GND."), ("FUSIBLE-DIN", "Fusible DIN (gabinete)", "componente-critico", ["V5.5", "V5.6", "V5.7", "v6"], 0, "por confirmar", "", ""), ("LTC4040", "LTC4040 (U6, QFN 4x5) — cargador/respaldo", "componente-critico", ["V5.5", "V5.6", "V5.7", "v6"], 0, "LCSC / ADI", "C2057152", "Largo plazo de entrega. Regla DFM: pasta EP ≤50 %."), ("TPS54360B", "TPS54360B — buck 24 V → 5 V a bordo (U25)", "componente-critico", ["V5.7", "v6"], 0, "LCSC / TI", "", "Fuente a bordo de V5.7 y v6."), ("SRP1265A", "Inductor SRP1265A (fuente a bordo)", "componente-critico", ["V5.7", "v6"], 0, "LCSC / Bourns", "", ""), ] TAREAS = [ {"id": "t-v56-pasada-2", "pista": "ingenieria", "estado": "en-curso", "responsable": "michelle", "version_tarjeta": "V5.6", "titulo": "V5.6 pasada 2 — correcciones antes del pedido", "descripcion": "BAT_PLUS fuera de In1/In2 (bloqueante), MAIN5V, SYS5V, SW3_NODE, pistas bajo U16 a B.Cu, sueltos, rev 5.6, stackup. " "Los conectores se quedan donde están (opción A). Zona x 170–195 / y 42–72 libre para la fuente de V5.7.", "enlaces": [{"titulo": "Instrucciones pasada 2", "url": "/hardware/v56-layout-pasada-2/"}, {"titulo": "Auditoría 13-oct (inglés)", "url": "/hardware/v56-auditoria-layout-13oct/"}], "etiquetas": ["layout", "bloqueante"]}, {"id": "t-v57-fuente", "pista": "ingenieria", "estado": "en-curso", "responsable": "leo", "version_tarjeta": "V5.7", "titulo": "V5.7 — colocar y rutear la fuente a bordo (guía de layout)", "descripcion": "Bloque U25 (TPS54360B) + F2 SMD en la esquina superior derecha. Solo las zonas acordadas con Michelle.", "enlaces": [{"titulo": "Guía de layout V5.7", "url": "/hardware/v57-layout-guia/"}, {"titulo": "ECO V5.7", "url": "/hardware/v57-eco-fuente/"}], "etiquetas": ["layout"]}, {"id": "t-v57-cierre", "pista": "ingenieria", "estado": "por-hacer", "responsable": "michelle", "version_tarjeta": "V5.7", "titulo": "V5.7 — replicar correcciones de V5.6 y cerrar layout", "descripcion": "Después de que Leo deje la fuente colocada y V5.6 pasada 2 esté cerrada.", "bloqueada_por": ["t-v56-pasada-2", "t-v57-fuente"], "enlaces": [{"titulo": "Guía de layout V5.7", "url": "/hardware/v57-layout-guia/"}], "etiquetas": ["layout"]}, {"id": "t-v56-revision-gerber", "pista": "ingenieria", "estado": "por-hacer", "responsable": "claude", "version_tarjeta": "V5.6", "titulo": "V5.6 — revisión pad a pad + Gerber/BOM/CPL", "descripcion": "Paridad con netlist, DRC, pasta EP U6 ≤50 %, vías tapadas; generar el paquete de fabricación.", "bloqueada_por": ["t-v56-pasada-2"], "etiquetas": ["auditoría", "fabricación"]}, {"id": "t-v6-cierre-pedido", "pista": "ingenieria", "estado": "por-hacer", "responsable": "leo, claude", "version_tarjeta": "v6", "titulo": "v6 — cierre para pedido (según «Próximos pasos para ordenar»)", "enlaces": [{"titulo": "Próximos pasos v6", "url": "/hardware/v6-proximos-pasos/"}, {"titulo": "Plan de corrección", "url": "/hardware/v6-plan-correccion/"}], "descripcion": "Aplicar a P7/P8 lo decidido para P12 en V5.6; regla DFM U6; no habrá v6.1.", "etiquetas": ["fabricación"]}, {"id": "t-caja-ethernet", "pista": "mecanica", "estado": "bloqueado", "responsable": "leo", "version_tarjeta": "—", "titulo": "Caja plástica HBOX — el cable Ethernet no cabe: definir y aplicar el cambio", "descripcion": "Falta definir la causa exacta (largo del boot RJ45, tamaño de la abertura o radio de curvatura) y quién lo corrige " "(pieza impresa en 3D o proveedor). Al definirlo: escribir el procedimiento y mover a En curso.", "etiquetas": ["caja", "por asignar"]}, {"id": "t-paquetes-aldair", "pista": "compras", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—", "titulo": "Enviar paquetes de fabricación V5.6, V5.7 y v6 a Aldair; cotización para 570 unidades (113 / 327 / 130)", "descripcion": "Gerber + BOM + CPL + planos. Aldair = fabricación/ensamble (rol por confirmar).", "bloqueada_por": ["t-v56-revision-gerber"], "etiquetas": ["pedido 570"]}, {"id": "t-compras-materiales", "pista": "compras", "estado": "por-hacer", "responsable": "leo, jesús", "version_tarjeta": "—", "titulo": "Mean Well SD-15B-05 ×113 (solo V5.6); paquetes LFP; gabinetes; cajas HBOX; antenas", "descripcion": "Chips y antenas RTK: ~4 semanas de entrega. Registrar las llegadas como entradas en el inventario.", "enlaces": [{"titulo": "Inventario", "url": "/inventario/inventario.html"}], "etiquetas": ["pedido 570"]}, {"id": "t-plan-570", "pista": "produccion", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—", "titulo": "Plan 570: cargar objetivos por versión y fechas", "enlaces": [{"titulo": "Tablero de producción", "url": "/tableros/produccion.html"}], "descripcion": "Editar el plan desde el tablero de producción (solo admin): fecha objetivo por versión."}, {"id": "t-inventario-inicial", "pista": "produccion", "estado": "por-hacer", "responsable": "jesús", "version_tarjeta": "—", "titulo": "Inventario inicial: conteo físico de tarjetas, módulos, gabinetes, cajas, packs", "enlaces": [{"titulo": "Inventario", "url": "/inventario/inventario.html"}], "descripcion": "Un movimiento de tipo «ajuste» por ítem con el conteo físico y el motivo «conteo inicial»."}, {"id": "t-sitios-alta", "pista": "campo", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—", "titulo": "Dar de alta los 4 sitios (ICAVE, LZC, EIT, TIMSA) con contactos y unidades instaladas", "enlaces": [{"titulo": "Sitios", "url": "/sitios/"}], "descripcion": "Los 4 sitios ya existen con «por confirmar»: completar ubicación y contactos (Editar sitio, solo admin)."}, {"id": "t-acta-instalacion", "pista": "campo", "estado": "por-hacer", "responsable": "claude", "version_tarjeta": "—", "titulo": "Acta de instalación en campo: formulario nuevo + instrucciones", "enlaces": [{"titulo": "Acta de instalación", "url": "/formularios/instalacion.html"}], "descripcion": "Formulario publicado el 13-oct; falta probarlo en una instalación real y ajustar el checklist."}, {"id": "t-ferreteria-eit-timsa", "pista": "compras", "estado": "por-hacer", "responsable": "adriana", "version_tarjeta": "—", "fecha_limite": "2026-10-14", "titulo": "Cerrar compras de ferretería para EIT y TIMSA", "descripcion": "Material de ferretería para la instalación de gabinetes en EIT (Ensenada) y TIMSA (Manzanillo). Cerrar pedidos mañana.", "enlaces": [{"titulo": "Sitios", "url": "/sitios/"}], "etiquetas": ["instalación"]}, ] # Responsable por omisión para pendientes ya sembrados que quedaron sin responsable (solo si está vacío). RESPONSABLE_SI_VACIO = {"t-caja-ethernet": "leo"} # ------------------------------------------------------------------ siembra def sembrar_plan(u, fuerza=False): if os.path.isfile(backend.PLAN_FILE) and not fuerza: return "plan: ya existe, se deja (usa --forzar-plan para reescribirlo)" p = json.loads(json.dumps(backend.PLAN_DEFECTO)) p["actualizado_por"] = u p["actualizado_en"] = backend.ahora() p["historial"] = [{"quien": u, "cuando": p["actualizado_en"], "cambio": "plan sembrado (seed_1013)"}] backend._escribir_json(backend.PLAN_FILE, p) return "plan: creado (570 → 113 V5.6 / 327 V5.7 / 130 v6)" def sembrar_sitios(u): n = 0 for s in SITIOS: if os.path.isfile(backend._ruta_registro(backend.SITIOS_DIR, s["id"])): continue t, err = backend._sitio_valido(s) if err: print(" sitio", s["id"], "->", err); continue t["id"] = s["id"] t["orden"] = s.get("orden", 50) t.update(marca(u)) t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "sitio sembrado (seed_1013)"}] backend._guardar_sitio(t) n += 1 return f"sitios: {n} creados, {len(SITIOS) - n} ya existían" def sembrar_inventario(u): n = 0 for codigo, desc, cat, aplica, minimo, prov, cod_prov, notas in INVENTARIO: rid = backend.slug(codigo, 40) if os.path.isfile(backend._ruta_registro(backend.INV_ITEMS(), rid)): continue t, err = backend._item_valido({"codigo": codigo, "descripcion": desc, "categoria": cat, "aplica_a": aplica, "unidad": "pza", "minimo": minimo, "proveedor": prov, "codigo_proveedor": cod_prov, "notas": notas, "ubicacion": ""}) if err: print(" item", codigo, "->", err); continue t["id"] = rid t.update(marca(u)) t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "ítem sembrado (seed_1013), stock 0"}] backend._guardar_item(t) n += 1 return f"inventario: {n} ítems creados, {len(INVENTARIO) - n} ya existían (stock 0: falta el conteo físico)" def sembrar_equipo(u): n = 0 for rid, nombre, rol, area, correo in EQUIPO: if os.path.isfile(backend._ruta_registro(backend.EQUIPO_DIR, rid)): continue t, err = backend._miembro_valido({"nombre": nombre, "rol": rol, "area": area, "correo": correo, "activo": True}) if err: print(" miembro", rid, "->", err); continue t["id"] = rid t.update(marca(u)) t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "miembro sembrado (seed_1013)"}] backend._guardar_miembro(t) n += 1 # cuentas del sitio sin miembro -> Producción extra = 0 try: lineas = backend._leer_htpasswd() or [] except Exception: lineas = [] conocidos = {rid for rid, *_ in EQUIPO} for l in lineas: if ":" not in l: continue login = l.split(":", 1)[0].strip().lower() if not login or login in conocidos or login in LOGINS_OMITIDOS: continue if os.path.isfile(backend._ruta_registro(backend.EQUIPO_DIR, login)): continue t, err = backend._miembro_valido({"nombre": login.capitalize(), "rol": "Producción", "area": "produccion", "correo": "", "activo": True}) if err: print(" cuenta", login, "->", err); continue t["id"] = login t.update(marca(u)) t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "miembro creado desde la cuenta del sitio (seed_1013; área Producción por regla de Leo)"}] backend._guardar_miembro(t) extra += 1 return f"equipo: {n} miembros creados, {len(EQUIPO) - n} ya existían, {extra} cuentas del sitio dadas de alta como Producción (sin crear accesos)" def completar_responsables(u): """Pendientes ya guardados: (a) responsable por omisión SOLO si está vacío; (b) responsable_ids calculado si falta (pendientes de antes del 14-oct). No toca lo que la gente ya cambió.""" n_resp, n_ids = 0, 0 miembros = backend._miembros() for t in backend._listar_json(backend.TAREAS_DIR): cambio = False if not str(t.get("responsable") or "").strip() and t.get("id") in RESPONSABLE_SI_VACIO: t["responsable"] = RESPONSABLE_SI_VACIO[t["id"]] backend._hist(t, u, "responsable: → " + t["responsable"] + " (seed_1013, estaba vacío)") n_resp += 1; cambio = True ids = backend._ids_responsable(t.get("responsable"), miembros)[0] if t.get("responsable_ids") != ids: t["responsable_ids"] = ids n_ids += 1; cambio = True if cambio: backend._escribir_json(backend._ruta_registro(backend.TAREAS_DIR, t["id"]), t) if n_resp or n_ids: backend._reescribir_indice(backend.TAREAS_DIR, backend._listar_json(backend.TAREAS_DIR), backend.COLS_TAREAS) return f"pendientes existentes: {n_resp} responsables completados, {n_ids} con responsable_ids recalculado" def sembrar_tareas(u, lista, origen="seed_1013", responsable_omision="leo"): n, orden = 0, {} existentes = backend._listar_json(backend.TAREAS_DIR) for t in existentes: orden[t.get("estado")] = max(orden.get(t.get("estado"), -1), t.get("orden", 0)) for d in lista: rid = backend.slug(d.get("id"), 60) if not rid: print(" pendiente sin id, se omite:", d.get("titulo")); continue if os.path.isfile(backend._ruta_registro(backend.TAREAS_DIR, rid)): continue if not str(d.get("responsable") or "").strip(): d = dict(d, responsable=responsable_omision) # un pendiente siempre tiene responsable (14-oct) print(f" pendiente {rid}: sin responsable en el origen → {responsable_omision}") t, err = backend._tarea_valida(d) if err: print(" pendiente", rid, "->", err); continue t["id"] = rid if "orden" not in d: orden[t["estado"]] = orden.get(t["estado"], -1) + 1 t["orden"] = orden[t["estado"]] t.update(marca(u)) t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": f"creado en {t['estado']} ({origen})"}] backend._guardar_tarea(t) n += 1 return f"pendientes ({origen}): {n} creados, {len(lista) - n} ya existían u omitidos" def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--usuario", default="leo", help="quién firma la siembra en los campos de auditoría") ap.add_argument("--solo", default="plan,sitios,inventario,equipo,tareas", help="partes a sembrar, separadas por coma") ap.add_argument("--extra", help="JSON con una lista de pendientes adicionales (p. ej. importación de Notion)") ap.add_argument("--forzar-plan", action="store_true", help="reescribe el plan aunque ya exista") ap.add_argument("--responsable-omision", default="leo", help="responsable para pendientes importados sin uno (--extra)") a = ap.parse_args() partes = {p.strip() for p in a.solo.split(",")} print("Carpetas:", {"tareas": backend.TAREAS_DIR, "inventario": backend.INVENTARIO_DIR, "sitios": backend.SITIOS_DIR, "plan": backend.PLAN_FILE}) for d in (backend.TAREAS_DIR, backend.INV_ITEMS(), backend.INV_MOVS(), backend.SITIOS_DIR, backend.EQUIPO_DIR, os.path.dirname(backend.PLAN_FILE)): os.makedirs(d, exist_ok=True) if "plan" in partes: print(sembrar_plan(a.usuario, a.forzar_plan)) if "sitios" in partes: print(sembrar_sitios(a.usuario)) if "inventario" in partes: print(sembrar_inventario(a.usuario)) if "equipo" in partes: print(sembrar_equipo(a.usuario)) # antes que los pendientes: así responsable_ids sale completo if "tareas" in partes: print(sembrar_tareas(a.usuario, TAREAS)) print(completar_responsables(a.usuario)) if a.extra: with open(a.extra, encoding="utf-8") as f: extra = json.load(f) if not isinstance(extra, list): sys.exit("--extra debe ser una lista JSON de pendientes") print(sembrar_tareas(a.usuario, extra, origen=os.path.basename(a.extra), responsable_omision=a.responsable_omision)) print("Listo.") if __name__ == "__main__": main()