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:
parent
b9ea0d688e
commit
02521e14f4
@ -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 |
|
| **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` |
|
| **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`) |
|
| **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 |
|
| **Ú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` |
|
| **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 |
|
| **Documentos recientes** (home) | automatic from `date:` front matter (`hooks/documentos.py`) | — | give every new page a `date:`; pages under «Historia» are left out |
|
||||||
|
|||||||
@ -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 |
|
| 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` |
|
| «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
|
## B · Verificación después de desplegar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -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,
|
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.
|
`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
|
```bash
|
||||||
curl -s -u $U "$D/api/inventario?categoria=fuente&aplica_a=V5.6&alertas=1" # alerta = stock < minimo
|
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 \
|
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"}'
|
-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 \
|
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"}'
|
-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 "$D/api/inventario/movimientos?item_id=EB-V56&n=50"
|
||||||
curl -s -u $U -o inventario.csv $D/api/inventario/export.csv
|
curl -s -u $U -o inventario.csv $D/api/inventario/export.csv
|
||||||
```
|
```
|
||||||
|
|||||||
208
backend/app.py
208
backend/app.py
@ -33,7 +33,10 @@ autenticado crea y modifica, solo un administrador borra:
|
|||||||
(DELETE /api/tarea: administradores y TAREAS_BORRAN, por omisión adriana y gibranlp; el pendiente pasa a tareas/_borrados/)
|
(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 /api/plan · PUT /api/plan (admin)
|
||||||
GET/POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento
|
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=
|
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.
|
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)
|
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")
|
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
|
||||||
VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—")
|
VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—")
|
||||||
CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable",
|
CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable",
|
||||||
"conector", "componente-critico", "otro")
|
"conector", "componente-critico", "instalacion", "otro")
|
||||||
UNIDADES_INV = ("pza", "m")
|
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 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")
|
TIPOS_SITIO = ("envio", "recepcion", "instalacion-campo", "diagnostico-campo", "dano")
|
||||||
FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
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_ITEMS = lambda: os.path.join(INVENTARIO_DIR, "items")
|
||||||
INV_MOVS = lambda: os.path.join(INVENTARIO_DIR, "movimientos")
|
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"]
|
"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):
|
def _item_valido(d, base=None):
|
||||||
@ -1658,6 +1669,8 @@ def _item_valido(d, base=None):
|
|||||||
t["unidad"] = un
|
t["unidad"] = un
|
||||||
if alta or "minimo" in d:
|
if alta or "minimo" in d:
|
||||||
t["minimo"] = max(0, _entero(d.get("minimo"), 0))
|
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)):
|
for k, n in (("ubicacion", 120), ("proveedor", 120), ("codigo_proveedor", 60), ("notas", 1000)):
|
||||||
if alta or k in d:
|
if alta or k in d:
|
||||||
t[k] = _texto(d.get(k), n)
|
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))
|
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")
|
@app.get("/api/inventario")
|
||||||
def inventario_listar():
|
def inventario_listar():
|
||||||
cat = _texto(request.args.get("categoria"), 30).lower()
|
cat = _texto(request.args.get("categoria"), 30).lower()
|
||||||
aplica = _texto(request.args.get("aplica_a"), 10)
|
aplica = _texto(request.args.get("aplica_a"), 10)
|
||||||
solo_alertas = request.args.get("alertas") == "1"
|
solo_alertas = request.args.get("alertas") == "1"
|
||||||
items = []
|
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)
|
it["alerta"] = bool(it.get("minimo", 0)) and it.get("stock", 0) < it.get("minimo", 0)
|
||||||
if cat and it.get("categoria") != cat:
|
if cat and it.get("categoria") != cat:
|
||||||
continue
|
continue
|
||||||
@ -1701,6 +1771,8 @@ def inventario_listar():
|
|||||||
x.get("codigo", "")))
|
x.get("codigo", "")))
|
||||||
return jsonify(ok=True, total=len(items), alertas=sum(1 for x in items if x["alerta"]), items=items,
|
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,
|
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())
|
admin=is_admin(), user=current_user())
|
||||||
|
|
||||||
|
|
||||||
@ -1730,9 +1802,12 @@ def inventario_crear():
|
|||||||
t["modificado_en"] = t["creado_en"]
|
t["modificado_en"] = t["creado_en"]
|
||||||
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "ítem creado"}]
|
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "ítem creado"}]
|
||||||
stock_inicial = _entero(d.get("stock_inicial"), 0)
|
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)
|
_guardar_item(t)
|
||||||
if stock_inicial:
|
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
|
return jsonify(ok=True, item=t), 201
|
||||||
|
|
||||||
|
|
||||||
@ -1768,20 +1843,40 @@ def inventario_modificar():
|
|||||||
return jsonify(ok=True, item=t)
|
return jsonify(ok=True, item=t)
|
||||||
|
|
||||||
|
|
||||||
def _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u):
|
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."""
|
"""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"])
|
actual = _stock_desde_movimientos(item["id"])
|
||||||
|
en_sitio = 0 if sitio == ORIGEN_FUERA else _stock_en_sitio(item["id"], sitio)
|
||||||
if tipo == "entrada":
|
if tipo == "entrada":
|
||||||
delta = cantidad
|
delta = delta_sitio = cantidad
|
||||||
elif tipo == "salida":
|
elif tipo == "salida":
|
||||||
delta = -cantidad
|
delta = delta_sitio = -cantidad
|
||||||
else: # ajuste: 'cantidad' es el conteo físico; el delta es lo que falta para llegar a él
|
elif tipo == "ajuste": # 'cantidad' es el conteo físico EN ESE SITIO; el delta es lo que falta para llegar a él
|
||||||
delta = cantidad - actual
|
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],
|
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,
|
"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(),
|
"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)
|
_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)
|
_reescribir_indice(INV_MOVS(), _listar_json(INV_MOVS()), COLS_MOV)
|
||||||
item["stock"] = _stock_desde_movimientos(item["id"])
|
item["stock"] = _stock_desde_movimientos(item["id"])
|
||||||
item["modificado_por"] = u
|
item["modificado_por"] = u
|
||||||
@ -1798,29 +1893,71 @@ def inventario_movimiento():
|
|||||||
d = _json_body()
|
d = _json_body()
|
||||||
if d is None:
|
if d is None:
|
||||||
return jsonify(ok=False, error="JSON inválido"), 400
|
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
|
item = _leer_json(_ruta_registro(INV_ITEMS(), rid)) if rid else None
|
||||||
if not item:
|
if not item:
|
||||||
return jsonify(ok=False, error=f"No existe el ítem {rid}."), 404
|
return jsonify(ok=False, error=f"No existe el ítem {rid}."), 404
|
||||||
tipo = _texto(d.get("tipo"), 10).lower()
|
if tipo == "recepcion" and d.get("cantidad") in (None, ""):
|
||||||
if tipo not in TIPOS_MOV:
|
cantidad = _entero(envio.get("cantidad"), 0)
|
||||||
return jsonify(ok=False, error="Tipo de movimiento inválido: entrada, salida o ajuste."), 400
|
else:
|
||||||
try:
|
try:
|
||||||
cantidad = int(d.get("cantidad"))
|
cantidad = int(d.get("cantidad"))
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify(ok=False, error="La cantidad debe ser un número entero."), 400
|
return jsonify(ok=False, error="La cantidad debe ser un número entero."), 400
|
||||||
if tipo != "ajuste" and cantidad <= 0:
|
if tipo in ("entrada", "salida", "envio") and cantidad <= 0:
|
||||||
return jsonify(ok=False, error="La cantidad debe ser mayor que cero."), 400
|
return jsonify(ok=False, error="La cantidad debe ser mayor que cero."), 400
|
||||||
if tipo == "ajuste" and cantidad < 0:
|
if tipo in ("ajuste", "recepcion") and cantidad < 0:
|
||||||
return jsonify(ok=False, error="El conteo físico no puede ser negativo."), 400
|
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)
|
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:
|
if not motivo:
|
||||||
return jsonify(ok=False, error="Escribe el motivo del movimiento."), 400
|
return jsonify(ok=False, error="Escribe el motivo del movimiento."), 400
|
||||||
referencia = _texto(d.get("referencia"), 120)
|
referencia = _texto(d.get("referencia"), 120)
|
||||||
if tipo == "salida" and _stock_desde_movimientos(rid) - cantidad < 0:
|
if tipo in ("salida", "envio") and sitio != ORIGEN_FUERA:
|
||||||
return jsonify(ok=False, error=f"No hay stock suficiente registrado ({_stock_desde_movimientos(rid)} {item.get('unidad')}). "
|
hay = _stock_en_sitio(rid, sitio)
|
||||||
"Registra primero la entrada o haz un ajuste con el conteo físico."), 409
|
if hay - cantidad < 0:
|
||||||
m = _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u)
|
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
|
return jsonify(ok=True, movimiento=m, item=item), 201
|
||||||
|
|
||||||
|
|
||||||
@ -1828,7 +1965,12 @@ def inventario_movimiento():
|
|||||||
def inventario_movimientos():
|
def inventario_movimientos():
|
||||||
rid = slug(request.args.get("item_id"), 40)
|
rid = slug(request.args.get("item_id"), 40)
|
||||||
n = min(500, max(1, _entero(request.args.get("n"), 200)))
|
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", ""))
|
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()
|
movs.reverse()
|
||||||
return jsonify(ok=True, total=len(movs), movimientos=movs[:n])
|
return jsonify(ok=True, total=len(movs), movimientos=movs[:n])
|
||||||
|
|
||||||
@ -1838,12 +1980,16 @@ def inventario_export():
|
|||||||
import io
|
import io
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
w = csv.writer(buf)
|
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"])
|
"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", ""))):
|
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)
|
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 []),
|
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("ubicacion"), it.get("proveedor"), it.get("codigo_proveedor"), it.get("notas"),
|
||||||
it.get("modificado_por"), it.get("modificado_en")])
|
it.get("modificado_por"), it.get("modificado_en")])
|
||||||
nombre = "inventario-hbox-" + datetime.date.today().isoformat() + ".csv"
|
nombre = "inventario-hbox-" + datetime.date.today().isoformat() + ".csv"
|
||||||
|
|||||||
47
backend/lotes/2026-10-23_inventario-sitios.json
Normal file
47
backend/lotes/2026-10-23_inventario-sitios.json
Normal 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
66
backend/tests-1023.sh
Normal 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"
|
||||||
@ -23,6 +23,7 @@ Arriba, lo que pasó en el tablero de pendientes y en las actas en los últimos
|
|||||||
### Octubre de 2026
|
### Octubre de 2026
|
||||||
|
|
||||||
**23 oct**
|
**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.
|
: 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.
|
: [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).
|
: [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).
|
||||||
|
|||||||
@ -1,22 +1,23 @@
|
|||||||
---
|
---
|
||||||
title: "Inventario"
|
title: "Política de inventario por sitio"
|
||||||
date: 2026-10-13
|
date: 2026-10-23
|
||||||
tags: [project-drogheda, hbox, inventario, produccion, compras]
|
tags: [project-drogheda, hbox, inventario, produccion, compras, instalacion]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Inventario de materiales
|
# 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í
|
## Qué hay aquí
|
||||||
|
|
||||||
| Página | Para qué |
|
| 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 |
|
| [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
|
## 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 |
|
| 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 |
|
| 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 | — |
|
| 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 | — |
|
| 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
|
## Quién registra y cuándo
|
||||||
|
|
||||||
| Qué pasó | Quién | Movimiento |
|
| 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 |
|
| 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 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 |
|
| 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 |
|
||||||
| Sale un kit al puerto | Quien llena el acta de envío | **Salida** de lo que va en la caja (fuente, antenas, gabinete) |
|
| 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 |
|
||||||
| 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») |
|
| 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 |
|
||||||
| Pieza dañada o de baja | Quien llena el acta de daño | **Salida** con el motivo y la serie |
|
| 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
|
## 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.
|
- 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).
|
- 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
|
## Para ingeniería
|
||||||
|
|
||||||
- Datos en `/srv/projects/drogheda/inventario/{items,movimientos}/` (un JSON por registro + `_indice.csv`).
|
- 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.
|
- Nada se borra. Un ítem que ya no aplica se marca «(obsoleto)» en la descripción y con mínimo 0.
|
||||||
|
|||||||
@ -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 |
|
| **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 |
|
| **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é **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 |
|
| 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é 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 |
|
| 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).
|
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)).
|
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.
|
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.
|
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
|
## 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.
|
- [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.
|
- [Bitácora](../bitacora.md): los documentos por fecha.
|
||||||
- [Historia del proyecto](../historia/index.md): jun → oct 2026 con ligas.
|
- [Historia del proyecto](../historia/index.md): jun → oct 2026 con ligas.
|
||||||
|
|||||||
@ -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-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-inst').href = forma('instalacion', s ? { sitio: s.id } : null);
|
||||||
$('ins-a-prueba').href = forma('prueba', 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';
|
document.title = 'Instalaciones' + (s ? ' · ' + s.nombre : '') + ' - Drogheda';
|
||||||
if (s) pintarSitio(s.id); else pintarResumen();
|
if (s) pintarSitio(s.id); else pintarResumen();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ hide:
|
|||||||
<div class="tb tb-ins" id="ins">
|
<div class="tb tb-ins" id="ins">
|
||||||
<header class="tb-cab">
|
<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__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>
|
</header>
|
||||||
|
|
||||||
<details class="info tb-como">
|
<details class="info tb-como">
|
||||||
|
|||||||
63
docs/tableros/inventario.css
Normal file
63
docs/tableros/inventario.css
Normal 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
446
docs/tableros/inventario.js
Normal 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();
|
||||||
|
})();
|
||||||
@ -1,29 +1,33 @@
|
|||||||
---
|
---
|
||||||
title: "Inventario"
|
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
|
template: tablero.html
|
||||||
hide:
|
hide:
|
||||||
- toc
|
- toc
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="tb tb-inventario">
|
<link rel="stylesheet" href="/tableros/inventario.css">
|
||||||
|
|
||||||
|
<div class="tb tb-inventario tb-inv">
|
||||||
<header class="tb-cab">
|
<header class="tb-cab">
|
||||||
<div class="tb-cab__t"><h1>Inventario</h1><span class="meta" id="meta">Cargando…</span></div>
|
<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>
|
</header>
|
||||||
|
|
||||||
<details class="info tb-como">
|
<details class="info tb-como">
|
||||||
<summary>Cómo se usa</summary>
|
<summary>Cómo se usa</summary>
|
||||||
<ol>
|
<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><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>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>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>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>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>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>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><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>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>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>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>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<nav class="inv-tabs" id="inv-tabs" aria-label="Sitio"></nav>
|
||||||
|
|
||||||
<div class="acciones">
|
<div class="acciones">
|
||||||
<select id="fCat"><option value="">Todas las categorías</option></select>
|
<select id="fCat"><option value="">Todas las categorías</option></select>
|
||||||
<select id="fVer"><option value="">Aplica a: todas</option></select>
|
<select id="fVer"><option value="">Aplica a: todas</option></select>
|
||||||
@ -35,185 +39,9 @@ hide:
|
|||||||
<span class="cnt" id="cnt"></span>
|
<span class="cnt" id="cnt"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="msg" id="aviso"></div>
|
<div class="msg" id="aviso"></div>
|
||||||
<div id="cont" class="tblwrap"></div>
|
<div id="cont"></div>
|
||||||
|
|
||||||
<dialog id="dlg"></dialog>
|
<dialog id="dlg"></dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/tableros/inventario.js"></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>
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user