Inventario por sitio: pestañas, envíos en camino y kit por instalación

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X4CAujMuib2tDqt9JRg6X
This commit is contained in:
Leopoldo Rodríguez 2026-10-23 13:33:51 -06:00
parent b9ea0d688e
commit 02521e14f4
14 changed files with 925 additions and 240 deletions

View File

@ -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=&<clave>=` 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 `<li><b>DD-mmm (hora)</b> — …</li>` 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 |

View File

@ -419,6 +419,46 @@ mv _borrados/<id>__<fecha>.json <id>.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

View File

@ -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
```

View File

@ -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"

View File

@ -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." }
]
}

66
backend/tests-1023.sh Normal file
View File

@ -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 <ruta-a-hbox-docs> [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"

View File

@ -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).

View File

@ -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.

View File

@ -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.

View File

@ -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();
}

View File

@ -13,7 +13,7 @@ hide:
<div class="tb tb-ins" id="ins">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Instalaciones</h1><span class="meta" id="ins-meta">Cargando…</span></div>
<div class="tb-cab__acc"><a class="boton sec" id="ins-a-inst" href="/formularios/instalacion.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5zM14 3.5v5h5M9 13h6M9 16.5h4"/></svg>Acta de instalación</a><a class="boton sec" id="ins-a-prueba" href="/formularios/prueba-sitio.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM8.5 12.2l2.4 2.4 4.6-4.8"/></svg>Prueba en sitio</a><button type="button" class="fantasma" id="ins-actualizar" title="Volver a leer las actas"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/></svg>Actualizar</button></div>
<div class="tb-cab__acc"><a class="boton sec" id="ins-a-inst" href="/formularios/instalacion.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5zM14 3.5v5h5M9 13h6M9 16.5h4"/></svg>Acta de instalación</a><a class="boton sec" id="ins-a-prueba" href="/formularios/prueba-sitio.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM8.5 12.2l2.4 2.4 4.6-4.8"/></svg>Prueba en sitio</a><a class="boton sec" id="ins-a-mat" href="/tableros/inventario/" title="Material de instalación de cada sitio"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5 20 8v8l-8 4.5L4 16V8zM4 8l8 4.5L20 8M12 12.5v8"/></svg>Material</a><button type="button" class="fantasma" id="ins-actualizar" title="Volver a leer las actas"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/></svg>Actualizar</button></div>
</header>
<details class="info tb-como">

View File

@ -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; }
}

446
docs/tableros/inventario.js Normal file
View File

@ -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 = '<tr><th>Código</th><th>Descripción</th><th>Aplica a</th><th class="num">Total</th><th>Dónde está</th><th class="num">Mínimo</th><th class="num" title="Cuánto lleva una unidad instalada">Kit</th><th></th></tr>';
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) + ' <span class="mini">' + esc(i.unidad) + '</span>' + (i.alerta ? ' <span class="pill falla">alerta</span>' : '') }));
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 = '<tr><th>Código</th><th class="c-desc">Descripción</th><th class="num">En sitio</th><th class="num c-op">En camino</th>' +
(sid === 'qro' ? '' : '<th class="num c-op" title="Cuánto lleva una unidad instalada">Por unidad</th><th class="num c-op">Para ' + N + '</th><th>Kit</th>') + '<th class="c-acc"></th></tr>';
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) + ' <span class="mini">' + esc(r.i.unidad) + '</span>' }));
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: '<b>' + esc(e.cantidad) + ' ' + esc(e.unidad || 'pza') + '</b> de ' + esc(e.codigo) + ' — ' + esc(e.descripcion || '') +
'<br>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: '<b>Creado</b> por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · total <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b> (se cambia con movimientos, no aquí)' }));
(i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + 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 <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b>' + (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)) + '<br><b>' + esc(m.quien) + '</b>' }),
el('span', { text: tipoTexto(m) }),
el('span', { class: 'd', html: m.tipo === 'envio' && m.sitio === FUERA.id ? '<span class="pos">+' + esc(m.cantidad) + '</span> · en camino'
: '<span class="' + (ds >= 0 ? 'pos' : 'neg') + '">' + (ds >= 0 ? '+' : '') + esc(ds) + '</span> · ' + esc(queda) }),
el('span', { html: esc(m.motivo) + (m.referencia ? '<br><span class="mini">ref. ' + esc(m.referencia) + '</span>' : '') })));
});
} 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();
})();

View File

@ -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
---
<div class="tb tb-inventario">
<link rel="stylesheet" href="/tableros/inventario.css">
<div class="tb tb-inventario tb-inv">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Inventario</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc"><a class="boton sec" href="/inventario/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4.5h11a3 3 0 0 1 3 3v12.5H8a3 3 0 0 1-3-3zM5 17a3 3 0 0 1 3-3h11"/></svg>Política de inventario</a></div>
<div class="tb-cab__acc"><a class="boton sec" id="inv-a-ins" href="/tableros/instalaciones/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 15.5V9.8l2.3-3.3h6.7v9M12.5 9.5h4.2l3.8 3.4v2.6M6.5 18.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM16.5 18.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM8.5 16.5h6"/></svg>Instalaciones</a><a class="boton sec" href="/inventario/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4.5h11a3 3 0 0 1 3 3v12.5H8a3 3 0 0 1-3-3zM5 17a3 3 0 0 1 3-3h11"/></svg>Política de inventario</a></div>
</header>
<details class="info tb-como">
<summary>Cómo se usa</summary>
<ol>
<li>El <b>stock no se escribe a mano</b>: es la suma de los movimientos. Llegó material → <b>Entrada</b>. Se usó en un ensamble o se envió → <b>Salida</b> con la unidad o el acta como referencia.</li>
<li>Contaste y no cuadra → <b>Ajuste</b> con el conteo físico; el sistema calcula la diferencia y la deja registrada con tu nombre.</li>
<li>Toca el <b>código</b> para ver los movimientos de ese ítem. Cada uno dice quién, cuándo, cuánto y por qué.</li>
<li><b>Alerta</b> (rojo) = stock por debajo del mínimo. El mínimo lo pone quien compra; el tablero de producción lo repite.</li>
<li><b>Nuevo ítem</b> solo para cosas que se cuentan: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. Nada de resistencias.</li>
<li><b>Exportar CSV</b> baja la tabla para Excel. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.</li>
<li><b>Una pestaña por sitio</b> (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. <b>Todo</b> es el inventario completo, con el total y dónde está cada cosa.</li>
<li>El <b>stock no se escribe a mano</b>: es la suma de los movimientos de cada sitio. Llegó material → <b>Entrada</b> en ese sitio. Se instaló, se usó o se dio de baja → <b>Salida</b>, con la unidad o el acta como referencia. Contaste y no cuadra → <b>Conteo físico</b>: lo que hay en el sitio queda exactamente en ese número.</li>
<li>Material que va de un sitio a otro → <b>Envío</b>: sale del sitio de origen y queda <b>en camino</b>. Cuando llega, quien lo recibe abre la pestaña de su sitio y le da <b>Recibir</b> 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 <b>«Proveedor o fuera del inventario»</b>.</li>
<li><b>Kit de instalación</b>: en <b>Editar</b>, «Por instalación» dice cuánto lleva una unidad instalada (un gabinete, dos antenas LTE…). Con eso cada sitio dice qué falta.</li>
<li>Toca el <b>código</b> para ver los movimientos de ese ítem: quién, cuándo, dónde, cuánto y por qué. <b>Alerta</b> (rojo) = el total está por debajo del mínimo.</li>
<li><b>Nuevo ítem</b> solo para cosas que se cuentan; lo de la instalación (soportes, tornillería, cable de potencia) va con categoría <b>Instalación</b>. <b>Exportar CSV</b> baja la tabla con una columna por sitio. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.</li>
</ol>
</details>
<nav class="inv-tabs" id="inv-tabs" aria-label="Sitio"></nav>
<div class="acciones">
<select id="fCat"><option value="">Todas las categorías</option></select>
<select id="fVer"><option value="">Aplica a: todas</option></select>
@ -35,185 +39,9 @@ hide:
<span class="cnt" id="cnt"></span>
</div>
<div class="msg" id="aviso"></div>
<div id="cont" class="tblwrap"></div>
<div id="cont"></div>
<dialog id="dlg"></dialog>
</div>
<script>
(function () {
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { 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(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 $ = id => document.getElementById(id);
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', 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' };
let ITEMS = [], ADMIN = false, USER = '';
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
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) {} 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)));
}
function pintar() {
const cont = $('cont'); cont.innerHTML = '';
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» o corre la siembra.' })); return; }
const t = el('table', { class: 't' });
t.innerHTML = '<tr><th>Código</th><th>Descripción</th><th>Aplica a</th><th class="num">Stock</th><th class="num">Mínimo</th><th>Ubicación</th><th>Proveedor</th><th></th></tr>';
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 })));
tr.append(el('td', null, document.createTextNode(i.descripcion), i.notas ? el('div', { class: 'mini', text: i.notas.length > 90 ? i.notas.slice(0, 89) + '…' : i.notas }) : null));
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) + ' <span class="mini">' + esc(i.unidad) + '</span>' + (i.alerta ? ' <span class="pill falla">alerta</span>' : '') }));
tr.append(el('td', { class: 'num', text: i.minimo || '—' }));
tr.append(el('td', { text: i.ubicacion || '—' }));
tr.append(el('td', { html: esc(i.proveedor || '—') + (i.codigo_proveedor ? '<div class="mini">' + esc(i.codigo_proveedor) + '</div>' : '') }));
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);
});
cont.append(t);
}
async function cargar() {
$('meta').textContent = 'Actualizando…';
try {
const j = await api('/api/inventario');
ITEMS = j.items || []; ADMIN = !!j.admin; USER = j.user || '';
pintar();
$('meta').textContent = ITEMS.length + ' ítems · ' + (j.alertas || 0) + ' en alerta · actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + (USER ? ' · ' + USER : '');
} 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: Kanban.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 + ' (' + i.stock + ' ' + i.unidad + ')' })));
if (sel) s.value = sel.id; return s;
}
function movimiento(item) {
abrirDialogo('Registrar movimiento', (c) => {
const fItem = selItems(item);
const fTipo = el('select'); [['entrada', 'Entrada — llegó material (+)'], ['salida', 'Salida — se usó o se envió (−)'], ['ajuste', 'Ajuste — conteo físico (fija el stock)']].forEach(([v, t]) => fTipo.append(el('option', { value: v, text: t })));
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 Mouser · ensamble · envío a ICAVE · conteo físico' });
const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'HBOX5-E07-A010-R0006 · OC-0042 · acta de envío' });
const ayudaCant = el('div', { class: 'ayuda', text: 'Cantidad que entra.' });
fTipo.addEventListener('change', () => { ayudaCant.textContent = fTipo.value === 'entrada' ? 'Cantidad que entra.' : fTipo.value === 'salida' ? 'Cantidad que sale. No puede dejar el stock en negativo.' : 'Lo que contaste físicamente: el stock quedará exactamente en ese número.'; });
c.append(campo('Ítem', fItem), campo('Tipo', fTipo));
const g = el('div', { class: 'dos-col' }); const cc = campo('Cantidad', fCant); cc.append(ayudaCant); g.append(cc, campo('Referencia (unidad, acta u orden)', fRef)); c.append(g);
c.append(campo('Motivo (obligatorio)', fMot));
c._leer = () => ({ item_id: fItem.value, tipo: fTipo.value, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() });
setTimeout(() => (item ? fCant : fItem).focus(), 50);
}, [['Registrar', async (m) => {
const d = dlg.querySelector('.dcuerpo')._leer();
if (!d.item_id) throw new Error('Elige el ítem.');
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));
dlg.close(); mensaje('Movimiento registrado: ' + j.item.codigo + ' ahora tiene ' + j.item.stock + ' ' + j.item.unidad + '.', true); cargar();
}]]);
}
function formItem(c, i) {
i = i || {};
const f = {};
f.codigo = el('input', { type: 'text', maxlength: '40', value: i.codigo || '', placeholder: 'EB-V56 · SD-15B-05', 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.ubicacion = el('input', { type: 'text', maxlength: '120', value: i.ubicacion || '', placeholder: 'Anaquel A2 · taller Veracruz' });
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('Mínimo (0 = sin alerta)', f.minimo), campo('Ubicación', 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' }); c.append(campo('Stock inicial (queda registrado como entrada)', f.stock_inicial)); }
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, 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;
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: '<b>Creado</b> por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · stock actual <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b> (se cambia con movimientos, no aquí)' }));
(i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + 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();
}]]);
}
async function verMovimientos(i) {
abrirDialogo('Movimientos de ' + i.codigo, async c => {
c.append(el('p', { class: 'sub', html: esc(i.descripcion) + ' · stock <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b>' + (i.minimo ? ' · mínimo ' + esc(i.minimo) : '') }));
const lista = el('div', { text: 'Cargando…' }); c.append(lista);
const b = el('button', { html: Kanban.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. El stock es 0 hasta que registres una entrada o un ajuste.' })); return; }
lista.append(el('div', { class: 'mov-fila', style: 'font-weight:700;color:var(--tinta2);font-size:11.5px;text-transform:uppercase' }, el('span', { text: 'Cuándo · quién' }), el('span', { text: 'Tipo' }), el('span', { text: 'Δ · stock' }), el('span', { text: 'Motivo · referencia' })));
j.movimientos.forEach(m => {
lista.append(el('div', { class: 'mov-fila' },
el('span', { html: esc(fmt(m.cuando)) + '<br><b>' + esc(m.quien) + '</b>' }),
el('span', { text: m.tipo + (m.tipo === 'ajuste' ? ' → ' + m.cantidad : '') }),
el('span', { class: 'd', html: '<span class="' + (m.delta >= 0 ? 'pos' : 'neg') + '">' + (m.delta >= 0 ? '+' : '') + esc(m.delta) + '</span> · ' + esc(m.stock_despues) }),
el('span', { html: esc(m.motivo) + (m.referencia ? '<br><span class="mini">ref. ' + esc(m.referencia) + '</span>' : '') })));
});
} catch (e) { lista.textContent = 'No se pudieron leer los movimientos: ' + e.message; }
});
}
$('bMov').addEventListener('click', () => movimiento(null));
$('bNuevo').addEventListener('click', nuevo);
cargar();
})();
</script>
<script src="/tableros/inventario.js"></script>