From 02521e14f4c3be2e36fce095c84120741f9addcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leopoldo=20Rodr=C3=ADguez?= Date: Fri, 23 Oct 2026 13:33:51 -0600 Subject: [PATCH] =?UTF-8?q?Inventario=20por=20sitio:=20pesta=C3=B1as,=20en?= =?UTF-8?q?v=C3=ADos=20en=20camino=20y=20kit=20por=20instalaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Una pestaña por sitio (ICAVE, TIMSA, EIT, LCT, Querétaro): lo que hay, lo que va en camino y lo que falta del kit para instalar. Movimientos con sitio, envío/recepción, origen «fuera». Lote 2026-10-23_inventario-sitios. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016X4CAujMuib2tDqt9JRg6X --- CLAUDE.md | 1 + DESPLIEGUE.md | 40 ++ backend/README.md | 19 + backend/app.py | 208 ++++++-- .../lotes/2026-10-23_inventario-sitios.json | 47 ++ backend/tests-1023.sh | 66 +++ docs/bitacora.md | 1 + docs/inventario/index.md | 63 ++- docs/tableros/index.md | 5 +- docs/tableros/instalaciones.js | 2 + docs/tableros/instalaciones.md | 2 +- docs/tableros/inventario.css | 63 +++ docs/tableros/inventario.js | 446 ++++++++++++++++++ docs/tableros/inventario.md | 202 +------- 14 files changed, 925 insertions(+), 240 deletions(-) create mode 100644 backend/lotes/2026-10-23_inventario-sitios.json create mode 100644 backend/tests-1023.sh create mode 100644 docs/tableros/inventario.css create mode 100644 docs/tableros/inventario.js diff --git a/CLAUDE.md b/CLAUDE.md index 7dc5395..ffbc6ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Adriana, Michelle, Jesús and the field technicians work from it; Claude closes | **Enfoque de la semana** (home) | server `/api/hoy` (`enfoque.json`) | Leo and Adriana only | Claude proposes the text in the hand-off; `enfoque_inicial` in `proyecto.json` shows only while nobody has written one | | **Ruta a producción**, version cards, fase, pedido, próximas fechas, map pins | `docs/datos/proyecto.json` | Claude or Leo | edit by hand (keep the one-array-per-line format); step states `hecho · ahora · bloqueado · falta`, card tone `hecho · ahora · aviso · bloqueado`; bump `actualizado` | | **Instalaciones** (per-site installation board, 22-oct) | `docs/tableros/instalaciones.md` + `.js`/`.css`; data: field actas via `GET /api/instalaciones` (`backend/instalaciones.py`, hooked at the end of `app.py`) | automatic | stages envío → recepción → instalación → **prueba en sitio** (acta `prueba-sitio`; only a passed test counts for the 1–2 per-site goal) → en operación (7 days after the test, no failure) + «con falla»; colour, nota and meta per site from `proyecto.json`; forms accept `?serie=&sitio=&=` prefill (`acta.js`) | +| **Inventario por sitio** (23-oct) | `docs/tableros/inventario.md` + `.js`/`.css`; data: server `/srv/projects/drogheda/inventario` via `/api/inventario` | everyone in the UI | tabs ICAVE · TIMSA · EIT · LCT · Querétaro + «Todo» (`?sitio=eit&n=2`); every movement carries `sitio` (none = qro); `envio` leaves a site (or `fuera`: purchase in transit / material shipped before counting) and stays «en camino» until the destination presses «Recibir» (`recepcion`); item `por_instalacion` = the installation kit; team rules in `docs/inventario/index.md`; tests `backend/tests-1023.sh` | | **Últimas decisiones** (Ingeniería) | `docs/tableros/ingenieria.md` | Claude or Leo | new `
  • DD-mmm (hora) — …
  • ` at the top, with the decision number (D-xx-n) and links | | **Bitácora → Documentos por fecha** | `docs/bitacora.md` | Claude or Leo | newest first under the month: `**DD oct**` + `: [Title](path.md#anchor): what it decides or changes` | | **Documentos recientes** (home) | automatic from `date:` front matter (`hooks/documentos.py`) | — | give every new page a `date:`; pages under «Historia» are left out | diff --git a/DESPLIEGUE.md b/DESPLIEGUE.md index f3b89e9..39d5bab 100644 --- a/DESPLIEGUE.md +++ b/DESPLIEGUE.md @@ -419,6 +419,46 @@ mv _borrados/__.json .json # vuelve al tablero como estaba; | Adriana o Gibran no ven «Borrar» en el detalle | servicio sin reiniciar | el `restart` de arriba | | «Solo … pueden borrar pendientes» | ese usuario no es administrador ni está en `TAREAS_BORRAN` | así debe ser; si debe poder, agrégalo a `TAREAS_BORRAN` | +--- + +## A9 · 23-oct: inventario por sitio + +Qué cambia: **Inventario** (`/tableros/inventario/`) tiene una pestaña por sitio —ICAVE · TIMSA · EIT · LCT · Querétaro— y +**Todo**. Cada movimiento dice en qué sitio pasó. Tipo nuevo **Envío**: saca material de un sitio (o de «Proveedor o fuera +del inventario»: una compra en camino, o material que salió antes de que se contara) y lo deja **en camino** hasta que el +sitio que lo espera le da **Recibir** (tipo `recepcion`; si llegó menos, la diferencia baja del total). Cada ítem puede decir +cuánto lleva una instalación (**Por instalación**) y la pestaña de cada puerto calcula qué falta para instalar *N* unidades. +Categoría nueva **Instalación** (soportes, tornillería, cable de potencia). Instalaciones tiene un botón **Material** que abre +la pestaña del sitio. El CSV trae una columna por sitio y otra de lo que va en camino. Reglas para el equipo: +[Política de inventario](docs/inventario/index.md). + +Datos: no hay migración. Los movimientos de antes no tienen sitio y cuentan como **Querétaro**; el total de cada ítem no +cambia. El primer paso del equipo es el conteo físico de cada sitio (lote `2026-10-23_inventario-sitios`). + +**Cambió `backend/app.py`: reinicia el servicio.** Sin reiniciar, la página muestra solo **Todo** y avisa que falta +reiniciar `actas-hbox`. + +```bash +cd ~/Documents/Claude/Projects/Drogheda/hbox-docs +mkdocs build --strict +git add -A && git commit -m "Inventario por sitio: pestañas, envíos en camino y kit por instalación" && git push +cd .. && git add hbox-docs && git commit -m "hbox-docs: inventario por sitio" && git push && cd hbox-docs # sube el submódulo +./scripts/deploy.sh +ssh leo@git.helgen.online 'sudo systemctl restart actas-hbox' +curl -s -u leo:PASS https://drogheda.helgen.online/api/inventario | head -c 300 # debe traer «"sitios"» y «"envios"» +./scripts/tablero.sh revisar && ./scripts/tablero.sh aplicar +``` + +Pruebas en la Mac (opcional, necesita Flask): `bash backend/tests-1023.sh .` (35 revisiones: entrada por sitio, envío, +recepción completa y con faltante, conteo físico, envío desde fuera, errores y CSV). + +| Síntoma | Causa | Arreglo | +|---|---|---| +| Solo se ve **Todo** y un aviso de reiniciar | servicio sin reiniciar | el `restart` de arriba | +| «No hay stock suficiente en EIT: hay 0 pza registrados» | ese sitio no tiene su conteo físico ni entradas | **Conteo físico** en ese sitio primero; si es material que ya salió sin contarse, envío desde «Proveedor o fuera del inventario» | +| Un envío no sale en la pestaña del sitio que lo espera | se registró con otro destino, o ya se recibió | tocar el código del ítem: sus movimientos dicen a dónde iba y quién lo recibió | +| El total de un ítem subió sin que llegara nada | un envío desde «Proveedor o fuera del inventario» entra al total al registrarse (va en camino) | así debe ser; si al recibir llega menos, la diferencia baja | + ## B · Verificación después de desplegar ```bash diff --git a/backend/README.md b/backend/README.md index 8a8eb0c..92b24d1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -132,6 +132,17 @@ acta se cuenta (`tipos`), si solo cuentan las aprobadas (`ok`) y si solo cuentan El stock **no se edita a mano**: es la suma de los movimientos del ítem (`entrada` +n, `salida` −n, `ajuste` = conteo físico; el servidor calcula el delta). Una salida mayor al stock registrado → 409. +**Por sitio (23-oct).** Cada movimiento lleva `sitio`: `icave`, `timsa`, `eit`, `lzc` o `qro` (acepta `LCT`, `Ensenada`, +`Manzanillo`…; vacío = `qro`). Los de antes no tienen sitio y cuentan como Querétaro. Dos cuentas por movimiento: `delta` +mueve el **total** del ítem (`stock`, incluye lo que va en camino) y `delta_sitio` lo que hay **en ese sitio**; `ajuste` fija +el conteo de ese sitio. `envio` (con `destino`) sale del sitio y queda `"estado": "en-camino"` sin cambiar el total; con +`"sitio": "fuera"` no descuenta de nadie y sube el total (una compra en camino, o material que salió antes de contarlo). +`recepcion` (con `envio_id`; `cantidad` por omisión = lo enviado, nunca más) lo pone en el destino y cierra el envío; si +llegó menos, la diferencia baja del total. Salida o envío sin existencia en ese sitio → 409 «No hay stock suficiente en …». +`GET /api/inventario` trae en cada ítem `por_sitio` (`{"qro": 7, "eit": 2}`), `en_camino` (`{"eit": 3}`) y +`por_instalacion` (cuánto lleva una unidad instalada; 0 = no es del kit), y además `sitios` y `envios` (los abiertos). +Pruebas: `bash backend/tests-1023.sh .` + ```bash curl -s -u $U "$D/api/inventario?categoria=fuente&aplica_a=V5.6&alertas=1" # alerta = stock < minimo curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario \ @@ -144,6 +155,14 @@ curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movi -d '{"item_id":"EB-V56","tipo":"salida","cantidad":1,"motivo":"ensamble","referencia":"HBOX5-E07-A010-R0006"}' curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movimiento \ -d '{"item_id":"EB-V56","tipo":"ajuste","cantidad":12,"motivo":"conteo físico 13-oct"}' +# por sitio (23-oct): entrada en TIMSA, envío TIMSA → EIT (queda en camino) y su recepción en EIT +curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movimiento \ + -d '{"item_id":"GAB-IP67","tipo":"entrada","cantidad":4,"sitio":"timsa","motivo":"llegó pedido","referencia":"guía 1234"}' +curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movimiento \ + -d '{"item_id":"GAB-IP67","tipo":"envio","cantidad":2,"sitio":"timsa","destino":"eit","motivo":"kit EIT","referencia":"guía 5678"}' +curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/inventario/movimiento \ + -d '{"tipo":"recepcion","envio_id":"M261023-111500-ab12","cantidad":2}' # el id sale de "envios" en GET /api/inventario +curl -s -u $U "$D/api/inventario/movimientos?sitio=eit" # lo que pasó en EIT y lo que va hacia EIT curl -s -u $U "$D/api/inventario/movimientos?item_id=EB-V56&n=50" curl -s -u $U -o inventario.csv $D/api/inventario/export.csv ``` diff --git a/backend/app.py b/backend/app.py index ea3fcd0..fc0b216 100644 --- a/backend/app.py +++ b/backend/app.py @@ -33,7 +33,10 @@ autenticado crea y modifica, solo un administrador borra: (DELETE /api/tarea: administradores y TAREAS_BORRAN, por omisión adriana y gibranlp; el pendiente pasa a tareas/_borrados/) GET /api/plan · PUT /api/plan (admin) GET/POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento - GET /api/inventario/movimientos?item_id= · GET /api/inventario/export.csv + GET /api/inventario/movimientos?item_id=&sitio= · GET /api/inventario/export.csv + (23-oct) inventario por sitio: cada movimiento lleva «sitio»; «envio» saca de un sitio y queda en camino hasta + su «recepcion» en el destino (sitio «fuera» = una compra en camino o material que salió antes de contarlo: no + descuenta de ningún sitio); cada ítem puede llevar «por_instalacion» (cuánto usa una unidad instalada). GET /api/sitios · POST /api/sitios (admin) · PATCH /api/sitio?id= (admin) · GET /api/sitio/unidades?id= Equipo y accesos (14-oct): los miembros del equipo NO son lo mismo que los usuarios con login. GET /api/equipo · POST /api/equipo (admin) · PATCH /api/equipo/miembro?id= (admin) @@ -785,9 +788,16 @@ AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "prue ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho") VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—") CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable", - "conector", "componente-critico", "otro") + "conector", "componente-critico", "instalacion", "otro") UNIDADES_INV = ("pza", "m") -TIPOS_MOV = ("entrada", "salida", "ajuste") +# Inventario por sitio (23-oct): entrada, salida y ajuste (conteo físico) pasan EN un sitio; «envio» sale de un sitio +# y queda en camino hasta que se registra su «recepcion» en el destino. Lo de antes del 23-oct es de Querétaro. +TIPOS_MOV = ("entrada", "salida", "ajuste", "envio", "recepcion") +SITIO_INV_OMISION = "qro" +# Origen de un envío que no sale de ningún sitio del inventario: una compra que viene en camino, o material que +# salió de un puerto antes de que ahí se contara. No descuenta de nadie; entra al total al registrarse (en camino). +ORIGEN_FUERA = "fuera" +ALIAS_FUERA = ("fuera", "proveedor", "externo", "compra") # Tipos de acta que "pasan por un sitio": son los que se listan en la página de cada sitio. TIPOS_SITIO = ("envio", "recepcion", "instalacion-campo", "diagnostico-campo", "dano") FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -1626,9 +1636,10 @@ def plan_escribir(): INV_ITEMS = lambda: os.path.join(INVENTARIO_DIR, "items") INV_MOVS = lambda: os.path.join(INVENTARIO_DIR, "movimientos") -COLS_INV = ["id", "codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta", +COLS_INV = ["id", "codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta", "por_instalacion", "ubicacion", "proveedor", "codigo_proveedor", "modificado_por", "modificado_en"] -COLS_MOV = ["id", "cuando", "quien", "item_id", "tipo", "cantidad", "delta", "stock_despues", "motivo", "referencia"] +COLS_MOV = ["id", "cuando", "quien", "item_id", "tipo", "sitio", "destino", "estado", "cantidad", "delta", "delta_sitio", + "stock_despues", "motivo", "referencia"] def _item_valido(d, base=None): @@ -1658,6 +1669,8 @@ def _item_valido(d, base=None): t["unidad"] = un if alta or "minimo" in d: t["minimo"] = max(0, _entero(d.get("minimo"), 0)) + if alta or "por_instalacion" in d: # cuánto lleva UNA unidad instalada (0 = no es material de instalación) + t["por_instalacion"] = min(999, max(0, _entero(d.get("por_instalacion"), 0))) for k, n in (("ubicacion", 120), ("proveedor", 120), ("codigo_proveedor", 60), ("notas", 1000)): if alta or k in d: t[k] = _texto(d.get(k), n) @@ -1682,13 +1695,70 @@ def _stock_desde_movimientos(item_id): return sum(_entero(m.get("delta"), 0) for m in _movs_de(item_id)) +def _sitio_inv(v, omision=SITIO_INV_OMISION): + """'Ensenada', 'EIT', 'eit' → 'eit'. Vacío → la omisión; un sitio que no existe → None.""" + s = _norm_id(_texto(v, 40)) + if not s: + return omision + s = ALIAS_SITIO.get(s, s) + return s if s in SITIOS_TAREA_IDS else None + + +def _mov_sitio(m): + return m.get("sitio") or SITIO_INV_OMISION # movimientos de antes del 23-oct: Querétaro + + +def _mov_delta_sitio(m): + return _entero(m.get("delta_sitio"), 0) if "delta_sitio" in m else _entero(m.get("delta"), 0) + + +def _stock_en_sitio(item_id, sitio): + return sum(_mov_delta_sitio(m) for m in _movs_de(item_id) if _mov_sitio(m) == sitio) + + +def _existencias(): + """En una pasada: {item_id: {"sitios": {sitio: n}, "camino": {destino: n}}}.""" + out = {} + for m in _listar_json(INV_MOVS()): + iid = m.get("item_id") + if not iid: + continue + e = out.setdefault(iid, {"sitios": {}, "camino": {}}) + st = _mov_sitio(m) + if st != ORIGEN_FUERA: + e["sitios"][st] = e["sitios"].get(st, 0) + _mov_delta_sitio(m) + if m.get("tipo") == "envio" and m.get("estado") == "en-camino": + dst = m.get("destino") or "" + e["camino"][dst] = e["camino"].get(dst, 0) + _entero(m.get("cantidad"), 0) + return out + + +def _envios_abiertos(items=None): + items = items if items is not None else {it["id"]: it for it in _listar_json(INV_ITEMS())} + out = [] + for m in _listar_json(INV_MOVS()): + if m.get("tipo") == "envio" and m.get("estado") == "en-camino": + it = items.get(m.get("item_id")) or {} + e = {k: m.get(k) for k in ("id", "item_id", "codigo", "cantidad", "destino", "quien", "cuando", "motivo", "referencia")} + e.update(origen=_mov_sitio(m), descripcion=it.get("descripcion"), unidad=it.get("unidad")) + out.append(e) + out.sort(key=lambda x: x.get("cuando") or "") + return out + + @app.get("/api/inventario") def inventario_listar(): cat = _texto(request.args.get("categoria"), 30).lower() aplica = _texto(request.args.get("aplica_a"), 10) solo_alertas = request.args.get("alertas") == "1" items = [] - for it in _listar_json(INV_ITEMS()): + exis = _existencias() + todos = _listar_json(INV_ITEMS()) + for it in todos: + e = exis.get(it.get("id")) or {} + it["por_sitio"] = {k: v for k, v in (e.get("sitios") or {}).items() if v} + it["en_camino"] = {k: v for k, v in (e.get("camino") or {}).items() if v} + it.setdefault("por_instalacion", 0) it["alerta"] = bool(it.get("minimo", 0)) and it.get("stock", 0) < it.get("minimo", 0) if cat and it.get("categoria") != cat: continue @@ -1701,6 +1771,8 @@ def inventario_listar(): x.get("codigo", ""))) return jsonify(ok=True, total=len(items), alertas=sum(1 for x in items if x["alerta"]), items=items, categorias=CATEGORIAS_INV, unidades=UNIDADES_INV, tipos_movimiento=TIPOS_MOV, + sitios=[{"id": i, "nombre": n, "corto": c} for i, n, c in SITIOS_TAREA], + envios=_envios_abiertos({it["id"]: it for it in todos}), admin=is_admin(), user=current_user()) @@ -1730,9 +1802,12 @@ def inventario_crear(): t["modificado_en"] = t["creado_en"] t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "ítem creado"}] stock_inicial = _entero(d.get("stock_inicial"), 0) + sitio_inicial = _sitio_inv(d.get("sitio")) + if sitio_inicial is None: + return jsonify(ok=False, error="Sitio inválido para el stock inicial."), 400 _guardar_item(t) if stock_inicial: - _registrar_movimiento(t, "entrada", stock_inicial, "stock inicial", "", u) + _registrar_movimiento(t, "entrada", stock_inicial, "stock inicial", "", u, sitio=sitio_inicial) return jsonify(ok=True, item=t), 201 @@ -1768,20 +1843,40 @@ def inventario_modificar(): return jsonify(ok=True, item=t) -def _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u): - """Escribe el movimiento y recalcula el stock del ítem como suma de todos sus movimientos.""" +def _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u, sitio=SITIO_INV_OMISION, destino=None, envio=None): + """Escribe el movimiento y recalcula el stock del ítem como suma de todos sus movimientos. + + Dos cuentas (23-oct): «delta» mueve el total del ítem (lo que tenemos, esté donde esté, incluido lo que va en + camino) y «delta_sitio» mueve lo que hay en «sitio». Un envío no cambia el total: sale del sitio de origen y queda + en camino; su recepción lo pone en el destino (si llegó menos, la diferencia baja del total). Un envío desde + ORIGEN_FUERA (compra en camino) sí sube el total desde que se registra y no descuenta de ningún sitio.""" actual = _stock_desde_movimientos(item["id"]) + en_sitio = 0 if sitio == ORIGEN_FUERA else _stock_en_sitio(item["id"], sitio) if tipo == "entrada": - delta = cantidad + delta = delta_sitio = cantidad elif tipo == "salida": - delta = -cantidad - else: # ajuste: 'cantidad' es el conteo físico; el delta es lo que falta para llegar a él - delta = cantidad - actual + delta = delta_sitio = -cantidad + elif tipo == "ajuste": # 'cantidad' es el conteo físico EN ESE SITIO; el delta es lo que falta para llegar a él + delta = delta_sitio = cantidad - en_sitio + elif tipo == "envio": + delta, delta_sitio = (cantidad, 0) if sitio == ORIGEN_FUERA else (0, -cantidad) + else: # recepción de un envío: llega 'cantidad' al destino + delta_sitio = cantidad + delta = cantidad - _entero(envio.get("cantidad"), 0) m = {"id": "M" + datetime.datetime.now().strftime("%y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4], "item_id": item["id"], "codigo": item.get("codigo"), "tipo": tipo, "cantidad": cantidad, "delta": delta, + "sitio": sitio, "delta_sitio": delta_sitio, "motivo": motivo, "referencia": referencia, "quien": u, "cuando": ahora(), - "stock_antes": actual, "stock_despues": actual + delta} + "stock_antes": actual, "stock_despues": actual + delta, + "en_sitio_antes": en_sitio, "en_sitio_despues": en_sitio + delta_sitio} + if tipo == "envio": + m["destino"], m["estado"] = destino, "en-camino" + if envio is not None: + m["envio_id"], m["origen"] = envio["id"], _mov_sitio(envio) _escribir_json(_ruta_registro(INV_MOVS(), m["id"]), m) + if envio is not None: # el envío queda cerrado: ya no está en camino + envio.update(estado="recibido", recibido=cantidad, recepcion_id=m["id"], recibido_por=u, recibido_en=m["cuando"]) + _escribir_json(_ruta_registro(INV_MOVS(), envio["id"]), envio) _reescribir_indice(INV_MOVS(), _listar_json(INV_MOVS()), COLS_MOV) item["stock"] = _stock_desde_movimientos(item["id"]) item["modificado_por"] = u @@ -1798,29 +1893,71 @@ def inventario_movimiento(): d = _json_body() if d is None: return jsonify(ok=False, error="JSON inválido"), 400 - rid = slug(d.get("item_id"), 40) + tipo = _texto(d.get("tipo"), 10).lower() + if tipo not in TIPOS_MOV: + return jsonify(ok=False, error="Tipo de movimiento inválido: entrada, salida, ajuste, envio o recepcion."), 400 + nombre_sitio = {i: ("Querétaro" if i == "qro" else c) for i, _, c in SITIOS_TAREA} + envio = None + if tipo == "recepcion": # se recibe un envío que va en camino: el ítem y el destino salen del envío + eid = slug(d.get("envio_id"), 60) + envio = _leer_json(_ruta_registro(INV_MOVS(), eid)) if eid else None + if not envio or envio.get("tipo") != "envio": + return jsonify(ok=False, error=f"No existe el envío {eid}."), 404 + if envio.get("estado") != "en-camino": + return jsonify(ok=False, error="Ese envío ya se recibió" + (f" ({envio.get('recibido_por')}, {envio.get('recibido_en')})." if envio.get("recibido_por") else ".")), 409 + rid = envio.get("item_id") + else: + rid = slug(d.get("item_id"), 40) item = _leer_json(_ruta_registro(INV_ITEMS(), rid)) if rid else None if not item: return jsonify(ok=False, error=f"No existe el ítem {rid}."), 404 - tipo = _texto(d.get("tipo"), 10).lower() - if tipo not in TIPOS_MOV: - return jsonify(ok=False, error="Tipo de movimiento inválido: entrada, salida o ajuste."), 400 - try: - cantidad = int(d.get("cantidad")) - except Exception: - return jsonify(ok=False, error="La cantidad debe ser un número entero."), 400 - if tipo != "ajuste" and cantidad <= 0: + if tipo == "recepcion" and d.get("cantidad") in (None, ""): + cantidad = _entero(envio.get("cantidad"), 0) + else: + try: + cantidad = int(d.get("cantidad")) + except Exception: + return jsonify(ok=False, error="La cantidad debe ser un número entero."), 400 + if tipo in ("entrada", "salida", "envio") and cantidad <= 0: return jsonify(ok=False, error="La cantidad debe ser mayor que cero."), 400 - if tipo == "ajuste" and cantidad < 0: - return jsonify(ok=False, error="El conteo físico no puede ser negativo."), 400 + if tipo in ("ajuste", "recepcion") and cantidad < 0: + return jsonify(ok=False, error="La cantidad no puede ser negativa."), 400 + if tipo == "recepcion": + sitio = envio.get("destino") + if cantidad > _entero(envio.get("cantidad"), 0): + return jsonify(ok=False, error=f"Se enviaron {envio.get('cantidad')}: no pueden llegar más. Si llegó de más, " + "registra la diferencia como entrada."), 400 + elif tipo == "envio" and _norm_id(_texto(d.get("sitio"), 40)) in ALIAS_FUERA: + sitio = ORIGEN_FUERA + else: + sitio = _sitio_inv(d.get("sitio")) + if sitio is None: + return jsonify(ok=False, error="Sitio inválido: usa icave, timsa, eit, lzc o qro" + + (" (o fuera, si el envío no sale de ningún sitio)." if tipo == "envio" else ".")), 400 + destino = None + if tipo == "envio": + destino = _sitio_inv(d.get("destino"), omision=None) + if not destino: + return jsonify(ok=False, error="Elige a qué sitio va el envío."), 400 + if destino == sitio: + return jsonify(ok=False, error="El envío tiene que ir a otro sitio."), 400 motivo = _texto(d.get("motivo"), 200) + if not motivo and tipo == "recepcion": + o = _mov_sitio(envio) + motivo = "llegó el envío " + ("(venía de fuera del inventario)" if o == ORIGEN_FUERA else f"desde {nombre_sitio.get(o, o)}") if not motivo: return jsonify(ok=False, error="Escribe el motivo del movimiento."), 400 referencia = _texto(d.get("referencia"), 120) - if tipo == "salida" and _stock_desde_movimientos(rid) - cantidad < 0: - return jsonify(ok=False, error=f"No hay stock suficiente registrado ({_stock_desde_movimientos(rid)} {item.get('unidad')}). " - "Registra primero la entrada o haz un ajuste con el conteo físico."), 409 - m = _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u) + if tipo in ("salida", "envio") and sitio != ORIGEN_FUERA: + hay = _stock_en_sitio(rid, sitio) + if hay - cantidad < 0: + s = nombre_sitio.get(sitio, sitio) + falta = f"No hay stock suficiente en {s}: hay {hay} {item.get('unidad')} registrados. " + if tipo == "envio": + return jsonify(ok=False, error=falta + "Si ese material salió antes de que se contara, en «Sale de» elige " + f"«Proveedor o fuera del inventario»; si no, registra primero el conteo físico de {s}."), 409 + return jsonify(ok=False, error=falta + "Registra primero la entrada o el conteo físico de ese sitio."), 409 + m = _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u, sitio=sitio, destino=destino, envio=envio) return jsonify(ok=True, movimiento=m, item=item), 201 @@ -1828,7 +1965,12 @@ def inventario_movimiento(): def inventario_movimientos(): rid = slug(request.args.get("item_id"), 40) n = min(500, max(1, _entero(request.args.get("n"), 200))) + sitio = _sitio_inv(request.args.get("sitio"), omision="") movs = _movs_de(rid) if rid else sorted(_listar_json(INV_MOVS()), key=lambda m: m.get("cuando", "")) + if sitio: # lo que pasó en ese sitio, más lo que va o fue hacia él + movs = [m for m in movs if _mov_sitio(m) == sitio or m.get("destino") == sitio] + for m in movs: + m.setdefault("sitio", SITIO_INV_OMISION) movs.reverse() return jsonify(ok=True, total=len(movs), movimientos=movs[:n]) @@ -1838,12 +1980,16 @@ def inventario_export(): import io buf = io.StringIO() w = csv.writer(buf) - w.writerow(["codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta", + exis = _existencias() + w.writerow(["codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta", "por_instalacion"] + + ["en_" + c.lower() for _, _, c in SITIOS_TAREA] + ["en_camino", "ubicacion", "proveedor", "codigo_proveedor", "notas", "modificado_por", "modificado_en"]) for it in sorted(_listar_json(INV_ITEMS()), key=lambda x: (x.get("categoria", ""), x.get("codigo", ""))): alerta = bool(it.get("minimo", 0)) and it.get("stock", 0) < it.get("minimo", 0) + e = exis.get(it.get("id")) or {} w.writerow([it.get("codigo"), it.get("descripcion"), it.get("categoria"), " ".join(it.get("aplica_a") or []), - it.get("unidad"), it.get("stock", 0), it.get("minimo", 0), "SI" if alerta else "", + it.get("unidad"), it.get("stock", 0), it.get("minimo", 0), "SI" if alerta else "", it.get("por_instalacion", 0)] + + [(e.get("sitios") or {}).get(i, 0) for i, _, _ in SITIOS_TAREA] + [sum((e.get("camino") or {}).values()), it.get("ubicacion"), it.get("proveedor"), it.get("codigo_proveedor"), it.get("notas"), it.get("modificado_por"), it.get("modificado_en")]) nombre = "inventario-hbox-" + datetime.date.today().isoformat() + ".csv" diff --git a/backend/lotes/2026-10-23_inventario-sitios.json b/backend/lotes/2026-10-23_inventario-sitios.json new file mode 100644 index 0000000..69e79aa --- /dev/null +++ b/backend/lotes/2026-10-23_inventario-sitios.json @@ -0,0 +1,47 @@ +{ + "lote": "2026-10-23_inventario-sitios", + "autor": "claude", + "motivo": "Inventario por sitio (23-oct): una pestaña por puerto con lo que hay, lo que va en camino y lo que falta del kit para instalar; faltan el kit y el conteo de cada sitio", + "fuente": "/inventario/", + "cambios": [ + { "crear": { "id": "t-kit-instalacion", + "titulo": "Definir el kit de instalación en Inventario («Por instalación» de cada ítem)", + "pista": "campo", "estado": "por-hacer", "responsable": "adriana, leo", "version_tarjeta": "—", + "sitios": ["qro"], + "descripcion": "En Inventario → Editar, poner en cada ítem cuánto lleva UNA unidad instalada («Por instalación»). Con eso la pestaña de cada puerto dice qué falta para instalar N unidades. Punto de partida, de las actas de envío e instalación: GAB-IP67 1, ANT-GNSS 1, ANT-LTE 2, CONN-GX16 1, FUSIBLE-DIN 1; SD-15B-05 1 y CABLE-ETH 1 solo si la versión o la unidad los usan. Dar de alta, con categoría Instalación, lo que falte: soporte para RTG (LCT), tornillería, cinchos, cable de potencia. La unidad HBOX no va en el kit: se sigue por su acta.", + "enlaces": [{ "titulo": "Inventario", "url": "/tableros/inventario/" }, + { "titulo": "Kit de instalación (política)", "url": "/inventario/#kit" }], + "etiquetas": ["quick-win", "instalación"] }, + "si_no_hay": ["kit de instalacion"] }, + + { "crear": { "id": "t-conteo-sitios", + "titulo": "Conteo físico del material en cada puerto (ICAVE, TIMSA, EIT, LCT)", + "pista": "campo", "estado": "por-hacer", "responsable": "alan, Jesús Honorato, Miguel, Antonio", "version_tarjeta": "—", + "sitios": ["icave", "timsa", "eit", "lzc"], + "descripcion": "Cada responsable de campo cuenta lo que hay en su puerto y lo registra en la pestaña de su sitio: Movimiento → Conteo físico, uno por ítem, motivo «conteo inicial». Lo que no esté en la lista se da de alta con Nuevo ítem (categoría Instalación si es de montaje). ICAVE: Alán · TIMSA: Jesús Honorato · EIT: Miguel · LCT: Antonio. Mientras Miguel, Antonio y Jesús Honorato no tengan cuenta (t-alta-responsables-campo), pasan su conteo a Adriana y ella lo registra.", + "enlaces": [{ "titulo": "Inventario · ICAVE", "url": "/tableros/inventario/?sitio=icave" }, + { "titulo": "Inventario · TIMSA", "url": "/tableros/inventario/?sitio=timsa" }, + { "titulo": "Inventario · EIT", "url": "/tableros/inventario/?sitio=eit" }, + { "titulo": "Inventario · LCT", "url": "/tableros/inventario/?sitio=lzc" }, + { "titulo": "Conteo inicial (política)", "url": "/inventario/#conteo-inicial" }], + "etiquetas": ["quick-win", "instalación"] }, + "si_no_hay": ["conteo fisico del material en cada puerto"] }, + + { "id": "t-inventario-inicial", + "poner": { "titulo": "Inventario inicial en Querétaro: conteo físico de tarjetas, módulos, gabinetes, cajas, packs", + "enlaces": [{ "titulo": "Inventario · Querétaro", "url": "/tableros/inventario/?sitio=qro" }, + { "titulo": "Conteo inicial (política)", "url": "/inventario/#conteo-inicial" }] }, + "nota": "23-oct: el inventario ya es por sitio. Este es el conteo de Querétaro: pestaña Querétaro → Movimiento → Conteo físico, uno por ítem, motivo «conteo inicial». Lo registrado hasta hoy cuenta como Querétaro; los puertos van en t-conteo-sitios." }, + + { "id": "t-eit-material", + "poner": { "enlaces": [{ "titulo": "Acta de recepción", "url": "/formularios/recepcion.html" }, + { "titulo": "Inventario · EIT", "url": "/tableros/inventario/?sitio=eit" }] }, + "nota": "23-oct: ya se sigue en Inventario → EIT. Registrar lo que salió de Manzanillo como Envío desde «Proveedor o fuera del inventario» (en TIMSA no se había contado) a EIT, con la guía. Al llegar, Miguel (o Adriana por él) le da Recibir con lo que llegó." }, + + { "id": "t-lct-herreria", + "nota": "23-oct: cuando se sepa cuántos soportes van, darlos de alta en Inventario como ítem de categoría Instalación con «Por instalación» 1; así la pestaña LCT dice cuántos faltan. Lo que ya hay en LCT lo cuenta Antonio (t-conteo-sitios)." }, + + { "id": "t-ferreteria-eit-timsa", + "nota": "23-oct: lo que se compre se da de alta en Inventario (categoría Instalación) y, cuando salga, se registra como envío a EIT o TIMSA: el sitio lo ve en camino y le da Recibir al llegar." } + ] +} diff --git a/backend/tests-1023.sh b/backend/tests-1023.sh new file mode 100644 index 0000000..373d72f --- /dev/null +++ b/backend/tests-1023.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Pruebas del inventario por sitio (23-oct): arranca app.py con carpetas temporales. +# Uso: bash tests-1023.sh [puerto] +set -u +REPO=${1:?ruta a hbox-docs}; PORT=${2:-8095} +T=$(mktemp -d); export ACTAS_DIR=$T/actas PORT +mkdir -p $ACTAS_DIR +python3 $REPO/backend/app.py > $T/app.log 2>&1 & +APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.5 +U="http://127.0.0.1:$PORT" +LEO=(-s -H "X-Remote-User: leo" -H "Content-Type: application/json") +ok=0; fail=0 +check() { if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:200}"; fi; } +mov() { curl "${LEO[@]}" -X POST $U/api/inventario/movimiento -d "$1"; } +inv() { curl "${LEO[@]}" $U/api/inventario; } +py() { python3 -c "import sys,json; j=json.load(sys.stdin); $1"; } + +echo "## ítems y kit" +check "alta GAB con kit 1" '"por_instalacion":1' "$(curl "${LEO[@]}" -X POST $U/api/inventario -d '{"codigo":"GAB-IP67","descripcion":"Gabinete IP67","categoria":"gabinete","por_instalacion":1}')" +check "alta soporte (instalación) con stock inicial en LCT" '"ok":true' "$(curl "${LEO[@]}" -X POST $U/api/inventario -d '{"codigo":"SOP-RTG","descripcion":"Soporte para RTG","categoria":"instalacion","por_instalacion":1,"stock_inicial":2,"sitio":"lct"}')" +check "categoría instalacion aceptada" '"instalacion"' "$(inv)" +echo "## entrada, envío, recepción" +check "entrada sin sitio = Querétaro" '"sitio":"qro"' "$(mov '{"item_id":"GAB-IP67","tipo":"entrada","cantidad":10,"motivo":"compra"}')" +check "envío QRO → EIT" '"estado":"en-camino"' "$(mov '{"item_id":"GAB-IP67","tipo":"envio","cantidad":3,"sitio":"qro","destino":"Ensenada","motivo":"kit EIT","referencia":"guía 123"}')" +R=$(inv) +check "QRO baja a 7" "7" "$(echo "$R" | py 'print([i["por_sitio"].get("qro") for i in j["items"] if i["codigo"]=="GAB-IP67"][0])')" +check "3 en camino a EIT" "3" "$(echo "$R" | py 'print([i["en_camino"].get("eit") for i in j["items"] if i["codigo"]=="GAB-IP67"][0])')" +check "total sigue en 10" "10" "$(echo "$R" | py 'print([i["stock"] for i in j["items"] if i["codigo"]=="GAB-IP67"][0])')" +check "lista de envíos abiertos" '"destino": "eit"' "$(echo "$R" | py 'print(json.dumps(j["envios"]))')" +EID=$(echo "$R" | py 'print(j["envios"][0]["id"])') +check "salida en EIT sin stock → 409" "hay 0" "$(mov '{"item_id":"GAB-IP67","tipo":"salida","cantidad":1,"sitio":"eit","motivo":"instalación"}')" +check "recibir de más → 400" "no pueden llegar m" "$(mov '{"tipo":"recepcion","envio_id":"'$EID'","cantidad":5}')" +check "recibir 2 de 3" '"sitio":"eit"' "$(mov '{"tipo":"recepcion","envio_id":"'$EID'","cantidad":2}')" +R=$(inv) +check "EIT tiene 2" "2" "$(echo "$R" | py 'print([i["por_sitio"].get("eit") for i in j["items"] if i["codigo"]=="GAB-IP67"][0])')" +check "ya no va en camino" "{}" "$(echo "$R" | py 'print(json.dumps([i["en_camino"] for i in j["items"] if i["codigo"]=="GAB-IP67"][0]))')" +check "el que no llegó baja del total (9)" "9" "$(echo "$R" | py 'print([i["stock"] for i in j["items"] if i["codigo"]=="GAB-IP67"][0])')" +check "recibir otra vez → 409" "ya se recib" "$(mov '{"tipo":"recepcion","envio_id":"'$EID'"}')" +check "salida en EIT con stock" '"en_sitio_despues":1' "$(mov '{"item_id":"GAB-IP67","tipo":"salida","cantidad":1,"sitio":"eit","motivo":"instalación","referencia":"HBOX5-E07-A010-R0006"}')" +echo "## conteo físico por sitio" +check "conteo en LCT = 4 (había 2 del alta)" '"en_sitio_despues":4' "$(mov '{"item_id":"SOP-RTG","tipo":"ajuste","cantidad":4,"sitio":"lzc","motivo":"conteo"}')" +check "el total del soporte sube a 4" "4" "$(inv | py 'print([i["stock"] for i in j["items"] if i["codigo"]=="SOP-RTG"][0])')" +echo "## errores" +check "sitio inválido" "Sitio inv" "$(mov '{"item_id":"GAB-IP67","tipo":"entrada","cantidad":1,"sitio":"marte","motivo":"x"}')" +check "envío al mismo sitio" "otro sitio" "$(mov '{"item_id":"GAB-IP67","tipo":"envio","cantidad":1,"sitio":"qro","destino":"qro","motivo":"x"}')" +check "envío sin destino" "Elige a qu" "$(mov '{"item_id":"GAB-IP67","tipo":"envio","cantidad":1,"sitio":"qro","motivo":"x"}')" +check "envío sin stock en el origen (sugiere «fuera»)" "fuera del inventario" "$(mov '{"item_id":"GAB-IP67","tipo":"envio","cantidad":50,"sitio":"qro","destino":"timsa","motivo":"x"}')" +check "salida desde «fuera» no se acepta" "Sitio inv" "$(mov '{"item_id":"GAB-IP67","tipo":"salida","cantidad":1,"sitio":"fuera","motivo":"x"}')" +echo "## envío de fuera del inventario (compra en camino, o material que salió antes de contarlo)" +check "alta antena LTE con kit 2" '"por_instalacion":2' "$(curl "${LEO[@]}" -X POST $U/api/inventario -d '{"codigo":"ANT-LTE","descripcion":"Antena LTE","categoria":"antena","por_instalacion":2}')" +check "envío desde fuera a EIT" '"sitio":"fuera"' "$(mov '{"item_id":"ANT-LTE","tipo":"envio","cantidad":4,"sitio":"proveedor","destino":"eit","motivo":"salió de Manzanillo antes del conteo","referencia":"guía 999"}')" +R=$(inv) +check "entra al total desde ya (4)" "4" "$(echo "$R" | py 'print([i["stock"] for i in j["items"] if i["codigo"]=="ANT-LTE"][0])')" +check "no aparece en ningún sitio" "{}" "$(echo "$R" | py 'print(json.dumps([i["por_sitio"] for i in j["items"] if i["codigo"]=="ANT-LTE"][0]))')" +check "va en camino a EIT" '{"eit": 4}' "$(echo "$R" | py 'print(json.dumps([i["en_camino"] for i in j["items"] if i["codigo"]=="ANT-LTE"][0]))')" +EF=$(echo "$R" | py 'print([e["id"] for e in j["envios"] if e["codigo"]=="ANT-LTE"][0])') +check "el envío abierto dice origen fuera" '"origen": "fuera"' "$(echo "$R" | py 'print(json.dumps([e for e in j["envios"] if e["codigo"]=="ANT-LTE"][0]))')" +check "recibir las 4 en EIT" '"en_sitio_despues":4' "$(mov '{"tipo":"recepcion","envio_id":"'$EF'"}')" +R=$(inv) +check "EIT tiene 4 y el total sigue en 4" "4 4" "$(echo "$R" | py 'i=[i for i in j["items"] if i["codigo"]=="ANT-LTE"][0]; print(i["por_sitio"].get("eit"), i["stock"])')" +echo "## consultas" +check "movimientos de EIT (el envío y su recepción)" "recepcion" "$(curl "${LEO[@]}" "$U/api/inventario/movimientos?sitio=eit")" +check "CSV con columna por sitio" "en_eit" "$(curl "${LEO[@]}" $U/api/inventario/export.csv | head -1)" +check "CSV con la existencia en EIT" "GAB-IP67" "$(curl "${LEO[@]}" $U/api/inventario/export.csv)" +echo +echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T" diff --git a/docs/bitacora.md b/docs/bitacora.md index d0cb022..53a7d46 100644 --- a/docs/bitacora.md +++ b/docs/bitacora.md @@ -23,6 +23,7 @@ Arriba, lo que pasó en el tablero de pendientes y en las actas en los últimos ### Octubre de 2026 **23 oct** +: [Inventario por sitio](tableros/inventario.md): una pestaña por sitio (ICAVE, TIMSA, EIT, LCT y Querétaro) con lo que hay ahí, lo que va **en camino** y, con el kit por instalación, **qué falta para instalar** *N* unidades. Los envíos salen de un sitio (o de «Proveedor o fuera del inventario») y el que recibe le da **Recibir**; lo que no llegó baja del total con su nombre. [Política de inventario](inventario/index.md) al día: primero el conteo físico de cada sitio. Lote `2026-10-23_inventario-sitios`. : Documentos de diseño de la [V5.6](hardware/diseno-v56.md), la [V5.7](hardware/diseno-v57.md) y la [v6](hardware/diseno-v6.md), cada uno con su PDF y el esquemático completo: bloques, árbol de alimentación, especificaciones, mapa de pines del MCU, conectores, esquemático, capas, vistas 3D, BOM, puntos de prueba, reglas y DRC, riesgos e historial. Las tablas salen del netlist y del PCB, en los designadores de T43. La v6 lo dice arriba: todavía no se puede pedir. : [Serigrafía de 0.8 mm (T44)](hardware/v6-t44-serigrafia.md): **D-22-9 (b) en las tres**. V5.6 239 de 239 y V5.7 244 de 244 designadores a 0.8 mm con trazo de 0.15 mm, colocados por script sin mover piezas ni tocar cobre (`93d13e3`); con **T44b** quedan a 0.40 mm de cualquier otro texto para que no se lean pegados (`d19c832`). Rev6/Rev7 regenerados y comparados capa por capa: solo cambia la serigrafía superior. v6: 338 de 350 (`bd27501`); los 12 que no caben quedan para Michelle con lo que habría que mover. : [Plan de fines de semana de la v6, rev. 1.7](hardware/v6-plan-fines-de-semana.md): T44 hecho en la v6; los designadores no se acomodan a mano antes del cierre (al final corren T43 y T44). diff --git a/docs/inventario/index.md b/docs/inventario/index.md index 8ac9df5..74ec4a2 100644 --- a/docs/inventario/index.md +++ b/docs/inventario/index.md @@ -1,22 +1,23 @@ --- -title: "Inventario" -date: 2026-10-13 -tags: [project-drogheda, hbox, inventario, produccion, compras] +title: "Política de inventario por sitio" +date: 2026-10-23 +tags: [project-drogheda, hbox, inventario, produccion, compras, instalacion] --- # Inventario de materiales -Qué hay en el taller para producir e instalar HBOX: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. No es una lista de BOM: aquí solo entra lo que se cuenta a mano. +Qué hay **en cada sitio** —Querétaro (taller) y los cuatro puertos: ICAVE, TIMSA, EIT y LCT— para producir e instalar HBOX: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables, el material de instalación (soportes, tornillería, cable de potencia) y los componentes de entrega larga. No es una lista de BOM: aquí solo entra lo que se cuenta a mano. -**[Abrir el inventario →](../tableros/inventario.md)** +**[Abrir el inventario →](../tableros/inventario.md)** · por sitio: [ICAVE](../tableros/inventario.md?sitio=icave) · [TIMSA](../tableros/inventario.md?sitio=timsa) · [EIT](../tableros/inventario.md?sitio=eit) · [LCT](../tableros/inventario.md?sitio=lzc) · [Querétaro](../tableros/inventario.md?sitio=qro) ## Qué hay aquí | Página | Para qué | |---|---| -| [Inventario — existencias y movimientos](../tableros/inventario.md) | Tabla de existencias por ítem con mínimo y alerta; alta de ítem; movimiento de **entrada** (compra, devolución) o **salida** (ensamble, envío, baja) con motivo y liga a acta/unidad; exportar CSV | +| [Inventario](../tableros/inventario.md) | **Una pestaña por sitio:** lo que hay ahí, lo que va en camino hacia ese sitio y, con el kit, cuánto falta para instalar las unidades que pongas. **Todo:** el total de cada ítem y dónde está. Movimientos (entrada, salida, conteo físico, envío y recibir), alta de ítem y exportar CSV con una columna por sitio | +| [Instalaciones](../tableros/instalaciones.md) | Cuántas unidades van instaladas y probadas en cada sitio. Su botón **Material** abre la pestaña de ese sitio en el inventario | | [Tablero de producción](../tableros/produccion.md) | Lo que el inventario tiene que cubrir: 113 V5.6 · 327 V5.7 · 130 v6 | -| [Pendientes, pista Compras](../tableros/pendientes.md?pista=compras) | Pedidos en curso (SD-15B-05 ×113, paquetes LFP, gabinetes, cajas HBOX, antenas) | +| [Pendientes, pista Compras](../tableros/pendientes.md?pista=compras) | Pedidos en curso (SD-15B-05 ×113, paquetes LFP, gabinetes, cajas HBOX, antenas, ferretería de EIT y TIMSA) | ## Qué se registra y qué no @@ -24,21 +25,48 @@ Qué hay en el taller para producir e instalar HBOX: tarjetas, módulos, gabinet |---|---| | Tarjetas EinarBoard por versión (V5.5, V5.6, V5.7, v6) | Resistencias, capacitores y componentes de la BOM normal | | Módulos: ArduSimple simpleRTK2B Pro, 4G NTRIP Master | Herramienta y consumibles del banco | -| Gabinetes IP67, cajas plásticas HBOX, fuentes SD-15B, packs LFP | Unidades ya ensambladas (esas viven en el [tablero](../tableros/actas.md) por su acta) | +| Gabinetes IP67, cajas plásticas HBOX, fuentes SD-15B, packs LFP | Unidades ya ensambladas (esas viven en el [tablero](../tableros/actas.md) por su acta, y en [Instalaciones](../tableros/instalaciones.md) cuando ya están en un puerto) | | Antenas GNSS y LTE, cable Ethernet, conectores GX16, fusibles DIN | — | +| **Material de instalación** (categoría *Instalación*): soportes y herrería (los de las RTG de LCT), tornillería, cinchos, cable de potencia | — | | Componentes críticos de entrega larga: LTC4040, TPS54360B, SRP1265A | — | +## Dónde está cada cosa { #por-sitio } + +- **Cada movimiento pasa en un sitio.** Lo que hay en un sitio es la suma de sus movimientos; nadie escribe el número a mano. +- El **total** de un ítem es lo de todos los sitios **más lo que va en camino**. El mínimo y la alerta se comparan contra el total. +- Lo que se registró antes del 23-oct no tenía sitio: **cuenta como Querétaro**. El total de cada ítem no cambió. +- **En camino:** un envío sale de un sitio y no está en ningún lado hasta que el sitio que lo recibe le da **Recibir**. Si llegó menos de lo enviado, se recibe lo que llegó: la diferencia baja del total y queda anotada con el nombre de quien recibió. +- **Proveedor o fuera del inventario:** una compra que ya viene en camino, o material que salió de un puerto antes de que ahí se contara (como el material que salió de Manzanillo hacia EIT), se registra como envío que sale de «Proveedor o fuera del inventario». No descuenta de ningún sitio y entra al total desde que se registra. + ## Quién registra y cuándo | Qué pasó | Quién | Movimiento | |---|---|---| -| Llega material (paquetería, Aldair, Mouser) | Quien recibe (Jesús o Leo) | **Entrada**, con la orden o guía como referencia, el mismo día | -| Se arma una unidad | Quien ensambla, al cerrar el acta de ensamble | **Salida** de cada pieza, con el número de unidad `HBOX5-Exx-Axxx-Rxxxx` como referencia | -| Sale un kit al puerto | Quien llena el acta de envío | **Salida** de lo que va en la caja (fuente, antenas, gabinete) | -| El conteo no cuadra | Jesús, al contar | **Ajuste** con el conteo físico y el motivo («conteo mensual», «se encontró en el otro anaquel») | -| Pieza dañada o de baja | Quien llena el acta de daño | **Salida** con el motivo y la serie | +| Llega material de un proveedor (paquetería, Aldair, Mouser) | Quien recibe en ese sitio (en Querétaro, Jesús Romero o Leo) | **Entrada** en ese sitio, con la orden o guía como referencia, el mismo día | +| Se manda material a otro sitio (de Querétaro a un puerto, o entre puertos) | Quien empaca (si va con una unidad, al llenar su [acta de envío](../formularios/envio.html)) | **Envío**: elige de dónde sale y a qué sitio va, con la guía como referencia. Queda en camino | +| Llega un envío al puerto | El responsable del sitio: Alán (ICAVE), Jesús Honorato (TIMSA), Miguel (EIT), Antonio (LCT) | **Recibir** en la pestaña de su sitio, con lo que llegó de verdad, el mismo día | +| Se arma una unidad | Quien ensambla, al cerrar el acta de ensamble | **Salida** en Querétaro de cada pieza, con el número de unidad `HBOX5-Exx-Axxx-Rxxxx` como referencia | +| Se instala una unidad | Quien instala, al cerrar el [acta de instalación](../formularios/instalacion.html) | **Salida** en ese puerto del material del kit, con la unidad o el vehículo como referencia | +| El conteo no cuadra | Quien cuenta en ese sitio | **Conteo físico** en ese sitio: queda exactamente en el número que contaste, con el motivo («conteo mensual», «se encontró en el otro anaquel») | +| Pieza dañada o de baja | Quien llena el acta de daño | **Salida** en el sitio donde estaba, con el motivo y la serie | -El stock es la **suma de los movimientos**; nadie lo escribe a mano. Cada movimiento queda con usuario, fecha, cantidad, motivo y referencia, y se puede ver desde el código del ítem. +Cada movimiento queda con usuario, fecha, sitio, cantidad, motivo y referencia; se ven todos tocando el código del ítem. Una salida o un envío no pueden dejar un sitio en negativo: si el sistema dice «No hay stock suficiente», falta la entrada o el conteo físico de ese sitio. + +## Kit de instalación { #kit } + +- En cada ítem, **«Por instalación»** dice cuánto lleva **una** unidad instalada (0 = no es del kit). Se cambia en **Editar**. +- Con eso, la pestaña de cada puerto calcula para las unidades que pongas arriba («Para instalar *N* unidades»): **Faltan *n* pza** (rojo), **Completo al llegar** (lo que falta ya va en camino), **Completo**. +- El kit lo definen Adriana y Leo (pendiente `t-kit-instalacion`). Punto de partida, tomado de las actas de [envío](../formularios/envio.html) e [instalación](../formularios/instalacion.html): gabinete IP67 ×1, antena GNSS con cable ×1, antenas LTE ×2, arnés GX16 ×1, fusible ×1, fuente Mean Well ×1 si la versión la usa, tornillería y cinchos; en LCT, además, soporte para RTG ×1. La unidad HBOX no va en el kit: se sigue por su acta. + +## Conteo inicial por sitio { #conteo-inicial } + +Los ítems existen desde el 13-oct; lo registrado hasta hoy cuenta como Querétaro. Para que cada pestaña diga la verdad: + +1. **Querétaro:** Jesús Romero cuenta y registra un **conteo físico por ítem** con el motivo «conteo inicial» (pendiente `t-inventario-inicial`). +2. **Cada puerto:** su responsable de campo cuenta lo que hay ahí y registra un **conteo físico por ítem** en la pestaña de su sitio (pendiente `t-conteo-sitios`). Lo que no esté en la lista se da de alta con **Nuevo ítem** (categoría *Instalación* si es material de montaje). +3. Lo que ya viene en camino se registra como **envío** (de Querétaro, de otro puerto o de «Proveedor o fuera del inventario»), para que el sitio que lo espera lo vea y le dé **Recibir** al llegar. + +Mientras no se cuente un sitio, su pestaña dice que no hay material y las alertas pueden no ser reales. ## Mínimos y alertas @@ -47,12 +75,9 @@ El stock es la **suma de los movimientos**; nadie lo escribe a mano. Cada movimi - Por debajo del mínimo el ítem se marca en rojo aquí y en el [tablero de producción](../tableros/produccion.md). La alerta no manda correos: hay que mirar el tablero. - Mínimo 0 = sin alerta (consumibles que no vale la pena vigilar). -## Conteo inicial (pendiente del 13-oct) - -Los ítems ya existen con stock 0. Jesús cuenta y registra **un ajuste por ítem** con motivo «conteo inicial». Mientras no se haga, el tablero mostrará alertas que no son reales. - ## Para ingeniería - Datos en `/srv/projects/drogheda/inventario/{items,movimientos}/` (un JSON por registro + `_indice.csv`). -- API: `GET /api/inventario`, `POST /api/inventario/movimiento`, `GET /api/inventario/movimientos?item_id=`, `GET /api/inventario/export.csv`. Ejemplos en `backend/README.md`. +- API: `GET /api/inventario` (cada ítem con `por_sitio`, `en_camino` y `por_instalacion`; además `sitios` y los `envios` abiertos), `POST /api/inventario/movimiento` (`tipo` entrada, salida, ajuste, envio o recepcion; `sitio` icave, timsa, eit, lzc o qro, y `fuera` como origen de un envío; `destino` en el envío; `envio_id` en la recepción), `GET /api/inventario/movimientos?item_id=&sitio=`, `GET /api/inventario/export.csv` (una columna por sitio y otra de en camino). Ejemplos en `backend/README.md`; pruebas en `backend/tests-1023.sh`. +- Cada movimiento lleva dos cuentas: `delta` mueve el total y `delta_sitio` lo que hay en su sitio. Un envío entre sitios no cambia el total; su recepción sí, si llegó menos. - Nada se borra. Un ítem que ya no aplica se marca «(obsoleto)» en la descripción y con mínimo 0. diff --git a/docs/tableros/index.md b/docs/tableros/index.md index 7709694..42b8953 100644 --- a/docs/tableros/index.md +++ b/docs/tableros/index.md @@ -14,7 +14,7 @@ Las páginas que abres para saber **dónde está el proyecto hoy**: qué pendien | **Dónde está el hardware** (V5.5 → V5.6 → V5.7 → v6) | [Ingeniería](ingenieria.md) | Una tarjeta por versión con su estado, siguiente paso, responsables y documentos; los pendientes de la pista Ingeniería; últimas decisiones y filosofía de diseño | | **Cuántas unidades** llevamos y cuántas faltan | [Producción](produccion.md) | Plan 113 V5.6 · 327 V5.7 · 130 v6 contra las unidades reales por etapa; embudo por paso; bloqueos; alertas de inventario | | Qué **tarjeta, módulo o unidad** pasó cada acta | [Actas](actas.md) | Series V5-NN, ARD-NNN, RTK-NNNN y V4-NN; parejas integradas; unidades ensambladas. Pendiente = liga al acta | -| Qué **material** hay y qué falta | [Inventario](inventario.md) | Existencias, mínimos y cada movimiento con quién y por qué | +| Qué **material** hay en cada sitio y qué falta para instalar | [Inventario](inventario.md) | Una pestaña por sitio (ICAVE, TIMSA, EIT, LCT, Querétaro): lo que hay, lo que va **en camino** y, con el kit por instalación, cuánto falta para instalar *N* unidades. **Todo**: el total de cada ítem y dónde está. Cada movimiento con quién, dónde y por qué | | En qué **vehículo** está cada unidad y si ya pasó la **prueba en sitio** | [Instalaciones](instalaciones.md) | Una tarjeta por unidad en cada sitio: enviada, recibida, instalada, probada en sitio y en operación, más «con falla»; vehículo (número económico, tipo, 12/24 V), sus actas y el botón de la siguiente acta. Solo las probadas cuentan para la meta de 1–2 por sitio | | Qué hay **en cada puerto** | [Sitios](sitio.md) | Unidades instaladas, envíos, diagnósticos, daños y contactos de ICAVE, LCT, EIT y TIMSA | | Qué se acordó en las **juntas** (standup y quick wins) | [Juntas](juntas.md) | La agenda fija, la cadencia y el registro de cada junta con su lote | @@ -31,12 +31,13 @@ Las páginas que abres para saber **dónde está el proyecto hoy**: qué pendien 5. Los pendientes sustituyen al *Task Tracker* de Notion, que queda como histórico (sus tareas se importaron una vez). 6. Claude también mueve pendientes: sus cambios llegan en **lotes** que Leo revisa y aplica, y se ven en la Actividad con su nombre y una nota que dice por qué. Toda tarea termina en el tablero, sea de quien sea ([el sitio es el centro de mando](../guia-ingeniero.md#el-sitio-es-el-centro-de-mando)). 7. **Sitio** (22-oct): cada pendiente dice dónde pasa — ICAVE, TIMSA, EIT, LCT o Querétaro (fabricación e ingeniería); puede ser más de uno. En [Pendientes](pendientes.md) filtra con los botones de sitio (la dirección lo guarda: `?sitio=eit`) o abre la vista **Por sitio**: una fila por sitio. Pista **Gestión**: finanzas, trámites y juntas. Las [juntas](juntas.md) (standup y quick wins) terminan en el tablero. +8. **Inventario por sitio** (23-oct): en [Inventario](inventario.md) cada puerto tiene su pestaña (la dirección la guarda: `?sitio=eit`). El material que va de un sitio a otro se registra como **envío** y queda en camino hasta que el que lo recibe le da **Recibir**. Desde [Instalaciones](instalaciones.md), el botón **Material** abre la pestaña del sitio. Reglas en la [Política de inventario](../inventario/index.md). Las direcciones viejas (`tableros/kanban.html`, `unidad.html?serie=…`, `sitios/sitio.html?id=…` y las demás) siguen sirviendo: llevan solas a la página nueva. ## Relacionados -- [Política de inventario](../inventario/index.md): qué se cuenta, mínimos y cómo se registra. +- [Política de inventario](../inventario/index.md): qué se cuenta, quién registra en cada sitio, envíos en camino y kit de instalación. - [Sitios y puertos](../sitios/index.md): la lista de sitios y cómo se da de alta uno. - [Bitácora](../bitacora.md): los documentos por fecha. - [Historia del proyecto](../historia/index.md): jun → oct 2026 con ligas. diff --git a/docs/tableros/instalaciones.js b/docs/tableros/instalaciones.js index 12f74d1..0cb7876 100644 --- a/docs/tableros/instalaciones.js +++ b/docs/tableros/instalaciones.js @@ -303,6 +303,8 @@ $('ins-meta').textContent = (s ? s.nombre + ' · ' : '') + plural(us.length, 'unidad', 'unidades') + (s ? '' : ' en ' + plural(SITIOS.length, 'sitio', 'sitios')) + ' · al día a las ' + hora; $('ins-a-inst').href = forma('instalacion', s ? { sitio: s.id } : null); $('ins-a-prueba').href = forma('prueba', s ? { sitio: s.id } : null); + const mat = $('ins-a-mat'); // inventario del sitio (23-oct) + if (mat) mat.href = '/tableros/inventario/' + (s ? '?sitio=' + encodeURIComponent(s.id) : ''); document.title = 'Instalaciones' + (s ? ' · ' + s.nombre : '') + ' - Drogheda'; if (s) pintarSitio(s.id); else pintarResumen(); } diff --git a/docs/tableros/instalaciones.md b/docs/tableros/instalaciones.md index 7e78e27..a90886a 100644 --- a/docs/tableros/instalaciones.md +++ b/docs/tableros/instalaciones.md @@ -13,7 +13,7 @@ hide:

    Instalaciones

    Cargando…
    - +
    diff --git a/docs/tableros/inventario.css b/docs/tableros/inventario.css new file mode 100644 index 0000000..97f0c56 --- /dev/null +++ b/docs/tableros/inventario.css @@ -0,0 +1,63 @@ +/* Inventario por sitio (23-oct-2026). Lo común (tabla .t, .acciones, .pill, diálogos) viene de tablero.css. */ + +/* ---- Pestañas de sitio (mismas que Instalaciones) ------------------------- */ +.tb-inv .inv-tabs { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 3px; margin: 0 0 14px; + border: 1px solid var(--hg-linea-2); border-radius: 10px; background: var(--hg-plano-2); } +.tb-inv .inv-tabs button { background: transparent; border: 0; color: var(--hg-texto-2); font-size: 13.5px; font-weight: 500; + padding: 5px 11px; border-radius: 7px; gap: 7px; } +.tb-inv .inv-tabs button[aria-pressed="true"] { background: var(--hg-elevado); color: var(--hg-texto-fuerte); font-weight: 600; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hg-curso) 45%, transparent); } +.tb-inv .inv-tabs button:hover:not(:disabled) { filter: none; color: var(--hg-texto-fuerte); } +.tb-inv .inv-tabs .n { font-weight: 400; color: var(--hg-aviso); font-variant-numeric: tabular-nums; } + +/* ---- «Dónde está» en la vista Todo y en los movimientos -------------------- */ +.tb-inv .inv-donde { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; } +.tb-inv .inv-donde .chip { font-size: 12px; padding: 1px 7px; border-radius: 6px; background: var(--hg-plano-2); + border: 1px solid var(--hg-linea); color: var(--hg-texto-2); white-space: nowrap; font-variant-numeric: tabular-nums; } +.tb-inv .inv-donde .chip b { font-weight: 600; color: var(--hg-texto-fuerte); } +.tb-inv .inv-donde .chip.camino { background: var(--hg-aviso-bg); color: var(--hg-aviso); border-color: transparent; } +.tb-inv .inv-donde .chip.neg { background: var(--hg-bloqueado-bg); color: var(--hg-bloqueado); border-color: transparent; } +.tb-inv .inv-donde .mini { flex-basis: 100%; } +.tb-inv dialog .inv-donde { margin: -2px 0 10px; } + +/* ---- Cabeza de un sitio --------------------------------------------------- */ +.tb-inv .inv-cab { margin: 2px 0 14px; } +.md-typeset .tb-inv .inv-cab h2 { margin: 0 0 6px; font-size: 20px; } +.tb-inv .inv-n { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; font-size: 14px; color: var(--hg-texto-2); } +.tb-inv .inv-n label { margin: 0; display: inline-flex; align-items: center; gap: 6px; font-weight: 500; color: var(--hg-texto-2); } +.tb-inv .inv-n input[type="number"] { width: 4.2em; padding: 4px 6px; text-align: center; } +.tb-inv .inv-res { font-weight: 600; } +.tb-inv .inv-res.mal { color: var(--hg-bloqueado); } +.tb-inv .inv-res.casi { color: var(--hg-aviso); } +.tb-inv .inv-res.bien { color: var(--hg-hecho); } +.tb-inv .inv-sin-kit { margin: 0 0 10px; } +.tb-inv .inv-vacio p { margin: 0 0 6px; } + +/* ---- Envíos en camino ----------------------------------------------------- */ +.tb-inv .inv-camino { border: 1px solid color-mix(in srgb, var(--hg-aviso) 45%, var(--hg-linea)); background: var(--hg-aviso-bg); + border-radius: 10px; padding: 10px 14px; margin: 0 0 14px; } +.tb-inv .inv-camino.sale { border-color: var(--hg-linea); background: var(--hg-plano-2); } +.md-typeset .tb-inv .inv-camino h3 { margin: 0 0 6px; font-size: 14px; color: var(--hg-texto-fuerte); } +.tb-inv .inv-camino .fila { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-top: 1px dashed var(--hg-linea); font-size: 13.5px; } +.tb-inv .inv-camino h3 + .fila { border-top: 0; } +.tb-inv .inv-camino .cant { font-weight: 700; min-width: 4.5em; font-variant-numeric: tabular-nums; color: var(--hg-texto-fuerte); } +.tb-inv .inv-camino .que { flex: 1; min-width: 0; } +.tb-inv .inv-camino .que .mini { display: block; } +.tb-inv .inv-camino button { padding: 5px 12px; font-size: 13px; } + +/* ---- Tabla de un sitio ---------------------------------------------------- */ +.tb-inv table.inv-sitio tr.falta td.num.stock { color: var(--hg-bloqueado); font-weight: 700; } +.tb-inv table.inv-sitio .desc-m { display: none; } +.tb-inv .mov-fila { grid-template-columns: 118px 150px 82px 1fr; } +@media (max-width: 640px) { + .tb-inv .inv-tabs { display: flex; } + .tb-inv .inv-tabs button { flex: 1 1 auto; justify-content: center; } + .tb-inv .inv-camino .fila { flex-wrap: wrap; } + .tb-inv .mov-fila { grid-template-columns: 1fr 1fr; } + /* teléfono: la descripción va bajo el código y se quitan las columnas de cálculo; el estado del kit ya dice cuánto falta */ + .tb-inv table.inv-sitio .c-desc, .tb-inv table.inv-sitio .c-op, .tb-inv table.inv-sitio .c-acc { display: none; } + .tb.tb-inv #cont table.inv-sitio { min-width: 0; } /* tablero.css fuerza 780 px a las tablas anchas; esta ya cabe */ + .tb-inv table.inv-sitio .desc-m { display: block; font-size: 12.5px; color: var(--hg-texto-2); margin-top: 2px; max-width: 13em; } + .tb-inv table.inv-sitio .cod { font-size: 13px; } + .tb-inv table.inv-sitio th { white-space: nowrap; } +} diff --git a/docs/tableros/inventario.js b/docs/tableros/inventario.js new file mode 100644 index 0000000..3aa4e1f --- /dev/null +++ b/docs/tableros/inventario.js @@ -0,0 +1,446 @@ +/* Inventario por sitio (23-oct-2026). Hasta el 22-oct este código vivía dentro de inventario.md. + + Una pestaña por sitio (ICAVE, TIMSA, EIT, LCT y Querétaro): lo que hay ahí, lo que va en camino y, para el material + de instalación («Por instalación» > 0), cuánto falta para instalar N unidades. «Todo» es el inventario completo. + La dirección guarda la vista: ?sitio=eit&n=2. + + Servidor (backend/app.py): GET /api/inventario (ítems con por_sitio y en_camino, envíos abiertos, sitios) · + POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento (entrada, salida, ajuste, + envio con sitio y destino, recepcion con envio_id) · GET /api/inventario/movimientos?item_id= · export.csv. + Un envío puede salir de «fuera» (FUERA): una compra que viene en camino o material que salió antes de contarlo. + Necesita tableros/kanban.js (íconos) y tableros/tablero.css; estilos propios en tableros/inventario.css. */ +(function () { + const el = (t, a, ...k) => { + const e = document.createElement(t); + if (a) for (const p in a) { if (a[p] == null) continue; if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } + k.forEach(c => c != null && e.append(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c)); + return e; + }; + const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }; + const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16); + const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']; + const fechaC = iso => { const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}:\d{2}))?/.exec(iso || ''); return m ? (+m[3]) + ' ' + MES[+m[2] - 1] + (m[4] ? ', ' + m[4] : '') : ''; }; + const $ = id => document.getElementById(id); + const icono = n => (window.Kanban && window.Kanban.icono ? window.Kanban.icono(n) : ''); + const CAT = { tarjeta: 'Tarjeta', modulo: 'Módulo', gabinete: 'Gabinete', caja: 'Caja', fuente: 'Fuente', bateria: 'Batería', antena: 'Antena', + cable: 'Cable', conector: 'Conector', 'componente-critico': 'Componente crítico', instalacion: 'Instalación', otro: 'Otro' }; + const VERS = ['V5.5', 'V5.6', 'V5.7', 'v6']; + const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' }; + const ORDEN = ['icave', 'timsa', 'eit', 'lzc', 'qro']; + const ALIAS = { lct: 'lzc', lazarocardenas: 'lzc', 'lazaro-cardenas': 'lzc', veracruz: 'icave', manzanillo: 'timsa', ensenada: 'eit', queretaro: 'qro', oficina: 'qro' }; + const SITIOS_OMISION = [{ id: 'icave', corto: 'ICAVE', nombre: 'ICAVE · Veracruz' }, { id: 'timsa', corto: 'TIMSA', nombre: 'TIMSA · Manzanillo' }, + { id: 'eit', corto: 'EIT', nombre: 'EIT · Ensenada' }, { id: 'lzc', corto: 'LCT', nombre: 'LCT · Lázaro Cárdenas' }, { id: 'qro', corto: 'QRO', nombre: 'Querétaro' }]; + const TIPOS = [['entrada', 'Entrada — llegó material a este sitio (+)'], ['salida', 'Salida — se instaló, se usó o se dio de baja (−)'], + ['ajuste', 'Conteo físico — fija lo que hay en el sitio'], ['envio', 'Envío a otro sitio — sale de aquí y queda en camino']]; + + let ITEMS = [], ENVIOS = [], SITIOS = SITIOS_OMISION.slice(), USER = '', vista = 'todo', N = 2, cargadoEn = 0, conSitios = true; + const dlg = $('dlg'), aviso = $('aviso'), cont = $('cont'), tabs = $('inv-tabs'); + + const FUERA = { id: 'fuera', corto: 'fuera del inventario', nombre: 'Proveedor o fuera del inventario' }; + const sitio = id => SITIOS.find(s => s.id === id) || (id === FUERA.id ? FUERA : { id, corto: String(id || '').toUpperCase(), nombre: String(id || '').toUpperCase() }); + const corto = id => (id === 'qro' ? 'Querétaro' : sitio(id).corto); + const enSitio = (i, s) => (i.por_sitio || {})[s] || 0; + const enCamino = (i, s) => (i.en_camino || {})[s] || 0; + const totalCamino = i => Object.values(i.en_camino || {}).reduce((a, b) => a + b, 0); + const pl = (n, uno, varios) => n + ' ' + (n === 1 ? uno : varios); + + function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => (aviso.className = 'msg'), bien ? 5000 : 10000); } + async function api(url, opts) { + const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {})); + let j = null; try { j = await r.json(); } catch (e) { /* sin JSON */ } + if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); + return j; + } + const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); + function campo(l, i, ayuda) { const d = el('div'); d.append(el('label', { text: l }), i); if (ayuda) d.append(el('div', { class: 'ayuda', text: ayuda })); return d; } + + Object.entries(CAT).forEach(([k, v]) => $('fCat').append(el('option', { value: k, text: v }))); + VERS.forEach(v => $('fVer').append(el('option', { value: v, text: 'Aplica a: ' + v }))); + + function visibles() { + const cat = $('fCat').value, ver = $('fVer').value, solo = $('fAlerta').checked, q = $('fBuscar').value.trim().toLowerCase(); + return ITEMS.filter(i => (!cat || i.categoria === cat) && (!ver || (i.aplica_a || []).includes(ver)) && (!solo || i.alerta) + && (!q || (i.codigo + ' ' + i.descripcion + ' ' + (i.proveedor || '') + ' ' + (i.ubicacion || '')).toLowerCase().includes(q))); + } + + // ---------------------------------------------------------------- pestañas + function pintarTabs() { + tabs.innerHTML = ''; + const b = (id, txt, n) => { + const x = el('button', { type: 'button', 'aria-pressed': String(vista === id) }, txt); + if (n) x.append(el('span', { class: 'n', text: ' · ' + n + ' en camino' })); + x.addEventListener('click', () => ir(id)); + tabs.append(x); + }; + b('todo', 'Todo'); + ORDEN.filter(id => SITIOS.some(s => s.id === id)).forEach(id => b(id, corto(id), ENVIOS.filter(e => e.destino === id).length)); + } + function ir(id) { + vista = id; + const u = new URL(location.href); + if (id === 'todo') { u.searchParams.delete('sitio'); u.searchParams.delete('n'); } else { u.searchParams.set('sitio', id); if (N !== 2) u.searchParams.set('n', N); else u.searchParams.delete('n'); } + history.replaceState(null, '', u.pathname + u.search + u.hash); + pintar(); + } + + // ---------------------------------------------------------------- vista «Todo» + function chipsDonde(i) { + const d = el('div', { class: 'inv-donde' }); + ORDEN.forEach(s => { const n = enSitio(i, s); if (n) d.append(el('span', { class: 'chip' + (n < 0 ? ' neg' : ''), title: sitio(s).nombre }, el('b', { text: corto(s) === 'Querétaro' ? 'QRO' : corto(s) }), ' ' + n)); }); + const c = totalCamino(i); + if (c) d.append(el('span', { class: 'chip camino', title: 'Va en camino' }, c + ' en camino')); + if (!d.childNodes.length) d.append(el('span', { class: 'mini', text: '—' })); + if (i.ubicacion) d.append(el('div', { class: 'mini', text: i.ubicacion })); + return d; + } + function pintarTodo() { + const vis = visibles(); + $('cnt').textContent = vis.length + ' ítem(s)' + (vis.length !== ITEMS.length ? ' de ' + ITEMS.length : '') + ' · ' + vis.filter(i => i.alerta).length + ' en alerta'; + if (!vis.length) { cont.append(el('div', { class: 'vacio', text: ITEMS.length ? 'Nada coincide con el filtro.' : 'Todavía no hay ítems. Crea el primero con «Nuevo ítem».' })); return; } + const w = el('div', { class: 'tblwrap' }); const t = el('table', { class: 't' }); + t.innerHTML = 'CódigoDescripciónAplica aTotalDónde estáMínimoKit'; + vis.forEach(i => { + const tr = el('tr', { class: i.alerta ? 'alerta' : '' }); + const cod = el('span', { class: 'cod', text: i.codigo, title: 'Ver movimientos' }); cod.addEventListener('click', () => verMovimientos(i)); + tr.append(el('td', null, cod, el('span', { class: 'cat', text: CAT[i.categoria] || i.categoria }))); + const desc = el('td', null, i.descripcion); + if (i.proveedor || i.codigo_proveedor) desc.append(el('div', { class: 'mini', text: [i.proveedor, i.codigo_proveedor].filter(Boolean).join(' · ') })); + if (i.notas) desc.append(el('div', { class: 'mini', text: i.notas.length > 90 ? i.notas.slice(0, 89) + '…' : i.notas })); + tr.append(desc); + const ap = el('td'); (i.aplica_a || []).forEach(v => ap.append(el('span', { class: BADGE[v] || 'pill pend', text: v, style: 'margin-right:3px' }))); tr.append(ap); + tr.append(el('td', { class: 'num stock', html: esc(i.stock) + ' ' + esc(i.unidad) + '' + (i.alerta ? ' alerta' : '') })); + tr.append(el('td', null, chipsDonde(i))); + tr.append(el('td', { class: 'num', text: i.minimo || '—' })); + tr.append(el('td', { class: 'num', text: i.por_instalacion || '—' })); + const acc = el('td', { style: 'white-space:nowrap' }); + const bm = el('button', { text: 'Movimiento' }); bm.addEventListener('click', () => movimiento(i)); + const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(i)); + acc.append(bm, be); tr.append(acc); + t.append(tr); + }); + w.append(t); cont.append(w); + } + + // ---------------------------------------------------------------- vista de un sitio + function filasSitio(sid) { + const q = $('fBuscar').value.trim().toLowerCase(), cat = $('fCat').value; + return ITEMS.map(i => { + const en = enSitio(i, sid), cam = enCamino(i, sid), k = i.por_instalacion || 0, nec = k * N; + return { i, en, cam, k, nec, faltaHoy: Math.max(0, nec - en), falta: Math.max(0, nec - en - cam) }; + }).filter(r => (r.k > 0 || r.en !== 0 || r.cam > 0) + && (!cat || r.i.categoria === cat) && (!q || (r.i.codigo + ' ' + r.i.descripcion).toLowerCase().includes(q))) + .sort((a, b) => ((b.k > 0) - (a.k > 0)) || (b.falta - a.falta) || (b.faltaHoy - a.faltaHoy) || String(a.i.codigo).localeCompare(String(b.i.codigo))); + } + function estadoKit(r) { + if (!r.k) return el('span', { class: 'mini', text: 'fuera del kit' }); + if (r.falta) return el('span', { class: 'pill falla', text: 'Faltan ' + r.falta + ' ' + r.i.unidad }); + if (r.faltaHoy) return el('span', { class: 'pill aviso', text: 'Completo al llegar' }); + return el('span', { class: 'pill ok', text: 'Completo' }); + } + function pintarSitio(sid) { + const s = sitio(sid), filas = filasSitio(sid), kit = filas.filter(r => r.k > 0); + const llegan = ENVIOS.filter(e => e.destino === sid), salen = ENVIOS.filter(e => e.origen === sid); + $('cnt').textContent = ''; + + // cabeza: cuántas unidades y cómo va el kit + const cab = el('div', { class: 'inv-cab' }); + const fN = el('input', { type: 'number', min: '1', max: '50', step: '1', value: String(N), inputmode: 'numeric', 'aria-label': 'Unidades por instalar' }); + fN.addEventListener('change', () => { N = Math.min(50, Math.max(1, parseInt(fN.value, 10) || 1)); ir(vista); }); + cab.append(el('h2', { text: 'Material en ' + (sid === 'qro' ? 'Querétaro' : s.nombre) })); + const linea = el('div', { class: 'inv-n' }); + if (sid === 'qro') linea.append(el('span', { class: 'mini', text: 'Almacén de fabricación e ingeniería: de aquí sale el material a los puertos.' })); + else linea.append(el('label', null, 'Para instalar ', fN, ' ' + (N === 1 ? 'unidad' : 'unidades'))); + if (kit.length) { + const completos = kit.filter(r => !r.falta && !r.faltaHoy).length, faltan = kit.filter(r => r.falta).length, llegando = kit.filter(r => !r.falta && r.faltaHoy).length; + linea.append(el('span', { class: 'inv-res' + (faltan ? ' mal' : (llegando ? ' casi' : ' bien')) }, + sid === 'qro' ? pl(kit.length, 'ítem del kit', 'ítems del kit') : (faltan ? (faltan === 1 ? 'Falta 1 ítem' : 'Faltan ' + faltan + ' ítems') + ' del kit' : (llegando ? 'Kit completo cuando llegue lo que va en camino' : 'Kit completo')) + ' · ' + completos + ' de ' + kit.length + ' completos')); + } + if (sid !== 'qro') linea.append(el('a', { href: '/tableros/instalaciones/?sitio=' + encodeURIComponent(sid), text: 'Instalaciones de ' + corto(sid) })); + cab.append(linea); + cont.append(cab); + + // lo que va en camino hacia este sitio (se recibe aquí) y lo que salió de aquí + if (llegan.length) { + const box = el('section', { class: 'inv-camino' }); + box.append(el('h3', { text: 'En camino a ' + corto(sid) })); + llegan.forEach(e => { + const f = el('div', { class: 'fila' }); + f.append(el('span', { class: 'cant', text: e.cantidad + ' ' + (e.unidad || 'pza') }), + el('span', { class: 'que' }, el('b', { text: e.codigo }), ' ' + (e.descripcion || ''), + el('span', { class: 'mini', text: 'desde ' + corto(e.origen) + ' · ' + fechaC(e.cuando) + ' · ' + e.quien + (e.referencia ? ' · ' + e.referencia : '') }))); + const b = el('button', { type: 'button', text: 'Recibir' }); b.addEventListener('click', () => recibir(e)); + f.append(b); box.append(f); + }); + cont.append(box); + } + if (salen.length) { + const box = el('section', { class: 'inv-camino sale' }); + box.append(el('h3', { text: 'Salió de ' + corto(sid) + ' y va en camino' })); + salen.forEach(e => box.append(el('div', { class: 'fila' }, el('span', { class: 'cant', text: e.cantidad + ' ' + (e.unidad || 'pza') }), + el('span', { class: 'que' }, el('b', { text: e.codigo }), ' ' + (e.descripcion || ''), el('span', { class: 'mini', text: 'a ' + corto(e.destino) + ' · ' + fechaC(e.cuando) + ' · ' + e.quien + (e.referencia ? ' · ' + e.referencia : '') }))))); + cont.append(box); + } + + if (!filas.length) { + cont.append(el('div', { class: 'vacio inv-vacio' }, + el('p', { text: 'No hay material registrado en ' + corto(sid) + '.' }), + el('p', { text: 'Registra el conteo físico de lo que hay (Movimiento → Conteo físico) o, si va en camino, el envío desde el sitio de donde salió. ' + + 'Para ver qué falta, define el kit: en «Todo», Editar → «Por instalación».' }))); + return; + } + if (!kit.length) cont.append(el('p', { class: 'mini inv-sin-kit', text: 'Todavía no hay kit de instalación: en «Todo», Editar → «Por instalación» (cuánto lleva una unidad instalada). Con eso esta pestaña dice qué falta.' })); + const w = el('div', { class: 'tblwrap' }); const t = el('table', { class: 't inv-sitio' }); + t.innerHTML = 'CódigoDescripciónEn sitioEn camino' + + (sid === 'qro' ? '' : 'Por unidadPara ' + N + 'Kit') + ''; + filas.forEach(r => { + const tr = el('tr', { class: r.falta ? 'falta' : '' }); + const cod = el('span', { class: 'cod', text: r.i.codigo, title: 'Ver movimientos' }); cod.addEventListener('click', () => verMovimientos(r.i)); + tr.append(el('td', null, cod, el('span', { class: 'cat', text: CAT[r.i.categoria] || r.i.categoria }), el('span', { class: 'desc-m', text: r.i.descripcion }))); + tr.append(el('td', { class: 'c-desc' }, r.i.descripcion)); + tr.append(el('td', { class: 'num stock', html: esc(r.en) + ' ' + esc(r.i.unidad) + '' })); + tr.append(el('td', { class: 'num c-op', text: r.cam || '—' })); + if (sid !== 'qro') { + tr.append(el('td', { class: 'num c-op', text: r.k || '—' })); + tr.append(el('td', { class: 'num c-op', text: r.k ? r.nec : '—' })); + tr.append(el('td', null, estadoKit(r))); + } + const acc = el('td', { class: 'c-acc', style: 'white-space:nowrap' }); + const bm = el('button', { text: 'Movimiento', title: 'Movimiento de ' + r.i.codigo + ' en ' + corto(sid) }); bm.addEventListener('click', () => movimiento(r.i, { sitio: sid })); + acc.append(bm); tr.append(acc); + t.append(tr); + }); + w.append(t); cont.append(w); + } + + function pintar() { + pintarTabs(); + cont.innerHTML = ''; + $('fVer').style.display = $('fAlerta').parentElement.style.display = vista === 'todo' ? '' : 'none'; + $('inv-a-ins').href = '/tableros/instalaciones/' + (vista !== 'todo' && vista !== 'qro' ? '?sitio=' + encodeURIComponent(vista) : ''); + document.title = 'Inventario' + (vista !== 'todo' ? ' · ' + corto(vista) : '') + ' - Drogheda'; + if (vista === 'todo') pintarTodo(); else pintarSitio(vista); + const hora = cargadoEn ? new Date(cargadoEn).toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit', hour12: false }) : ''; + $('meta').textContent = pl(ITEMS.length, 'ítem', 'ítems') + ' · ' + ITEMS.filter(i => i.alerta).length + ' en alerta' + + (ENVIOS.length ? ' · ' + pl(ENVIOS.length, 'envío', 'envíos') + ' en camino' : '') + (hora ? ' · al día a las ' + hora : '') + (USER ? ' · ' + USER : ''); + } + + async function cargar() { + try { + const j = await api('/api/inventario'); + ITEMS = j.items || []; USER = j.user || ''; ENVIOS = j.envios || []; + conSitios = Array.isArray(j.sitios); + if (conSitios && j.sitios.length) SITIOS = j.sitios; + cargadoEn = Date.now(); + if (!conSitios && vista !== 'todo') mensaje('El servicio todavía no sabe de sitios: falta reiniciar actas-hbox en el servidor. Mientras tanto se ve el inventario completo.', false); + if (!conSitios) vista = 'todo'; + pintar(); + } catch (e) { + cont.innerHTML = ''; cont.append(el('div', { class: 'err', text: 'No se pudo cargar el inventario: ' + e.message })); + $('meta').textContent = 'Error'; + } + } + ['fCat', 'fVer', 'fAlerta'].forEach(id => $(id).addEventListener('change', pintar)); + $('fBuscar').addEventListener('input', pintar); + + // ---------------------------------------------------------------- diálogos + function abrirDialogo(titulo, cuerpoFn, botones) { + dlg.innerHTML = ''; + const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo })); + const x = el('button', { type: 'button', 'aria-label': 'Cerrar', html: icono('cerrar') || '×' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab); + const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo); + const m = el('div', { class: 'msg' }); + cuerpoFn(cuerpo, m); cuerpo.append(m); + const pie = el('div', { class: 'dpie' }); dlg.append(pie); + const cancelar = el('button', { type: 'button', class: 'sec', text: botones ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar); + (botones || []).forEach(([txt, fn]) => { + const b = el('button', { type: 'button', text: txt }); + b.addEventListener('click', async () => { b.disabled = true; try { await fn(m); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); + pie.append(b); + }); + if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', ''); + } + function selItems(sel) { + const s = el('select'); s.append(el('option', { value: '', text: '— elegir ítem —' })); + ITEMS.forEach(i => s.append(el('option', { value: i.id, text: i.codigo + ' — ' + i.descripcion + ' (total ' + i.stock + ' ' + i.unidad + ')' }))); + if (sel) s.value = sel.id; return s; + } + function selSitios(valor, vacio) { + const s = el('select'); + if (vacio) s.append(el('option', { value: '', text: '— elegir sitio —' })); + ORDEN.filter(id => SITIOS.some(x => x.id === id)).forEach(id => s.append(el('option', { value: id, text: sitio(id).nombre }))); + if (valor) s.value = valor; + return s; + } + + function movimiento(item, pre) { + pre = pre || {}; + const base = pre.sitio || (vista !== 'todo' ? vista : 'qro'); + abrirDialogo('Registrar movimiento', (c) => { + const fItem = selItems(item); + const fTipo = el('select'); TIPOS.forEach(([v, t]) => fTipo.append(el('option', { value: v, text: t }))); if (pre.tipo) fTipo.value = pre.tipo; + const fSitio = selSitios(base); + const fDest = selSitios(null, true); + const fCant = el('input', { type: 'number', min: '0', step: '1', inputmode: 'numeric', placeholder: '0' }); + const fMot = el('input', { type: 'text', maxlength: '200', placeholder: 'llegó pedido · instalación en RTG-12 · kit para EIT · conteo físico' }); + const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'HBOX5-E07-A010-R0006 · guía 1234 · OC-0042' }); + const hay = el('div', { class: 'ayuda' }); + const lSitio = campo('Sitio', fSitio), lDest = campo('A qué sitio va', fDest); + const oFuera = el('option', { value: FUERA.id, text: FUERA.nombre }); + const act = () => { + const t = fTipo.value; + if (t === 'envio' && !oFuera.parentNode) fSitio.append(oFuera); // solo un envío puede salir de fuera + if (t !== 'envio' && oFuera.parentNode) { if (fSitio.value === FUERA.id) fSitio.value = base; oFuera.remove(); } + const it = ITEMS.find(x => x.id === fItem.value), s = fSitio.value; + lSitio.querySelector('label').textContent = t === 'envio' ? 'Sale de' : 'Sitio'; + lDest.style.display = t === 'envio' ? '' : 'none'; + if (!conSitios) { lSitio.style.display = 'none'; } + if (s === FUERA.id) hay.textContent = 'Para una compra que viene en camino, o material que salió de un sitio antes de contarlo: no descuenta de ningún sitio y entra al total como «en camino» hasta que le den «Recibir».'; + else hay.textContent = it ? 'En ' + corto(s) + ' hay ' + enSitio(it, s) + ' ' + it.unidad + '.' + + (t === 'ajuste' ? ' Escribe lo que contaste: queda exactamente en ese número.' : (t === 'salida' || t === 'envio') ? ' No puede quedar en negativo.' : '') : ''; + }; + [fItem, fTipo, fSitio].forEach(x => x.addEventListener('change', act)); act(); + c.append(campo('Ítem', fItem), campo('Tipo', fTipo)); + const g = el('div', { class: 'dos-col' }); g.append(lSitio, lDest); c.append(g, hay); + const g2 = el('div', { class: 'dos-col' }); g2.append(campo('Cantidad', fCant), campo('Referencia (unidad, guía u orden)', fRef)); c.append(g2); + c.append(campo('Motivo (obligatorio)', fMot)); + c._leer = () => ({ item_id: fItem.value, tipo: fTipo.value, sitio: conSitios ? fSitio.value : undefined, destino: fTipo.value === 'envio' ? fDest.value : undefined, + cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() }); + setTimeout(() => (item ? fCant : fItem).focus(), 50); + }, [['Registrar', async () => { + const d = dlg.querySelector('.dcuerpo')._leer(); + if (!d.item_id) throw new Error('Elige el ítem.'); + if (d.tipo === 'envio' && !d.destino) throw new Error('Elige a qué sitio va el envío.'); + if (d.cantidad == null || isNaN(d.cantidad)) throw new Error('Escribe la cantidad.'); + if (!d.motivo) throw new Error('Escribe el motivo.'); + const j = await api('/api/inventario/movimiento', json('POST', d)); + const mv = j.movimiento || {}, it = j.item || {}; + dlg.close(); + if (d.tipo === 'envio') mensaje('Envío registrado: ' + d.cantidad + ' ' + it.unidad + ' de ' + it.codigo + ' van en camino a ' + corto(d.destino) + '. Quien lo reciba le da «Recibir» en la pestaña de su sitio.', true); + else mensaje('Movimiento registrado: en ' + corto(mv.sitio || 'qro') + ' quedan ' + (mv.en_sitio_despues != null ? mv.en_sitio_despues : it.stock) + ' ' + it.unidad + ' de ' + it.codigo + ' (total ' + it.stock + ').', true); + cargar(); + }]]); + } + + function recibir(e) { + abrirDialogo('Recibir envío', c => { + c.append(el('p', { class: 'sub', html: '' + esc(e.cantidad) + ' ' + esc(e.unidad || 'pza') + ' de ' + esc(e.codigo) + ' — ' + esc(e.descripcion || '') + + '
    desde ' + esc(corto(e.origen)) + ' hacia ' + esc(corto(e.destino)) + ', enviado el ' + esc(fmt(e.cuando)) + ' por ' + esc(e.quien) + (e.referencia ? ' · ' + esc(e.referencia) : '') })); + const fCant = el('input', { type: 'number', min: '0', max: String(e.cantidad), step: '1', inputmode: 'numeric', value: String(e.cantidad) }); + const fMot = el('input', { type: 'text', maxlength: '200', placeholder: 'llegó completo · una caja llegó abierta…' }); + const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'acta de recepción · guía' }); + c.append(campo('Cantidad que llegó', fCant, 'Si llegó menos, lo que falta baja del total y queda anotado con tu nombre.'), campo('Nota (opcional)', fMot), campo('Referencia (opcional)', fRef)); + c._leer = () => ({ tipo: 'recepcion', envio_id: e.id, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() }); + setTimeout(() => fCant.focus(), 50); + }, [['Recibir', async () => { + const d = dlg.querySelector('.dcuerpo')._leer(); + if (d.cantidad == null || isNaN(d.cantidad)) throw new Error('Escribe cuánto llegó.'); + const j = await api('/api/inventario/movimiento', json('POST', d)); + dlg.close(); + const falto = e.cantidad - d.cantidad; + mensaje('Recibido en ' + corto(e.destino) + ': ' + d.cantidad + ' ' + (j.item || {}).unidad + ' de ' + e.codigo + (falto > 0 ? '. Faltaron ' + falto + ' y bajaron del total.' : '.'), true); + cargar(); + }]]); + } + + function formItem(c, i) { + i = i || {}; + const f = {}; + f.codigo = el('input', { type: 'text', maxlength: '40', value: i.codigo || '', placeholder: 'GAB-IP67 · SOP-RTG', style: 'text-transform:uppercase' }); + f.descripcion = el('input', { type: 'text', maxlength: '200', value: i.descripcion || '' }); + f.categoria = el('select'); Object.entries(CAT).forEach(([k, v]) => f.categoria.append(el('option', { value: k, text: v }))); f.categoria.value = i.categoria || 'otro'; + f.unidad = el('select'); [['pza', 'pza (piezas)'], ['m', 'm (metros)']].forEach(([v, t]) => f.unidad.append(el('option', { value: v, text: t }))); f.unidad.value = i.unidad || 'pza'; + f.aplica = el('div', { class: 'lista-chk', style: 'max-height:none;display:flex;gap:14px;flex-wrap:wrap' }); + VERS.forEach(v => { const l = el('label', { style: 'margin:0' }); const ck = el('input', { type: 'checkbox', value: v }); ck.checked = (i.aplica_a || []).includes(v); l.append(ck, document.createTextNode(v)); f.aplica.append(l); }); + f.minimo = el('input', { type: 'number', min: '0', step: '1', value: i.minimo || 0 }); + f.por_instalacion = el('input', { type: 'number', min: '0', step: '1', value: i.por_instalacion || 0 }); + f.ubicacion = el('input', { type: 'text', maxlength: '120', value: i.ubicacion || '', placeholder: 'Anaquel A2 · bodega del puerto' }); + f.proveedor = el('input', { type: 'text', maxlength: '120', value: i.proveedor || '' }); + f.codigo_proveedor = el('input', { type: 'text', maxlength: '60', value: i.codigo_proveedor || '', placeholder: 'LCSC C2057152 · Mouser 709-SD15B05' }); + f.notas = el('textarea', { rows: '2' }); f.notas.value = i.notas || ''; + const g = el('div', { class: 'dos-col' }); + g.append(campo('Código (único)', f.codigo), campo('Categoría', f.categoria)); + c.append(g, campo('Descripción', f.descripcion), campo('Aplica a (versión de tarjeta)', f.aplica)); + const g2 = el('div', { class: 'dos-col' }); + g2.append(campo('Unidad', f.unidad), campo('Por instalación (0 = no es del kit)', f.por_instalacion, 'Cuánto lleva una unidad instalada.'), + campo('Mínimo del total (0 = sin alerta)', f.minimo), campo('Ubicación (anaquel, bodega)', f.ubicacion), campo('Proveedor', f.proveedor), campo('Código del proveedor', f.codigo_proveedor)); + c.append(g2); + if (!i.id) { + f.stock_inicial = el('input', { type: 'number', min: '0', step: '1', value: '0' }); + f.sitio = selSitios(vista !== 'todo' ? vista : 'qro'); + const g3 = el('div', { class: 'dos-col' }); g3.append(campo('Stock inicial (queda registrado como entrada)', f.stock_inicial), campo('En qué sitio está', f.sitio)); c.append(g3); + } + c.append(campo('Notas', f.notas)); + return () => { + const d = { codigo: f.codigo.value.trim().toUpperCase(), descripcion: f.descripcion.value.trim(), categoria: f.categoria.value, unidad: f.unidad.value, + aplica_a: [...f.aplica.querySelectorAll('input:checked')].map(x => x.value), minimo: +f.minimo.value || 0, por_instalacion: +f.por_instalacion.value || 0, + ubicacion: f.ubicacion.value.trim(), proveedor: f.proveedor.value.trim(), codigo_proveedor: f.codigo_proveedor.value.trim(), notas: f.notas.value.trim() }; + if (f.stock_inicial) { d.stock_inicial = +f.stock_inicial.value || 0; if (conSitios) d.sitio = f.sitio.value; } + return d; + }; + } + function nuevo() { + let leer; + abrirDialogo('Nuevo ítem', c => { leer = formItem(c); }, [['Crear ítem', async () => { + const d = leer(); if (!d.codigo) throw new Error('Escribe el código.'); if (!d.descripcion) throw new Error('Escribe la descripción.'); + await api('/api/inventario', json('POST', d)); dlg.close(); mensaje('Ítem ' + d.codigo + ' creado.', true); cargar(); + }]]); + } + function editar(i) { + let leer; + abrirDialogo('Editar ' + i.codigo, c => { + leer = formItem(c, i); + const h = el('div', { class: 'hist' }); + h.append(el('div', { html: 'Creado por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · total ' + esc(i.stock) + ' ' + esc(i.unidad) + ' (se cambia con movimientos, no aquí)' })); + (i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · ' + esc(x.quien) + ' · ' + esc(x.cambio) }))); + c.append(h); + }, [['Guardar', async () => { + const d = leer(); const cambios = {}; + Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(i[k] == null ? (Array.isArray(d[k]) ? [] : (typeof d[k] === 'number' ? 0 : '')) : i[k])) cambios[k] = d[k]; }); + if (Object.keys(cambios).length) await api('/api/inventario/item?id=' + encodeURIComponent(i.id), json('PATCH', cambios)); + dlg.close(); mensaje('Ítem guardado.', true); cargar(); + }]]); + } + + function tipoTexto(m) { + const s = corto(m.sitio || 'qro'); + if (m.tipo === 'envio') return 'envío ' + (m.sitio === FUERA.id ? 'de fuera del inventario' : s) + ' → ' + corto(m.destino) + (m.estado === 'en-camino' ? ' · en camino' : ' · recibido' + (m.recibido != null && m.recibido !== m.cantidad ? ' ' + m.recibido : '')); + if (m.tipo === 'recepcion') return 'recepción en ' + s + (m.origen ? ' (de ' + corto(m.origen) + ')' : ''); + if (m.tipo === 'ajuste') return 'conteo en ' + s + ' → ' + m.cantidad; + return m.tipo + ' en ' + s; + } + async function verMovimientos(i) { + abrirDialogo('Movimientos de ' + i.codigo, async c => { + c.append(el('p', { class: 'sub', html: esc(i.descripcion) + ' · total ' + esc(i.stock) + ' ' + esc(i.unidad) + '' + (i.minimo ? ' · mínimo ' + esc(i.minimo) : '') })); + c.append(chipsDonde(i)); + const lista = el('div', { text: 'Cargando…', class: 'inv-movs' }); c.append(lista); + const b = el('button', { html: icono('mas') + 'Movimiento', style: 'margin-top:10px' }); b.addEventListener('click', () => movimiento(i)); c.append(b); + try { + const j = await api('/api/inventario/movimientos?item_id=' + encodeURIComponent(i.id)); + lista.innerHTML = ''; + if (!j.total) { lista.append(el('div', { class: 'vacio', text: 'Sin movimientos. Está en 0 hasta que registres una entrada o un conteo físico.' })); return; } + lista.append(el('div', { class: 'mov-fila', style: 'font-weight:700;color:var(--hg-texto-2);font-size:11.5px;text-transform:uppercase' }, + el('span', { text: 'Cuándo · quién' }), el('span', { text: 'Qué y dónde' }), el('span', { text: 'Δ · queda' }), el('span', { text: 'Motivo · referencia' }))); + j.movimientos.forEach(m => { + const ds = m.delta_sitio != null ? m.delta_sitio : m.delta, queda = m.en_sitio_despues != null ? m.en_sitio_despues : m.stock_despues; + lista.append(el('div', { class: 'mov-fila' }, + el('span', { html: esc(fmt(m.cuando)) + '
    ' + esc(m.quien) + '' }), + el('span', { text: tipoTexto(m) }), + el('span', { class: 'd', html: m.tipo === 'envio' && m.sitio === FUERA.id ? '+' + esc(m.cantidad) + ' · en camino' + : '' + (ds >= 0 ? '+' : '') + esc(ds) + ' · ' + esc(queda) }), + el('span', { html: esc(m.motivo) + (m.referencia ? '
    ref. ' + esc(m.referencia) + '' : '') }))); + }); + } catch (e) { lista.textContent = 'No se pudieron leer los movimientos: ' + e.message; } + }); + } + + $('bMov').addEventListener('click', () => movimiento(null)); + $('bNuevo').addEventListener('click', nuevo); + + const qs = new URLSearchParams(location.search); + const q = (qs.get('sitio') || '').trim().toLowerCase(); + vista = q ? (ALIAS[q] || q) : 'todo'; + if (!ORDEN.includes(vista)) vista = 'todo'; + N = Math.min(50, Math.max(1, parseInt(qs.get('n'), 10) || 2)); + document.addEventListener('visibilitychange', () => { if (!document.hidden && cargadoEn && Date.now() - cargadoEn > 60000) cargar(); }); + cargar(); +})(); diff --git a/docs/tableros/inventario.md b/docs/tableros/inventario.md index b9256c5..f12dc96 100644 --- a/docs/tableros/inventario.md +++ b/docs/tableros/inventario.md @@ -1,29 +1,33 @@ --- title: "Inventario" -description: "Existencias y movimientos de materiales: entradas, salidas, ajustes y alertas de mínimo." +description: "Material por sitio: lo que hay en cada puerto y en Querétaro, lo que va en camino y lo que falta para instalar." template: tablero.html hide: - toc --- -
    + + +

    Inventario

    Cargando…
    - +
    Cómo se usa
      -
    1. El stock no se escribe a mano: es la suma de los movimientos. Llegó material → Entrada. Se usó en un ensamble o se envió → Salida con la unidad o el acta como referencia.
    2. -
    3. Contaste y no cuadra → Ajuste con el conteo físico; el sistema calcula la diferencia y la deja registrada con tu nombre.
    4. -
    5. Toca el código para ver los movimientos de ese ítem. Cada uno dice quién, cuándo, cuánto y por qué.
    6. -
    7. Alerta (rojo) = stock por debajo del mínimo. El mínimo lo pone quien compra; el tablero de producción lo repite.
    8. -
    9. Nuevo ítem solo para cosas que se cuentan: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. Nada de resistencias.
    10. -
    11. Exportar CSV baja la tabla para Excel. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.
    12. +
    13. Una pestaña por sitio (ICAVE, TIMSA, EIT, LCT y Querétaro): lo que hay ahí, lo que va en camino y, para el material de instalación, cuánto falta para instalar las unidades que pongas arriba. Todo es el inventario completo, con el total y dónde está cada cosa.
    14. +
    15. El stock no se escribe a mano: es la suma de los movimientos de cada sitio. Llegó material → Entrada en ese sitio. Se instaló, se usó o se dio de baja → Salida, con la unidad o el acta como referencia. Contaste y no cuadra → Conteo físico: lo que hay en el sitio queda exactamente en ese número.
    16. +
    17. Material que va de un sitio a otro → Envío: sale del sitio de origen y queda en camino. Cuando llega, quien lo recibe abre la pestaña de su sitio y le da Recibir con lo que llegó de verdad. Una compra que viene en camino, o material que salió de un puerto antes de contarlo, es un envío que sale de «Proveedor o fuera del inventario».
    18. +
    19. Kit de instalación: en Editar, «Por instalación» dice cuánto lleva una unidad instalada (un gabinete, dos antenas LTE…). Con eso cada sitio dice qué falta.
    20. +
    21. Toca el código para ver los movimientos de ese ítem: quién, cuándo, dónde, cuánto y por qué. Alerta (rojo) = el total está por debajo del mínimo.
    22. +
    23. Nuevo ítem solo para cosas que se cuentan; lo de la instalación (soportes, tornillería, cable de potencia) va con categoría Instalación. Exportar CSV baja la tabla con una columna por sitio. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.
    + +
    @@ -35,185 +39,9 @@ hide:
    -
    +
    - +