feat(ucepsa): persist Shop Floor context incident episodes
This commit is contained in:
parent
10e5582b2c
commit
aa8d3dc267
@ -0,0 +1,187 @@
|
|||||||
|
# UCEPSA — Historial persistente de incidencias Shop Floor v0.3.7
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Convertir incidencias vivas en episodios persistentes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
incidencia aparece
|
||||||
|
→ abre un episodio
|
||||||
|
|
||||||
|
incidencia continúa
|
||||||
|
→ actualiza last_seen_at
|
||||||
|
|
||||||
|
incidencia desaparece durante 45 s
|
||||||
|
→ cierra el episodio
|
||||||
|
|
||||||
|
incidencia reaparece más tarde
|
||||||
|
→ abre un episodio nuevo
|
||||||
|
```
|
||||||
|
|
||||||
|
No genera una fila cada 15 segundos.
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
|
||||||
|
La versión:
|
||||||
|
|
||||||
|
- lee la vista viva `li_shopfloor_context_incidents_v1`;
|
||||||
|
- guarda episodios SHADOW;
|
||||||
|
- permite revisión humana o técnica;
|
||||||
|
- amplía el dashboard existente;
|
||||||
|
- no crea sesiones `LEGACY_ERP`;
|
||||||
|
- no modifica Odoo;
|
||||||
|
- no atribuye pérdidas;
|
||||||
|
- no alimenta el Ledger oficial.
|
||||||
|
|
||||||
|
`first_seen_at` es la primera observación realizada por MESAVAULT después de
|
||||||
|
activar el colector. No debe interpretarse como el inicio físico exacto de una
|
||||||
|
situación que ya estuviera activa.
|
||||||
|
|
||||||
|
## Objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
shopfloor_context_incident_policies
|
||||||
|
shopfloor_context_incident_episodes
|
||||||
|
shopfloor_context_incident_reviews
|
||||||
|
shopfloor_context_incident_collector_state
|
||||||
|
|
||||||
|
li_shopfloor_context_incident_history_v1
|
||||||
|
li_shopfloor_context_incident_backlog_v1
|
||||||
|
li_shopfloor_context_incident_daily_summary_v1
|
||||||
|
li_shopfloor_context_incident_collector_health_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clasificaciones de revisión
|
||||||
|
|
||||||
|
```text
|
||||||
|
AUTHORIZED_LEGACY_PRODUCTION
|
||||||
|
TEST_OR_SETUP
|
||||||
|
RESIDUAL_MATERIAL
|
||||||
|
ODOO_START_OMITTED
|
||||||
|
DATA_ISSUE
|
||||||
|
NOT_RELEVANT
|
||||||
|
OTHER
|
||||||
|
```
|
||||||
|
|
||||||
|
Una revisión solo documenta lo sucedido. No abre un contexto ni convierte el
|
||||||
|
episodio en pérdida.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/109_ucepsa_shopfloor_context_incident_history_v037.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colector
|
||||||
|
|
||||||
|
Copiar las herramientas al runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -m 0644 \
|
||||||
|
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/tools/shopfloor_context_incident_collector.py \
|
||||||
|
/srv/mesavault/edge-oee-ucepsa/tools/
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
/srv/mesavault/40-clients/ucepsa/edge-oee-demo/tools/shopfloor_context_incident_admin.py \
|
||||||
|
/srv/mesavault/edge-oee-ucepsa/tools/
|
||||||
|
```
|
||||||
|
|
||||||
|
Arranque:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_shopfloor_context_incident_collector_v037.sh
|
||||||
|
|
||||||
|
"$MANAGER" dry-run
|
||||||
|
"$MANAGER" once
|
||||||
|
"$MANAGER" start
|
||||||
|
"$MANAGER" status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validación
|
||||||
|
|
||||||
|
Esperar al menos 20 segundos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_shopfloor_context_incident_history_v037.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$VALIDATION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Valores obligatorios:
|
||||||
|
|
||||||
|
```text
|
||||||
|
last_seen_before_start = 0
|
||||||
|
end_before_start = 0
|
||||||
|
open_with_end = 0
|
||||||
|
closed_without_end = 0
|
||||||
|
official_episode_rows = 0
|
||||||
|
duplicate_open_fingerprints = 0
|
||||||
|
live_incidents_without_open_episode = 0
|
||||||
|
expired_open_episodes_not_live = 0
|
||||||
|
review_trigger_mismatches = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Revisión de un episodio
|
||||||
|
|
||||||
|
Listar backlog:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin list
|
||||||
|
```
|
||||||
|
|
||||||
|
Ver detalle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin show --episode-id 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplo de clasificación, solo después de confirmación humana:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin review \
|
||||||
|
--episode-id 1 \
|
||||||
|
--classification AUTHORIZED_LEGACY_PRODUCTION \
|
||||||
|
--reviewed-by "Juan Pablo" \
|
||||||
|
--notes "Producción confirmada con el sistema anterior."
|
||||||
|
```
|
||||||
|
|
||||||
|
No ejecutar esa clasificación automáticamente para CORT-00.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
El despliegue mantiene el mismo UID y añade tres paneles:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Incidencias pendientes de revisión
|
||||||
|
Historial de las últimas 24 horas
|
||||||
|
Resumen diario de los últimos 7 días
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
|
||||||
|
echo
|
||||||
|
export GRAFANA_API_TOKEN
|
||||||
|
|
||||||
|
DASH=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/deploy_shopfloor_context_incident_history_dashboard_v037.sh
|
||||||
|
|
||||||
|
"$DASH" deploy
|
||||||
|
"$DASH" validate
|
||||||
|
|
||||||
|
unset GRAFANA_API_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workorder 340
|
||||||
|
|
||||||
|
Esta versión puede desplegarse con el workorder 340 todavía abierto. Su cierre
|
||||||
|
sigue siendo una validación independiente del ciclo de sesión Shop Floor.
|
||||||
@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Supervisión SHADOW de máquina física, orden Odoo, sesión Shop Floor, sincronizador y calidad temporal. Presentación operativa v0.3.6.2. No alimenta el Ledger oficial.",
|
"description": "Supervisión SHADOW de máquina física, orden Odoo, sesión Shop Floor, sincronizador, calidad temporal e historial persistente de incidencias. No alimenta el Ledger oficial.",
|
||||||
"editable": true,
|
"editable": true,
|
||||||
"fiscalYearStartMonth": 0,
|
"fiscalYearStartMonth": 0,
|
||||||
"graphTooltip": 1,
|
"graphTooltip": 1,
|
||||||
@ -1258,6 +1258,362 @@
|
|||||||
],
|
],
|
||||||
"title": "Calidad de sesiones",
|
"title": "Calidad de sesiones",
|
||||||
"type": "table"
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"filterable": true,
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Severidad"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "mappings",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"WARNING": {
|
||||||
|
"text": "WARNING",
|
||||||
|
"color": "orange",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"CRITICAL": {
|
||||||
|
"text": "CRITICAL",
|
||||||
|
"color": "red",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.cellOptions",
|
||||||
|
"value": {
|
||||||
|
"type": "color-background"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 110
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Situación"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 420
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Acción"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 520
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 42
|
||||||
|
},
|
||||||
|
"id": 11,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": []
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.0.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"rawSql": "SELECT\n backlog_rank AS \"Prioridad\",\n episode_id AS \"Episodio\",\n CASE episode_status\n WHEN 'OPEN' THEN 'Abierto'\n ELSE 'Cerrado'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n first_seen_at AS \"Primera observación\",\n episode_duration_min AS \"Duración min\",\n COALESCE(machine_id, '—') AS \"Máquina\",\n incident_title AS \"Situación\",\n COALESCE(production_order, '—') AS \"Orden\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n ELSE 'Técnico'\n END AS \"Responsable\",\n latest_recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_incident_backlog_v1\nORDER BY backlog_rank\nLIMIT 50",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Incidencias pendientes de revisión",
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"filterable": true,
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Severidad"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "mappings",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"WARNING": {
|
||||||
|
"text": "WARNING",
|
||||||
|
"color": "orange",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"CRITICAL": {
|
||||||
|
"text": "CRITICAL",
|
||||||
|
"color": "red",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.cellOptions",
|
||||||
|
"value": {
|
||||||
|
"type": "color-background"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 110
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Situación"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 420
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 52
|
||||||
|
},
|
||||||
|
"id": 12,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": []
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.0.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"rawSql": "SELECT\n episode_id AS \"Episodio\",\n CASE episode_status\n WHEN 'OPEN' THEN 'Abierto'\n ELSE 'Cerrado'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n first_seen_at AS \"Inicio observado\",\n ended_at AS \"Fin observado\",\n episode_duration_min AS \"Duración min\",\n COALESCE(machine_id, '—') AS \"Máquina\",\n incident_title AS \"Situación\",\n COALESCE(production_order, '—') AS \"Orden\",\n CASE review_status\n WHEN 'PENDING' THEN 'Pendiente'\n WHEN 'REVIEWED' THEN 'Revisado'\n WHEN 'DISMISSED' THEN 'Descartado'\n ELSE 'No requerido'\n END AS \"Revisión\",\n COALESCE(review_classification, '—')\n AS \"Clasificación\",\n COALESCE(reviewed_by, '—') AS \"Revisado por\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_incident_history_v1\nWHERE first_seen_at >= now() - interval '24 hours'\nORDER BY first_seen_at DESC\nLIMIT 100",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Historial de incidencias · últimas 24 horas",
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"filterable": true,
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Tipo"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 460
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Severidad máxima"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "mappings",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"WARNING": {
|
||||||
|
"text": "WARNING",
|
||||||
|
"color": "orange",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"CRITICAL": {
|
||||||
|
"text": "CRITICAL",
|
||||||
|
"color": "red",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.cellOptions",
|
||||||
|
"value": {
|
||||||
|
"type": "color-text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 62
|
||||||
|
},
|
||||||
|
"id": 13,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": []
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.0.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "bfnbcasbm6hhca"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"format": "table",
|
||||||
|
"rawQuery": true,
|
||||||
|
"rawSql": "SELECT\n local_day AS \"Día\",\n COALESCE(machine_id, '—') AS \"Máquina\",\n incident_title AS \"Tipo\",\n episode_count AS \"Episodios\",\n open_episode_count AS \"Abiertos\",\n pending_review_count AS \"Pendientes\",\n observed_minutes AS \"Minutos observados\",\n average_episode_minutes AS \"Media min\",\n max_severity AS \"Severidad máxima\"\nFROM\n mv_reports_ucepsa_prod\n .li_shopfloor_context_incident_daily_summary_v1\nWHERE local_day >=\n (\n now()\n AT TIME ZONE 'Europe/Madrid'\n )::date - 6\nORDER BY\n local_day DESC,\n machine_id NULLS FIRST,\n incident_title",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Resumen diario de incidencias · últimos 7 días",
|
||||||
|
"type": "table"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"refresh": "5s",
|
"refresh": "5s",
|
||||||
@ -1268,7 +1624,9 @@
|
|||||||
"shop-floor",
|
"shop-floor",
|
||||||
"context-health",
|
"context-health",
|
||||||
"shadow",
|
"shadow",
|
||||||
"presentation-v0362"
|
"presentation-v0362",
|
||||||
|
"incident-history",
|
||||||
|
"v037"
|
||||||
],
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": []
|
"list": []
|
||||||
|
|||||||
@ -0,0 +1,240 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${SHOPFLOOR_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${SHOPFLOOR_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${SHOPFLOOR_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}"
|
||||||
|
DASHBOARD_UID="ucepsa-shopfloor-context-health"
|
||||||
|
DATASOURCE_UID="bfnbcasbm6hhca"
|
||||||
|
|
||||||
|
resolve_url() {
|
||||||
|
if [[ -n "${GRAFANA_URL:-}" ]]; then
|
||||||
|
printf '%s' "$GRAFANA_URL"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local port_line host_port container_ip
|
||||||
|
|
||||||
|
port_line="$(
|
||||||
|
docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \
|
||||||
|
| head -n 1 || true
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$port_line" ]]; then
|
||||||
|
host_port="${port_line##*:}"
|
||||||
|
printf 'http://127.0.0.1:%s' "$host_port"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
container_ip="$(
|
||||||
|
docker inspect "$GRAFANA_CONTAINER" \
|
||||||
|
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "$container_ip" ]]; then
|
||||||
|
echo "ERROR: no se pudo resolver Grafana." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'http://%s:3000' "$container_ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
ERROR: falta GRAFANA_API_TOKEN.
|
||||||
|
|
||||||
|
read -rsp "Token temporal de Grafana: " GRAFANA_API_TOKEN
|
||||||
|
echo
|
||||||
|
export GRAFANA_API_TOKEN
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRAFANA_URL_RESOLVED
|
||||||
|
GRAFANA_URL_RESOLVED="$(resolve_url)"
|
||||||
|
|
||||||
|
python3 - \
|
||||||
|
"$ACTION" \
|
||||||
|
"$DASHBOARD_FILE" \
|
||||||
|
"$FOLDER_UID" \
|
||||||
|
"$DASHBOARD_UID" \
|
||||||
|
"$DATASOURCE_UID" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
(
|
||||||
|
action,
|
||||||
|
dashboard_file,
|
||||||
|
folder_uid,
|
||||||
|
dashboard_uid,
|
||||||
|
datasource_uid,
|
||||||
|
) = sys.argv[1:]
|
||||||
|
|
||||||
|
base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/")
|
||||||
|
token = os.environ["GRAFANA_API_TOKEN"]
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, path, payload=None):
|
||||||
|
body = None
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
}
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base_url + path,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req,
|
||||||
|
timeout=20,
|
||||||
|
) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return (
|
||||||
|
json.loads(raw.decode("utf-8"))
|
||||||
|
if raw
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Grafana API {method} {path}: "
|
||||||
|
f"HTTP {exc.code}: {detail}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
health = request("GET", "/api/health")
|
||||||
|
if not health or health.get("database") != "ok":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Grafana no está saludable: {health!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "deploy":
|
||||||
|
with open(
|
||||||
|
dashboard_file,
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
dashboard = json.load(handle)
|
||||||
|
|
||||||
|
result = request(
|
||||||
|
"POST",
|
||||||
|
"/api/dashboards/db",
|
||||||
|
{
|
||||||
|
"dashboard": dashboard,
|
||||||
|
"folderUid": folder_uid,
|
||||||
|
"overwrite": True,
|
||||||
|
"message": (
|
||||||
|
"MESAVAULT Shop Floor incident "
|
||||||
|
"history v0.3.7"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"status": "deployed",
|
||||||
|
"dashboard_uid": dashboard_uid,
|
||||||
|
"folder_uid": folder_uid,
|
||||||
|
"response": result,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
elif action == "validate":
|
||||||
|
result = request(
|
||||||
|
"GET",
|
||||||
|
(
|
||||||
|
"/api/dashboards/uid/"
|
||||||
|
+ urllib.parse.quote(dashboard_uid)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dashboard = result["dashboard"]
|
||||||
|
panels = {
|
||||||
|
panel.get("id"): panel
|
||||||
|
for panel in dashboard.get("panels", [])
|
||||||
|
}
|
||||||
|
datasource_uids = sorted({
|
||||||
|
panel.get("datasource", {}).get("uid")
|
||||||
|
for panel in dashboard.get("panels", [])
|
||||||
|
if isinstance(
|
||||||
|
panel.get("datasource"),
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
and panel.get("datasource", {}).get("uid")
|
||||||
|
})
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if len(dashboard.get("panels", [])) != 13:
|
||||||
|
errors.append("El dashboard no tiene 13 paneles")
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
expected = {
|
||||||
|
11: "li_shopfloor_context_incident_backlog_v1",
|
||||||
|
12: "li_shopfloor_context_incident_history_v1",
|
||||||
|
13: "li_shopfloor_context_incident_daily_summary_v1",
|
||||||
|
}
|
||||||
|
for panel_id, fragment in expected.items():
|
||||||
|
sql = (
|
||||||
|
panels.get(panel_id, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if fragment not in sql:
|
||||||
|
errors.append(
|
||||||
|
f"Panel {panel_id} no consulta {fragment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status": "ok" if not errors else "error",
|
||||||
|
"dashboard_uid": dashboard.get("uid"),
|
||||||
|
"folder_uid": result.get("meta", {}).get(
|
||||||
|
"folderUid"
|
||||||
|
),
|
||||||
|
"panel_count": len(
|
||||||
|
dashboard.get("panels", [])
|
||||||
|
),
|
||||||
|
"datasource_uids": datasource_uids,
|
||||||
|
"version": dashboard.get("version"),
|
||||||
|
"url": result.get("meta", {}).get("url"),
|
||||||
|
"history_panels": {
|
||||||
|
"backlog": 11 in panels,
|
||||||
|
"history_24h": 12 in panels,
|
||||||
|
"daily_summary": 13 in panels,
|
||||||
|
},
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: "
|
||||||
|
"deploy_shopfloor_context_incident_history_dashboard_v037.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
125
ucepsa/edge-oee-demo/ops/manage_shopfloor_context_incident_collector_v037.sh
Executable file
125
ucepsa/edge-oee-demo/ops/manage_shopfloor_context_incident_collector_v037.sh
Executable file
@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-status}"
|
||||||
|
ROOT="${SHOPFLOOR_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
CONTAINER="${SHOPFLOOR_INCIDENT_CONTAINER:-mv_ucepsa_shopfloor_context_incident_collector}"
|
||||||
|
NETWORK="${SHOPFLOOR_DOCKER_NETWORK:-mv_ucepsa_net}"
|
||||||
|
INTERVAL="${SHOPFLOOR_INCIDENT_INTERVAL_S:-15}"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
image() {
|
||||||
|
docker inspect \
|
||||||
|
mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
--format '{{.Config.Image}}'
|
||||||
|
}
|
||||||
|
|
||||||
|
start_collector() {
|
||||||
|
local collector_image
|
||||||
|
collector_image="$(image)"
|
||||||
|
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file .env \
|
||||||
|
-v "$ROOT:/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
--entrypoint python \
|
||||||
|
"$collector_image" \
|
||||||
|
tools/shopfloor_context_incident_collector.py \
|
||||||
|
--watch \
|
||||||
|
--interval "$INTERVAL"
|
||||||
|
|
||||||
|
docker ps \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
}
|
||||||
|
|
||||||
|
one_shot() {
|
||||||
|
local collector_image
|
||||||
|
collector_image="$(image)"
|
||||||
|
|
||||||
|
docker run --rm -i \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file .env \
|
||||||
|
-v "$ROOT:/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
--entrypoint python \
|
||||||
|
"$collector_image" \
|
||||||
|
tools/shopfloor_context_incident_collector.py \
|
||||||
|
--once
|
||||||
|
}
|
||||||
|
|
||||||
|
dry_run() {
|
||||||
|
local collector_image
|
||||||
|
collector_image="$(image)"
|
||||||
|
|
||||||
|
docker run --rm -i \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file .env \
|
||||||
|
-v "$ROOT:/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
--entrypoint python \
|
||||||
|
"$collector_image" \
|
||||||
|
tools/shopfloor_context_incident_collector.py \
|
||||||
|
--once \
|
||||||
|
--dry-run
|
||||||
|
}
|
||||||
|
|
||||||
|
admin() {
|
||||||
|
shift
|
||||||
|
local collector_image
|
||||||
|
collector_image="$(image)"
|
||||||
|
|
||||||
|
docker run --rm -i \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--env-file .env \
|
||||||
|
-v "$ROOT:/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
--entrypoint python \
|
||||||
|
"$collector_image" \
|
||||||
|
tools/shopfloor_context_incident_admin.py \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
start)
|
||||||
|
start_collector
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
docker stop "$CONTAINER"
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
start_collector
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
docker ps -a \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
docker logs --tail 200 -f "$CONTAINER"
|
||||||
|
;;
|
||||||
|
once)
|
||||||
|
one_shot
|
||||||
|
;;
|
||||||
|
dry-run)
|
||||||
|
dry_run
|
||||||
|
;;
|
||||||
|
admin)
|
||||||
|
admin "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo \
|
||||||
|
"Uso: $0 {start|stop|restart|status|logs|once|dry-run|admin ...}" \
|
||||||
|
>&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@ -0,0 +1,263 @@
|
|||||||
|
\pset pager off
|
||||||
|
|
||||||
|
\echo '=== 1. Objetos v0.3.7 ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.shopfloor_context_incident_policies'
|
||||||
|
) AS policy_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.shopfloor_context_incident_episodes'
|
||||||
|
) AS episodes_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.shopfloor_context_incident_reviews'
|
||||||
|
) AS reviews_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.shopfloor_context_incident_collector_state'
|
||||||
|
) AS collector_state_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_shopfloor_context_incident_history_v1'
|
||||||
|
) AS history_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_shopfloor_context_incident_backlog_v1'
|
||||||
|
) AS backlog_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_shopfloor_context_incident_daily_summary_v1'
|
||||||
|
) AS daily_summary_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_shopfloor_context_incident_collector_health_v1'
|
||||||
|
) AS collector_health_view;
|
||||||
|
|
||||||
|
\echo '=== 2. Política activa ==='
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_policies
|
||||||
|
WHERE tenant = 'ucepsa'
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND policy_code =
|
||||||
|
'SHOPFLOOR_CONTEXT_INCIDENT_HISTORY_V1';
|
||||||
|
|
||||||
|
\echo '=== 3. Salud del colector ==='
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_collector_health_v1;
|
||||||
|
|
||||||
|
\echo '=== 4. Invariantes de episodios ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE last_seen_at < first_seen_at
|
||||||
|
) AS last_seen_before_start,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE ended_at IS NOT NULL
|
||||||
|
AND ended_at < first_seen_at
|
||||||
|
) AS end_before_start,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE episode_status = 'OPEN'
|
||||||
|
AND ended_at IS NOT NULL
|
||||||
|
) AS open_with_end,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE episode_status = 'CLOSED'
|
||||||
|
AND ended_at IS NULL
|
||||||
|
) AS closed_without_end,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE official_eligible
|
||||||
|
) AS official_episode_rows
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes;
|
||||||
|
|
||||||
|
\echo '=== 5. No hay dos episodios OPEN con la misma huella ==='
|
||||||
|
|
||||||
|
WITH duplicated AS (
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
incident_fingerprint,
|
||||||
|
count(*) AS n
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes
|
||||||
|
WHERE episode_status = 'OPEN'
|
||||||
|
GROUP BY
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
incident_fingerprint
|
||||||
|
HAVING count(*) > 1
|
||||||
|
)
|
||||||
|
SELECT count(*) AS duplicate_open_fingerprints
|
||||||
|
FROM duplicated;
|
||||||
|
|
||||||
|
\echo '=== 6. Incidencias vivas con episodio OPEN ==='
|
||||||
|
|
||||||
|
SELECT count(*) AS live_incidents_without_open_episode
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incidents_v1 i
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes e
|
||||||
|
WHERE e.tenant = 'ucepsa'
|
||||||
|
AND e.site = 'ucepsa_onpremise'
|
||||||
|
AND e.episode_status = 'OPEN'
|
||||||
|
AND e.incident_key = i.incident_key
|
||||||
|
AND e.incident_scope = i.incident_scope
|
||||||
|
AND e.incident_code = i.incident_code
|
||||||
|
AND e.machine_id
|
||||||
|
IS NOT DISTINCT FROM i.machine_id
|
||||||
|
AND e.context_key
|
||||||
|
IS NOT DISTINCT FROM i.context_key
|
||||||
|
AND e.production_order
|
||||||
|
IS NOT DISTINCT FROM i.production_order
|
||||||
|
AND e.odoo_workorder_id
|
||||||
|
IS NOT DISTINCT FROM i.odoo_workorder_id
|
||||||
|
);
|
||||||
|
|
||||||
|
\echo '=== 7. Episodios OPEN vencidos que ya no están vivos ==='
|
||||||
|
|
||||||
|
WITH policy AS (
|
||||||
|
SELECT close_grace_s
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_policies
|
||||||
|
WHERE tenant = 'ucepsa'
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND policy_code =
|
||||||
|
'SHOPFLOOR_CONTEXT_INCIDENT_HISTORY_V1'
|
||||||
|
AND active
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
SELECT count(*) AS expired_open_episodes_not_live
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes e
|
||||||
|
CROSS JOIN policy p
|
||||||
|
WHERE e.tenant = 'ucepsa'
|
||||||
|
AND e.site = 'ucepsa_onpremise'
|
||||||
|
AND e.episode_status = 'OPEN'
|
||||||
|
AND e.last_seen_at
|
||||||
|
< now()
|
||||||
|
- (
|
||||||
|
p.close_grace_s
|
||||||
|
* interval '1 second'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incidents_v1 i
|
||||||
|
WHERE i.incident_key = e.incident_key
|
||||||
|
AND i.incident_scope = e.incident_scope
|
||||||
|
AND i.incident_code = e.incident_code
|
||||||
|
AND i.machine_id
|
||||||
|
IS NOT DISTINCT FROM e.machine_id
|
||||||
|
AND i.context_key
|
||||||
|
IS NOT DISTINCT FROM e.context_key
|
||||||
|
AND i.production_order
|
||||||
|
IS NOT DISTINCT FROM e.production_order
|
||||||
|
AND i.odoo_workorder_id
|
||||||
|
IS NOT DISTINCT FROM e.odoo_workorder_id
|
||||||
|
);
|
||||||
|
|
||||||
|
\echo '=== 8. Episodios actuales ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
episode_id,
|
||||||
|
episode_status,
|
||||||
|
max_severity,
|
||||||
|
first_seen_at,
|
||||||
|
last_seen_at,
|
||||||
|
episode_duration_min,
|
||||||
|
machine_id,
|
||||||
|
incident_title,
|
||||||
|
production_order,
|
||||||
|
review_required,
|
||||||
|
review_owner,
|
||||||
|
review_status,
|
||||||
|
official_eligible
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1
|
||||||
|
ORDER BY first_seen_at DESC
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
\echo '=== 9. Backlog de revisión ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
backlog_rank,
|
||||||
|
episode_id,
|
||||||
|
episode_status,
|
||||||
|
max_severity,
|
||||||
|
machine_id,
|
||||||
|
incident_title,
|
||||||
|
production_order,
|
||||||
|
episode_duration_min,
|
||||||
|
review_owner,
|
||||||
|
review_status,
|
||||||
|
latest_recommended_action
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_backlog_v1
|
||||||
|
ORDER BY backlog_rank
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
\echo '=== 10. Resumen diario ==='
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_daily_summary_v1
|
||||||
|
ORDER BY
|
||||||
|
local_day DESC,
|
||||||
|
machine_id NULLS FIRST,
|
||||||
|
incident_code
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
\echo '=== 11. Revisiones coherentes ==='
|
||||||
|
|
||||||
|
SELECT count(*) AS review_trigger_mismatches
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_reviews r
|
||||||
|
JOIN
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes e
|
||||||
|
ON e.episode_id = r.episode_id
|
||||||
|
WHERE r.review_id = (
|
||||||
|
SELECT max(r2.review_id)
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_reviews r2
|
||||||
|
WHERE r2.episode_id = r.episode_id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
e.review_status
|
||||||
|
IS DISTINCT FROM r.new_review_status
|
||||||
|
OR e.review_classification
|
||||||
|
IS DISTINCT FROM r.review_classification
|
||||||
|
OR e.reviewed_by
|
||||||
|
IS DISTINCT FROM r.reviewed_by
|
||||||
|
);
|
||||||
|
|
||||||
|
\echo '=== 12. Nada oficial ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes
|
||||||
|
WHERE official_eligible
|
||||||
|
) AS official_episode_rows,
|
||||||
|
(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_operator_stop_queue_v2
|
||||||
|
) AS official_operator_queue_rows;
|
||||||
@ -0,0 +1,738 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_policies (
|
||||||
|
tenant text NOT NULL,
|
||||||
|
site text NOT NULL,
|
||||||
|
policy_code text NOT NULL,
|
||||||
|
collector_interval_s integer NOT NULL DEFAULT 15
|
||||||
|
CHECK (collector_interval_s > 0),
|
||||||
|
close_grace_s integer NOT NULL DEFAULT 45
|
||||||
|
CHECK (close_grace_s >= collector_interval_s),
|
||||||
|
collector_delayed_after_s integer NOT NULL DEFAULT 45
|
||||||
|
CHECK (collector_delayed_after_s > 0),
|
||||||
|
collector_stale_after_s integer NOT NULL DEFAULT 120
|
||||||
|
CHECK (
|
||||||
|
collector_stale_after_s
|
||||||
|
> collector_delayed_after_s
|
||||||
|
),
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
policy_code
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_policies IS
|
||||||
|
'Política de captura persistente de incidencias vivas Shop Floor.';
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_policies (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
policy_code,
|
||||||
|
collector_interval_s,
|
||||||
|
close_grace_s,
|
||||||
|
collector_delayed_after_s,
|
||||||
|
collector_stale_after_s,
|
||||||
|
active,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'ucepsa',
|
||||||
|
'ucepsa_onpremise',
|
||||||
|
'SHOPFLOOR_CONTEXT_INCIDENT_HISTORY_V1',
|
||||||
|
15,
|
||||||
|
45,
|
||||||
|
45,
|
||||||
|
120,
|
||||||
|
true,
|
||||||
|
'Captura cada 15 s y cierra un episodio cuando la incidencia lleva 45 s sin aparecer. El inicio representa la primera observación de MESAVAULT, no necesariamente el inicio físico exacto.'
|
||||||
|
)
|
||||||
|
ON CONFLICT (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
policy_code
|
||||||
|
)
|
||||||
|
DO UPDATE SET
|
||||||
|
collector_interval_s =
|
||||||
|
EXCLUDED.collector_interval_s,
|
||||||
|
close_grace_s =
|
||||||
|
EXCLUDED.close_grace_s,
|
||||||
|
collector_delayed_after_s =
|
||||||
|
EXCLUDED.collector_delayed_after_s,
|
||||||
|
collector_stale_after_s =
|
||||||
|
EXCLUDED.collector_stale_after_s,
|
||||||
|
active = true,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_episodes (
|
||||||
|
episode_id bigserial PRIMARY KEY,
|
||||||
|
tenant text NOT NULL,
|
||||||
|
site text NOT NULL,
|
||||||
|
incident_fingerprint text NOT NULL,
|
||||||
|
incident_key text NOT NULL,
|
||||||
|
incident_scope text NOT NULL,
|
||||||
|
incident_code text NOT NULL,
|
||||||
|
machine_id text,
|
||||||
|
context_key text,
|
||||||
|
production_order text,
|
||||||
|
odoo_workorder_id bigint,
|
||||||
|
operator_name text,
|
||||||
|
initial_severity text NOT NULL,
|
||||||
|
current_severity text NOT NULL,
|
||||||
|
max_severity text NOT NULL,
|
||||||
|
episode_status text NOT NULL DEFAULT 'OPEN',
|
||||||
|
first_seen_at timestamptz NOT NULL,
|
||||||
|
last_seen_at timestamptz NOT NULL,
|
||||||
|
ended_at timestamptz,
|
||||||
|
closed_detected_at timestamptz,
|
||||||
|
close_reason text,
|
||||||
|
observation_count bigint NOT NULL DEFAULT 1
|
||||||
|
CHECK (observation_count > 0),
|
||||||
|
initial_detail text,
|
||||||
|
latest_detail text,
|
||||||
|
initial_recommended_action text,
|
||||||
|
latest_recommended_action text,
|
||||||
|
review_required boolean NOT NULL DEFAULT true,
|
||||||
|
review_owner text NOT NULL,
|
||||||
|
review_status text NOT NULL DEFAULT 'PENDING',
|
||||||
|
review_classification text,
|
||||||
|
reviewed_by text,
|
||||||
|
reviewed_at timestamptz,
|
||||||
|
review_notes text,
|
||||||
|
source_payload_json jsonb NOT NULL
|
||||||
|
DEFAULT '{}'::jsonb,
|
||||||
|
official_eligible boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_interval
|
||||||
|
CHECK (
|
||||||
|
ended_at IS NULL
|
||||||
|
OR ended_at >= first_seen_at
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_last_seen
|
||||||
|
CHECK (
|
||||||
|
last_seen_at >= first_seen_at
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_status
|
||||||
|
CHECK (
|
||||||
|
episode_status IN (
|
||||||
|
'OPEN',
|
||||||
|
'CLOSED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_severity
|
||||||
|
CHECK (
|
||||||
|
initial_severity IN (
|
||||||
|
'WARNING',
|
||||||
|
'CRITICAL'
|
||||||
|
)
|
||||||
|
AND current_severity IN (
|
||||||
|
'WARNING',
|
||||||
|
'CRITICAL'
|
||||||
|
)
|
||||||
|
AND max_severity IN (
|
||||||
|
'WARNING',
|
||||||
|
'CRITICAL'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_review_owner
|
||||||
|
CHECK (
|
||||||
|
review_owner IN (
|
||||||
|
'PRODUCTION',
|
||||||
|
'TECHNICAL'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_review_status
|
||||||
|
CHECK (
|
||||||
|
review_status IN (
|
||||||
|
'PENDING',
|
||||||
|
'REVIEWED',
|
||||||
|
'DISMISSED',
|
||||||
|
'NOT_REQUIRED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_review_classification
|
||||||
|
CHECK (
|
||||||
|
review_classification IS NULL
|
||||||
|
OR review_classification IN (
|
||||||
|
'AUTHORIZED_LEGACY_PRODUCTION',
|
||||||
|
'TEST_OR_SETUP',
|
||||||
|
'RESIDUAL_MATERIAL',
|
||||||
|
'ODOO_START_OMITTED',
|
||||||
|
'DATA_ISSUE',
|
||||||
|
'NOT_RELEVANT',
|
||||||
|
'OTHER'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_episode_shadow_only
|
||||||
|
CHECK (official_eligible = false)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||||
|
ux_shopfloor_context_incident_open_fingerprint
|
||||||
|
ON
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_episodes (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
incident_fingerprint
|
||||||
|
)
|
||||||
|
WHERE episode_status = 'OPEN';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS
|
||||||
|
idx_shopfloor_context_incident_episode_time
|
||||||
|
ON
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_episodes (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
first_seen_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS
|
||||||
|
idx_shopfloor_context_incident_episode_machine
|
||||||
|
ON
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_episodes (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
machine_id,
|
||||||
|
first_seen_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS
|
||||||
|
idx_shopfloor_context_incident_episode_backlog
|
||||||
|
ON
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_episodes (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
review_required,
|
||||||
|
review_status,
|
||||||
|
episode_status,
|
||||||
|
max_severity,
|
||||||
|
first_seen_at
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_reviews (
|
||||||
|
review_id bigserial PRIMARY KEY,
|
||||||
|
episode_id bigint NOT NULL
|
||||||
|
REFERENCES
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes (
|
||||||
|
episode_id
|
||||||
|
)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
previous_values jsonb NOT NULL,
|
||||||
|
new_review_status text NOT NULL,
|
||||||
|
review_classification text NOT NULL,
|
||||||
|
reviewed_by text NOT NULL,
|
||||||
|
reviewed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
notes text,
|
||||||
|
evidence_json jsonb NOT NULL
|
||||||
|
DEFAULT '{}'::jsonb,
|
||||||
|
CONSTRAINT ck_shopfloor_context_review_status
|
||||||
|
CHECK (
|
||||||
|
new_review_status IN (
|
||||||
|
'REVIEWED',
|
||||||
|
'DISMISSED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT ck_shopfloor_context_review_classification
|
||||||
|
CHECK (
|
||||||
|
review_classification IN (
|
||||||
|
'AUTHORIZED_LEGACY_PRODUCTION',
|
||||||
|
'TEST_OR_SETUP',
|
||||||
|
'RESIDUAL_MATERIAL',
|
||||||
|
'ODOO_START_OMITTED',
|
||||||
|
'DATA_ISSUE',
|
||||||
|
'NOT_RELEVANT',
|
||||||
|
'OTHER'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS
|
||||||
|
idx_shopfloor_context_incident_reviews_episode
|
||||||
|
ON
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_reviews (
|
||||||
|
episode_id,
|
||||||
|
reviewed_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION
|
||||||
|
mv_loss_intelligence.apply_shopfloor_context_incident_review()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes
|
||||||
|
SET
|
||||||
|
review_status =
|
||||||
|
NEW.new_review_status,
|
||||||
|
review_classification =
|
||||||
|
NEW.review_classification,
|
||||||
|
reviewed_by =
|
||||||
|
NEW.reviewed_by,
|
||||||
|
reviewed_at =
|
||||||
|
NEW.reviewed_at,
|
||||||
|
review_notes =
|
||||||
|
NEW.notes,
|
||||||
|
updated_at =
|
||||||
|
now()
|
||||||
|
WHERE episode_id =
|
||||||
|
NEW.episode_id;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS
|
||||||
|
trg_apply_shopfloor_context_incident_review
|
||||||
|
ON
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_reviews;
|
||||||
|
|
||||||
|
CREATE TRIGGER
|
||||||
|
trg_apply_shopfloor_context_incident_review
|
||||||
|
AFTER INSERT
|
||||||
|
ON
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_reviews
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION
|
||||||
|
mv_loss_intelligence.apply_shopfloor_context_incident_review();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS
|
||||||
|
mv_loss_intelligence.shopfloor_context_incident_collector_state (
|
||||||
|
tenant text NOT NULL,
|
||||||
|
site text NOT NULL,
|
||||||
|
collector_name text NOT NULL,
|
||||||
|
last_success_at timestamptz,
|
||||||
|
last_current_count integer,
|
||||||
|
last_opened_count integer,
|
||||||
|
last_updated_count integer,
|
||||||
|
last_closed_count integer,
|
||||||
|
last_error text,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
collector_name
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_history_v1
|
||||||
|
AS
|
||||||
|
WITH review_counts AS (
|
||||||
|
SELECT
|
||||||
|
episode_id,
|
||||||
|
COUNT(*) AS review_count
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_reviews
|
||||||
|
GROUP BY episode_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
e.episode_id,
|
||||||
|
(
|
||||||
|
'SHOPFLOOR_INCIDENT_EPISODE:'
|
||||||
|
|| e.episode_id::text
|
||||||
|
) AS episode_key,
|
||||||
|
e.tenant,
|
||||||
|
e.site,
|
||||||
|
e.incident_fingerprint,
|
||||||
|
e.incident_key,
|
||||||
|
e.incident_scope,
|
||||||
|
e.incident_code,
|
||||||
|
CASE e.incident_code
|
||||||
|
WHEN 'RUNNING_WITHOUT_SHOPFLOOR_SESSION'
|
||||||
|
THEN
|
||||||
|
'Máquina en marcha sin orden ni sesión Shop Floor'
|
||||||
|
WHEN 'RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION'
|
||||||
|
THEN
|
||||||
|
'Máquina en marcha con orden, pero sin sesión Shop Floor'
|
||||||
|
WHEN 'ORDER_WITHOUT_SHOPFLOOR_SESSION'
|
||||||
|
THEN
|
||||||
|
'Orden publicada sin sesión Shop Floor'
|
||||||
|
WHEN 'RUNNING_SESSION_WITHOUT_PUBLISHED_ORDER'
|
||||||
|
THEN
|
||||||
|
'Sesión abierta y máquina en marcha, pero sin orden publicada'
|
||||||
|
WHEN 'OPEN_SESSION_WITHOUT_PUBLISHED_ORDER'
|
||||||
|
THEN
|
||||||
|
'Sesión abierta sin orden publicada'
|
||||||
|
WHEN 'OPEN_SESSION_ORDER_MISMATCH'
|
||||||
|
THEN
|
||||||
|
'La orden publicada no coincide con la sesión abierta'
|
||||||
|
WHEN 'MULTIPLE_OPEN_SESSIONS'
|
||||||
|
THEN
|
||||||
|
'Varias sesiones abiertas en la misma máquina'
|
||||||
|
WHEN 'OPEN_SESSION_DATA_QUALITY_ERROR'
|
||||||
|
THEN
|
||||||
|
'Error de calidad en una sesión abierta'
|
||||||
|
WHEN 'SHOPFLOOR_SYNC_DELAYED'
|
||||||
|
THEN
|
||||||
|
'Sincronización Shop Floor retrasada'
|
||||||
|
WHEN 'SHOPFLOOR_SYNC_STALE'
|
||||||
|
THEN
|
||||||
|
'Sincronización Shop Floor obsoleta'
|
||||||
|
WHEN 'SHOPFLOOR_SYNC_ERROR'
|
||||||
|
THEN
|
||||||
|
'Error del sincronizador Shop Floor'
|
||||||
|
WHEN 'SHOPFLOOR_SYNC_NOT_INITIALIZED'
|
||||||
|
THEN
|
||||||
|
'Sincronizador Shop Floor no inicializado'
|
||||||
|
WHEN 'PENDING_HAS_TIME'
|
||||||
|
THEN
|
||||||
|
'Una sesión pendiente contiene tiempo productivo'
|
||||||
|
WHEN 'OPEN_MISSING_START'
|
||||||
|
THEN
|
||||||
|
'Sesión abierta sin hora de inicio'
|
||||||
|
WHEN 'OPEN_HAS_END'
|
||||||
|
THEN
|
||||||
|
'Sesión abierta con hora de fin'
|
||||||
|
WHEN 'CLOSED_MISSING_START'
|
||||||
|
THEN
|
||||||
|
'Sesión cerrada sin hora de inicio'
|
||||||
|
WHEN 'CLOSED_MISSING_END'
|
||||||
|
THEN
|
||||||
|
'Sesión cerrada sin hora de fin'
|
||||||
|
WHEN 'INVALID_INTERVAL'
|
||||||
|
THEN
|
||||||
|
'Intervalo temporal inválido'
|
||||||
|
WHEN 'WORKORDER_SESSION_STATE_MISMATCH'
|
||||||
|
THEN
|
||||||
|
'Estado Odoo y estado de sesión no coinciden'
|
||||||
|
WHEN 'STALE_OPEN_SESSION'
|
||||||
|
THEN
|
||||||
|
'Sesión abierta durante más tiempo del esperado'
|
||||||
|
WHEN 'FUTURE_SESSION_START'
|
||||||
|
THEN
|
||||||
|
'Sesión con inicio futuro'
|
||||||
|
ELSE e.incident_code
|
||||||
|
END AS incident_title,
|
||||||
|
e.machine_id,
|
||||||
|
e.context_key,
|
||||||
|
e.production_order,
|
||||||
|
e.odoo_workorder_id,
|
||||||
|
e.operator_name,
|
||||||
|
e.initial_severity,
|
||||||
|
e.current_severity,
|
||||||
|
e.max_severity,
|
||||||
|
e.episode_status,
|
||||||
|
e.first_seen_at,
|
||||||
|
e.last_seen_at,
|
||||||
|
e.ended_at,
|
||||||
|
e.closed_detected_at,
|
||||||
|
e.close_reason,
|
||||||
|
e.observation_count,
|
||||||
|
ROUND(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM (
|
||||||
|
COALESCE(
|
||||||
|
e.ended_at,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
- e.first_seen_at
|
||||||
|
)
|
||||||
|
)::numeric,
|
||||||
|
2
|
||||||
|
) AS episode_duration_s,
|
||||||
|
ROUND(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM (
|
||||||
|
COALESCE(
|
||||||
|
e.ended_at,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
- e.first_seen_at
|
||||||
|
)
|
||||||
|
)::numeric / 60.0,
|
||||||
|
2
|
||||||
|
) AS episode_duration_min,
|
||||||
|
ROUND(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM (
|
||||||
|
e.last_seen_at
|
||||||
|
- e.first_seen_at
|
||||||
|
)
|
||||||
|
)::numeric,
|
||||||
|
2
|
||||||
|
) AS confirmed_observed_duration_s,
|
||||||
|
e.initial_detail,
|
||||||
|
e.latest_detail,
|
||||||
|
e.initial_recommended_action,
|
||||||
|
e.latest_recommended_action,
|
||||||
|
e.review_required,
|
||||||
|
e.review_owner,
|
||||||
|
e.review_status,
|
||||||
|
e.review_classification,
|
||||||
|
e.reviewed_by,
|
||||||
|
e.reviewed_at,
|
||||||
|
e.review_notes,
|
||||||
|
COALESCE(
|
||||||
|
r.review_count,
|
||||||
|
0
|
||||||
|
) AS review_count,
|
||||||
|
e.source_payload_json,
|
||||||
|
e.official_eligible,
|
||||||
|
e.created_at,
|
||||||
|
e.updated_at
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes e
|
||||||
|
LEFT JOIN review_counts r
|
||||||
|
ON r.episode_id =
|
||||||
|
e.episode_id;
|
||||||
|
|
||||||
|
COMMENT ON VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_history_v1 IS
|
||||||
|
'Historial persistente de episodios derivados de incidencias vivas Shop Floor. first_seen_at es la primera observación MESAVAULT.';
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_backlog_v1
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
ORDER BY
|
||||||
|
CASE max_severity
|
||||||
|
WHEN 'CRITICAL' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
CASE episode_status
|
||||||
|
WHEN 'OPEN' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
first_seen_at
|
||||||
|
) AS backlog_rank,
|
||||||
|
h.*
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1 h
|
||||||
|
WHERE h.review_required
|
||||||
|
AND h.review_status = 'PENDING';
|
||||||
|
|
||||||
|
COMMENT ON VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_backlog_v1 IS
|
||||||
|
'Cola SHADOW de episodios pendientes de revisión humana o técnica. No crea contextos ni pérdidas oficiales.';
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_daily_summary_v1
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
first_seen_at
|
||||||
|
AT TIME ZONE 'Europe/Madrid'
|
||||||
|
)::date AS local_day,
|
||||||
|
machine_id,
|
||||||
|
incident_scope,
|
||||||
|
incident_code,
|
||||||
|
incident_title,
|
||||||
|
review_owner,
|
||||||
|
COUNT(*) AS episode_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE episode_status = 'OPEN'
|
||||||
|
) AS open_episode_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE review_required
|
||||||
|
AND review_status = 'PENDING'
|
||||||
|
) AS pending_review_count,
|
||||||
|
ROUND(
|
||||||
|
SUM(episode_duration_s)
|
||||||
|
/ 60.0,
|
||||||
|
2
|
||||||
|
) AS observed_minutes,
|
||||||
|
ROUND(
|
||||||
|
AVG(episode_duration_s)
|
||||||
|
/ 60.0,
|
||||||
|
2
|
||||||
|
) AS average_episode_minutes,
|
||||||
|
CASE
|
||||||
|
WHEN BOOL_OR(
|
||||||
|
max_severity = 'CRITICAL'
|
||||||
|
)
|
||||||
|
THEN 'CRITICAL'
|
||||||
|
ELSE 'WARNING'
|
||||||
|
END AS max_severity,
|
||||||
|
false AS official_eligible
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1
|
||||||
|
GROUP BY
|
||||||
|
(
|
||||||
|
first_seen_at
|
||||||
|
AT TIME ZONE 'Europe/Madrid'
|
||||||
|
)::date,
|
||||||
|
machine_id,
|
||||||
|
incident_scope,
|
||||||
|
incident_code,
|
||||||
|
incident_title,
|
||||||
|
review_owner;
|
||||||
|
|
||||||
|
COMMENT ON VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_daily_summary_v1 IS
|
||||||
|
'Resumen diario de episodios Shop Floor por máquina y tipo de incidencia.';
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_collector_health_v1
|
||||||
|
AS
|
||||||
|
WITH policy AS (
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_policies
|
||||||
|
WHERE tenant = 'ucepsa'
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND policy_code =
|
||||||
|
'SHOPFLOOR_CONTEXT_INCIDENT_HISTORY_V1'
|
||||||
|
AND active
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
state AS (
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_collector_state
|
||||||
|
WHERE tenant = 'ucepsa'
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND collector_name =
|
||||||
|
'shopfloor_context_incident_collector_v037'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.tenant,
|
||||||
|
p.site,
|
||||||
|
'shopfloor_context_incident_collector_v037'::text
|
||||||
|
AS collector_name,
|
||||||
|
p.policy_code,
|
||||||
|
p.collector_interval_s,
|
||||||
|
p.close_grace_s,
|
||||||
|
p.collector_delayed_after_s,
|
||||||
|
p.collector_stale_after_s,
|
||||||
|
s.last_success_at,
|
||||||
|
s.last_current_count,
|
||||||
|
s.last_opened_count,
|
||||||
|
s.last_updated_count,
|
||||||
|
s.last_closed_count,
|
||||||
|
NULLIF(
|
||||||
|
btrim(s.last_error),
|
||||||
|
''
|
||||||
|
) AS last_error,
|
||||||
|
CASE
|
||||||
|
WHEN s.last_success_at IS NULL
|
||||||
|
THEN NULL::numeric
|
||||||
|
ELSE ROUND(
|
||||||
|
GREATEST(
|
||||||
|
EXTRACT(
|
||||||
|
EPOCH FROM (
|
||||||
|
now()
|
||||||
|
- s.last_success_at
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)::numeric,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
END AS seconds_since_success,
|
||||||
|
CASE
|
||||||
|
WHEN s.tenant IS NULL
|
||||||
|
THEN 'NOT_INITIALIZED'
|
||||||
|
WHEN NULLIF(
|
||||||
|
btrim(s.last_error),
|
||||||
|
''
|
||||||
|
) IS NOT NULL
|
||||||
|
THEN 'ERROR'
|
||||||
|
WHEN s.last_success_at IS NULL
|
||||||
|
THEN 'NOT_INITIALIZED'
|
||||||
|
WHEN now() - s.last_success_at
|
||||||
|
> make_interval(
|
||||||
|
secs =>
|
||||||
|
p.collector_stale_after_s
|
||||||
|
::double precision
|
||||||
|
)
|
||||||
|
THEN 'STALE'
|
||||||
|
WHEN now() - s.last_success_at
|
||||||
|
> make_interval(
|
||||||
|
secs =>
|
||||||
|
p.collector_delayed_after_s
|
||||||
|
::double precision
|
||||||
|
)
|
||||||
|
THEN 'DELAYED'
|
||||||
|
ELSE 'HEALTHY'
|
||||||
|
END AS collector_health_status,
|
||||||
|
CASE
|
||||||
|
WHEN s.tenant IS NULL
|
||||||
|
OR s.last_success_at IS NULL
|
||||||
|
OR NULLIF(
|
||||||
|
btrim(s.last_error),
|
||||||
|
''
|
||||||
|
) IS NOT NULL
|
||||||
|
OR now() - s.last_success_at
|
||||||
|
> make_interval(
|
||||||
|
secs =>
|
||||||
|
p.collector_stale_after_s
|
||||||
|
::double precision
|
||||||
|
)
|
||||||
|
THEN 'CRITICAL'
|
||||||
|
WHEN now() - s.last_success_at
|
||||||
|
> make_interval(
|
||||||
|
secs =>
|
||||||
|
p.collector_delayed_after_s
|
||||||
|
::double precision
|
||||||
|
)
|
||||||
|
THEN 'WARNING'
|
||||||
|
ELSE 'OK'
|
||||||
|
END AS severity,
|
||||||
|
false AS official_eligible,
|
||||||
|
now() AS observed_at
|
||||||
|
FROM policy p
|
||||||
|
LEFT JOIN state s
|
||||||
|
ON s.tenant = p.tenant
|
||||||
|
AND s.site = p.site;
|
||||||
|
|
||||||
|
COMMENT ON VIEW
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incident_collector_health_v1 IS
|
||||||
|
'Salud del colector persistente de incidencias Shop Floor.';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_roles
|
||||||
|
WHERE rolname = 'grafana_ucepsa_ro'
|
||||||
|
) THEN
|
||||||
|
GRANT SELECT
|
||||||
|
ON
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1
|
||||||
|
TO grafana_ucepsa_ro;
|
||||||
|
|
||||||
|
GRANT SELECT
|
||||||
|
ON
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_backlog_v1
|
||||||
|
TO grafana_ucepsa_ro;
|
||||||
|
|
||||||
|
GRANT SELECT
|
||||||
|
ON
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_daily_summary_v1
|
||||||
|
TO grafana_ucepsa_ro;
|
||||||
|
|
||||||
|
GRANT SELECT
|
||||||
|
ON
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_collector_health_v1
|
||||||
|
TO grafana_ucepsa_ro;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
369
ucepsa/edge-oee-demo/tools/shopfloor_context_incident_admin.py
Executable file
369
ucepsa/edge-oee-demo/tools/shopfloor_context_incident_admin.py
Executable file
@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Administración de revisiones de episodios Shop Floor v0.3.7."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
|
CLASSIFICATIONS = (
|
||||||
|
"AUTHORIZED_LEGACY_PRODUCTION",
|
||||||
|
"TEST_OR_SETUP",
|
||||||
|
"RESIDUAL_MATERIAL",
|
||||||
|
"ODOO_START_OMITTED",
|
||||||
|
"DATA_ISSUE",
|
||||||
|
"NOT_RELEVANT",
|
||||||
|
"OTHER",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def env_first(*names: str, default: str | None = None) -> str | None:
|
||||||
|
for name in names:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value not in (None, ""):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
required = {
|
||||||
|
"dbname": env_first(
|
||||||
|
"PGDATABASE",
|
||||||
|
"POSTGRES_DB",
|
||||||
|
),
|
||||||
|
"user": env_first(
|
||||||
|
"PGUSER",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
),
|
||||||
|
"password": env_first(
|
||||||
|
"PGPASSWORD",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
missing = [
|
||||||
|
key
|
||||||
|
for key, value in required.items()
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Faltan variables PostgreSQL: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=env_first(
|
||||||
|
"PGHOST",
|
||||||
|
default="mv_ucepsa_postgres_hot",
|
||||||
|
),
|
||||||
|
port=int(
|
||||||
|
env_first("PGPORT", default="5432")
|
||||||
|
or "5432"
|
||||||
|
),
|
||||||
|
dbname=required["dbname"],
|
||||||
|
user=required["user"],
|
||||||
|
password=required["password"],
|
||||||
|
connect_timeout=10,
|
||||||
|
application_name=(
|
||||||
|
"shopfloor_context_incident_admin_v037"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_json(value: Any) -> None:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_backlog(limit: int) -> None:
|
||||||
|
with connect() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
backlog_rank,
|
||||||
|
episode_id,
|
||||||
|
episode_status,
|
||||||
|
max_severity,
|
||||||
|
first_seen_at,
|
||||||
|
last_seen_at,
|
||||||
|
episode_duration_min,
|
||||||
|
machine_id,
|
||||||
|
incident_title,
|
||||||
|
production_order,
|
||||||
|
operator_name,
|
||||||
|
review_owner,
|
||||||
|
review_status,
|
||||||
|
latest_recommended_action
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_backlog_v1
|
||||||
|
ORDER BY backlog_rank
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
print_json(
|
||||||
|
[dict(row) for row in cursor.fetchall()]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_episode(episode_id: int) -> None:
|
||||||
|
with connect() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1
|
||||||
|
WHERE episode_id = %s
|
||||||
|
""",
|
||||||
|
(episode_id,),
|
||||||
|
)
|
||||||
|
episode = cursor.fetchone()
|
||||||
|
if not episode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No existe el episodio {episode_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_reviews
|
||||||
|
WHERE episode_id = %s
|
||||||
|
ORDER BY reviewed_at, review_id
|
||||||
|
""",
|
||||||
|
(episode_id,),
|
||||||
|
)
|
||||||
|
reviews = [
|
||||||
|
dict(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
print_json({
|
||||||
|
"episode": dict(episode),
|
||||||
|
"reviews": reviews,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def apply_review(
|
||||||
|
episode_id: int,
|
||||||
|
status: str,
|
||||||
|
classification: str,
|
||||||
|
reviewed_by: str,
|
||||||
|
notes: str | None,
|
||||||
|
) -> None:
|
||||||
|
with connect() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
episode_id,
|
||||||
|
review_status,
|
||||||
|
review_classification,
|
||||||
|
reviewed_by,
|
||||||
|
reviewed_at,
|
||||||
|
review_notes,
|
||||||
|
episode_status,
|
||||||
|
incident_code,
|
||||||
|
machine_id,
|
||||||
|
production_order
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes
|
||||||
|
WHERE episode_id = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(episode_id,),
|
||||||
|
)
|
||||||
|
previous = cursor.fetchone()
|
||||||
|
if not previous:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No existe el episodio {episode_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_reviews (
|
||||||
|
episode_id,
|
||||||
|
previous_values,
|
||||||
|
new_review_status,
|
||||||
|
review_classification,
|
||||||
|
reviewed_by,
|
||||||
|
notes,
|
||||||
|
evidence_json
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s, %s
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
episode_id,
|
||||||
|
Json(dict(previous)),
|
||||||
|
status,
|
||||||
|
classification,
|
||||||
|
reviewed_by,
|
||||||
|
notes,
|
||||||
|
Json({
|
||||||
|
"source":
|
||||||
|
"shopfloor_context_incident_admin_v037"
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
review = dict(cursor.fetchone())
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1
|
||||||
|
WHERE episode_id = %s
|
||||||
|
""",
|
||||||
|
(episode_id,),
|
||||||
|
)
|
||||||
|
current = dict(cursor.fetchone())
|
||||||
|
|
||||||
|
print_json({
|
||||||
|
"review": review,
|
||||||
|
"episode": current,
|
||||||
|
"warning": (
|
||||||
|
"La revisión no crea una sesión de producción, "
|
||||||
|
"no atribuye pérdida y no modifica Odoo."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Revisión SHADOW de episodios "
|
||||||
|
"de contexto Shop Floor."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(
|
||||||
|
dest="command",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
list_cmd = sub.add_parser(
|
||||||
|
"list",
|
||||||
|
help="Lista el backlog pendiente.",
|
||||||
|
)
|
||||||
|
list_cmd.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
show_cmd = sub.add_parser(
|
||||||
|
"show",
|
||||||
|
help="Muestra un episodio y sus revisiones.",
|
||||||
|
)
|
||||||
|
show_cmd.add_argument(
|
||||||
|
"--episode-id",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
review_cmd = sub.add_parser(
|
||||||
|
"review",
|
||||||
|
help="Clasifica un episodio.",
|
||||||
|
)
|
||||||
|
review_cmd.add_argument(
|
||||||
|
"--episode-id",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
review_cmd.add_argument(
|
||||||
|
"--classification",
|
||||||
|
choices=CLASSIFICATIONS,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
review_cmd.add_argument(
|
||||||
|
"--reviewed-by",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
review_cmd.add_argument(
|
||||||
|
"--notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
dismiss_cmd = sub.add_parser(
|
||||||
|
"dismiss",
|
||||||
|
help="Descarta un episodio como no relevante.",
|
||||||
|
)
|
||||||
|
dismiss_cmd.add_argument(
|
||||||
|
"--episode-id",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
dismiss_cmd.add_argument(
|
||||||
|
"--reviewed-by",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
dismiss_cmd.add_argument(
|
||||||
|
"--notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
if args.command == "list":
|
||||||
|
list_backlog(args.limit)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "show":
|
||||||
|
show_episode(args.episode_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "review":
|
||||||
|
apply_review(
|
||||||
|
episode_id=args.episode_id,
|
||||||
|
status="REVIEWED",
|
||||||
|
classification=args.classification,
|
||||||
|
reviewed_by=args.reviewed_by,
|
||||||
|
notes=args.notes,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "dismiss":
|
||||||
|
apply_review(
|
||||||
|
episode_id=args.episode_id,
|
||||||
|
status="DISMISSED",
|
||||||
|
classification="NOT_RELEVANT",
|
||||||
|
reviewed_by=args.reviewed_by,
|
||||||
|
notes=args.notes,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
raise RuntimeError("Comando no soportado")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
806
ucepsa/edge-oee-demo/tools/shopfloor_context_incident_collector.py
Executable file
806
ucepsa/edge-oee-demo/tools/shopfloor_context_incident_collector.py
Executable file
@ -0,0 +1,806 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Persistencia de incidencias vivas Shop Floor en episodios SHADOW.
|
||||||
|
|
||||||
|
Lee exclusivamente:
|
||||||
|
mv_reports_ucepsa_prod.li_shopfloor_context_incidents_v1
|
||||||
|
|
||||||
|
Escribe exclusivamente en las tablas de historial v0.3.7.
|
||||||
|
No modifica Odoo, sesiones, paros, balizas ni el Ledger oficial.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = "0.3.7"
|
||||||
|
COLLECTOR_NAME = "shopfloor_context_incident_collector_v037"
|
||||||
|
POLICY_CODE = "SHOPFLOOR_CONTEXT_INCIDENT_HISTORY_V1"
|
||||||
|
RUNNING = True
|
||||||
|
|
||||||
|
|
||||||
|
def stop_handler(signum: int, frame: Any) -> None:
|
||||||
|
del signum, frame
|
||||||
|
global RUNNING
|
||||||
|
RUNNING = False
|
||||||
|
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, stop_handler)
|
||||||
|
signal.signal(signal.SIGINT, stop_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def env_first(*names: str, default: str | None = None) -> str | None:
|
||||||
|
for name in names:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value not in (None, ""):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
tenant: str
|
||||||
|
site: str
|
||||||
|
pg_host: str
|
||||||
|
pg_port: int
|
||||||
|
pg_db: str
|
||||||
|
pg_user: str
|
||||||
|
pg_password: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
required = {
|
||||||
|
"PGDATABASE": env_first("PGDATABASE", "POSTGRES_DB"),
|
||||||
|
"PGUSER": env_first("PGUSER", "POSTGRES_USER"),
|
||||||
|
"PGPASSWORD": env_first("PGPASSWORD", "POSTGRES_PASSWORD"),
|
||||||
|
}
|
||||||
|
missing = [name for name, value in required.items() if not value]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Faltan variables obligatorias: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
tenant=env_first(
|
||||||
|
"TENANT",
|
||||||
|
"TENANT_OVERRIDE",
|
||||||
|
default="ucepsa",
|
||||||
|
)
|
||||||
|
or "ucepsa",
|
||||||
|
site=env_first(
|
||||||
|
"SITE",
|
||||||
|
"SITE_OVERRIDE",
|
||||||
|
default="ucepsa_onpremise",
|
||||||
|
)
|
||||||
|
or "ucepsa_onpremise",
|
||||||
|
pg_host=env_first(
|
||||||
|
"PGHOST",
|
||||||
|
default="mv_ucepsa_postgres_hot",
|
||||||
|
)
|
||||||
|
or "mv_ucepsa_postgres_hot",
|
||||||
|
pg_port=int(
|
||||||
|
env_first("PGPORT", default="5432")
|
||||||
|
or "5432"
|
||||||
|
),
|
||||||
|
pg_db=str(required["PGDATABASE"]),
|
||||||
|
pg_user=str(required["PGUSER"]),
|
||||||
|
pg_password=str(required["PGPASSWORD"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connect(config: Config):
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=config.pg_host,
|
||||||
|
port=config.pg_port,
|
||||||
|
dbname=config.pg_db,
|
||||||
|
user=config.pg_user,
|
||||||
|
password=config.pg_password,
|
||||||
|
connect_timeout=10,
|
||||||
|
application_name=COLLECTOR_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_fingerprint(row: dict[str, Any]) -> str:
|
||||||
|
identity = {
|
||||||
|
"incident_key": row.get("incident_key"),
|
||||||
|
"incident_scope": row.get("incident_scope"),
|
||||||
|
"incident_code": row.get("incident_code"),
|
||||||
|
"machine_id": row.get("machine_id"),
|
||||||
|
"context_key": row.get("context_key"),
|
||||||
|
"production_order": row.get("production_order"),
|
||||||
|
"odoo_workorder_id": row.get("odoo_workorder_id"),
|
||||||
|
}
|
||||||
|
raw = json.dumps(
|
||||||
|
identity,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def severity_rank(value: str | None) -> int:
|
||||||
|
return {
|
||||||
|
"WARNING": 1,
|
||||||
|
"CRITICAL": 2,
|
||||||
|
}.get(value or "", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def review_policy(row: dict[str, Any]) -> tuple[bool, str]:
|
||||||
|
code = str(row.get("incident_code") or "")
|
||||||
|
scope = str(row.get("incident_scope") or "")
|
||||||
|
severity = str(row.get("severity") or "")
|
||||||
|
|
||||||
|
production_codes = {
|
||||||
|
"RUNNING_WITHOUT_SHOPFLOOR_SESSION",
|
||||||
|
"ORDER_WITHOUT_SHOPFLOOR_SESSION",
|
||||||
|
}
|
||||||
|
|
||||||
|
if code in production_codes:
|
||||||
|
return True, "PRODUCTION"
|
||||||
|
|
||||||
|
if severity == "CRITICAL":
|
||||||
|
return True, "TECHNICAL"
|
||||||
|
|
||||||
|
if scope in {"MACHINE", "SESSION"}:
|
||||||
|
return True, "TECHNICAL"
|
||||||
|
|
||||||
|
if code in {
|
||||||
|
"SHOPFLOOR_SYNC_STALE",
|
||||||
|
"SHOPFLOOR_SYNC_ERROR",
|
||||||
|
"SHOPFLOOR_SYNC_NOT_INITIALIZED",
|
||||||
|
}:
|
||||||
|
return True, "TECHNICAL"
|
||||||
|
|
||||||
|
return False, "TECHNICAL"
|
||||||
|
|
||||||
|
|
||||||
|
def source_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"incident_key": row.get("incident_key"),
|
||||||
|
"incident_scope": row.get("incident_scope"),
|
||||||
|
"incident_code": row.get("incident_code"),
|
||||||
|
"machine_id": row.get("machine_id"),
|
||||||
|
"context_key": row.get("context_key"),
|
||||||
|
"production_order": row.get("production_order"),
|
||||||
|
"odoo_workorder_id": row.get("odoo_workorder_id"),
|
||||||
|
"operator_name": row.get("operator_name"),
|
||||||
|
"severity": row.get("severity"),
|
||||||
|
"detail": row.get("detail"),
|
||||||
|
"recommended_action": row.get("recommended_action"),
|
||||||
|
"source_observed_at": (
|
||||||
|
row.get("observed_at").isoformat()
|
||||||
|
if isinstance(row.get("observed_at"), datetime)
|
||||||
|
else row.get("observed_at")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_policy(cursor, config: Config) -> dict[str, Any]:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
collector_interval_s,
|
||||||
|
close_grace_s,
|
||||||
|
collector_delayed_after_s,
|
||||||
|
collector_stale_after_s
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_policies
|
||||||
|
WHERE tenant = %s
|
||||||
|
AND site = %s
|
||||||
|
AND policy_code = %s
|
||||||
|
AND active
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
POLICY_CODE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No existe política activa "
|
||||||
|
f"{POLICY_CODE!r}"
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_incidents(
|
||||||
|
cursor,
|
||||||
|
config: Config,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
incident_key,
|
||||||
|
incident_scope,
|
||||||
|
observed_at,
|
||||||
|
severity,
|
||||||
|
incident_code,
|
||||||
|
machine_id,
|
||||||
|
context_key,
|
||||||
|
production_order,
|
||||||
|
odoo_workorder_id,
|
||||||
|
operator_name,
|
||||||
|
detail,
|
||||||
|
recommended_action
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incidents_v1
|
||||||
|
WHERE official_eligible = false
|
||||||
|
ORDER BY
|
||||||
|
incident_scope,
|
||||||
|
machine_id NULLS FIRST,
|
||||||
|
incident_code
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def current_db_time(cursor) -> datetime:
|
||||||
|
cursor.execute("SELECT now() AS db_now")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row["db_now"]
|
||||||
|
|
||||||
|
|
||||||
|
def max_severity(
|
||||||
|
existing: str,
|
||||||
|
incoming: str,
|
||||||
|
) -> str:
|
||||||
|
if severity_rank(incoming) > severity_rank(existing):
|
||||||
|
return incoming
|
||||||
|
return existing
|
||||||
|
|
||||||
|
|
||||||
|
def process_cycle(
|
||||||
|
config: Config,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
opened = 0
|
||||||
|
updated = 0
|
||||||
|
closed = 0
|
||||||
|
|
||||||
|
with connect(config) as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT pg_try_advisory_xact_lock(
|
||||||
|
hashtext(%s)
|
||||||
|
) AS locked
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
(
|
||||||
|
f"{config.tenant}:"
|
||||||
|
f"{config.site}:"
|
||||||
|
f"{COLLECTOR_NAME}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not cursor.fetchone()["locked"]:
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"status": "skipped_lock_busy",
|
||||||
|
}
|
||||||
|
|
||||||
|
policy = fetch_policy(cursor, config)
|
||||||
|
db_now = current_db_time(cursor)
|
||||||
|
incidents = fetch_current_incidents(
|
||||||
|
cursor,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
|
||||||
|
deduplicated: dict[str, dict[str, Any]] = {}
|
||||||
|
for incident in incidents:
|
||||||
|
fingerprint = canonical_fingerprint(incident)
|
||||||
|
current = deduplicated.get(fingerprint)
|
||||||
|
if current is None:
|
||||||
|
deduplicated[fingerprint] = incident
|
||||||
|
continue
|
||||||
|
if severity_rank(
|
||||||
|
incident.get("severity")
|
||||||
|
) >= severity_rank(
|
||||||
|
current.get("severity")
|
||||||
|
):
|
||||||
|
deduplicated[fingerprint] = incident
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"status": "dry_run",
|
||||||
|
"db_now": db_now.isoformat(),
|
||||||
|
"policy": policy,
|
||||||
|
"current_incident_count": len(
|
||||||
|
deduplicated
|
||||||
|
),
|
||||||
|
"incidents": [
|
||||||
|
{
|
||||||
|
**row,
|
||||||
|
"incident_fingerprint":
|
||||||
|
fingerprint,
|
||||||
|
}
|
||||||
|
for fingerprint, row
|
||||||
|
in deduplicated.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
current_fingerprints: list[str] = []
|
||||||
|
|
||||||
|
for fingerprint, row in deduplicated.items():
|
||||||
|
current_fingerprints.append(fingerprint)
|
||||||
|
review_required, review_owner = review_policy(
|
||||||
|
row
|
||||||
|
)
|
||||||
|
observed_at = (
|
||||||
|
row.get("observed_at")
|
||||||
|
or db_now
|
||||||
|
)
|
||||||
|
payload = source_payload(row)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
episode_id,
|
||||||
|
max_severity,
|
||||||
|
review_status,
|
||||||
|
review_required
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes
|
||||||
|
WHERE tenant = %s
|
||||||
|
AND site = %s
|
||||||
|
AND incident_fingerprint = %s
|
||||||
|
AND episode_status = 'OPEN'
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
fingerprint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
initial_review_status = (
|
||||||
|
"PENDING"
|
||||||
|
if review_required
|
||||||
|
else "NOT_REQUIRED"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
incident_fingerprint,
|
||||||
|
incident_key,
|
||||||
|
incident_scope,
|
||||||
|
incident_code,
|
||||||
|
machine_id,
|
||||||
|
context_key,
|
||||||
|
production_order,
|
||||||
|
odoo_workorder_id,
|
||||||
|
operator_name,
|
||||||
|
initial_severity,
|
||||||
|
current_severity,
|
||||||
|
max_severity,
|
||||||
|
episode_status,
|
||||||
|
first_seen_at,
|
||||||
|
last_seen_at,
|
||||||
|
observation_count,
|
||||||
|
initial_detail,
|
||||||
|
latest_detail,
|
||||||
|
initial_recommended_action,
|
||||||
|
latest_recommended_action,
|
||||||
|
review_required,
|
||||||
|
review_owner,
|
||||||
|
review_status,
|
||||||
|
source_payload_json,
|
||||||
|
official_eligible
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
|
'OPEN',
|
||||||
|
%s, %s, 1,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
|
%s, false
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
fingerprint,
|
||||||
|
row.get("incident_key"),
|
||||||
|
row.get("incident_scope"),
|
||||||
|
row.get("incident_code"),
|
||||||
|
row.get("machine_id"),
|
||||||
|
row.get("context_key"),
|
||||||
|
row.get("production_order"),
|
||||||
|
row.get("odoo_workorder_id"),
|
||||||
|
row.get("operator_name"),
|
||||||
|
row.get("severity"),
|
||||||
|
row.get("severity"),
|
||||||
|
row.get("severity"),
|
||||||
|
observed_at,
|
||||||
|
observed_at,
|
||||||
|
row.get("detail"),
|
||||||
|
row.get("detail"),
|
||||||
|
row.get(
|
||||||
|
"recommended_action"
|
||||||
|
),
|
||||||
|
row.get(
|
||||||
|
"recommended_action"
|
||||||
|
),
|
||||||
|
review_required,
|
||||||
|
review_owner,
|
||||||
|
initial_review_status,
|
||||||
|
Json(payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
opened += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
next_max = max_severity(
|
||||||
|
str(existing["max_severity"]),
|
||||||
|
str(row.get("severity")),
|
||||||
|
)
|
||||||
|
next_required = (
|
||||||
|
bool(existing["review_required"])
|
||||||
|
or review_required
|
||||||
|
)
|
||||||
|
next_status = str(
|
||||||
|
existing["review_status"]
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
next_required
|
||||||
|
and next_status == "NOT_REQUIRED"
|
||||||
|
):
|
||||||
|
next_status = "PENDING"
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes
|
||||||
|
SET
|
||||||
|
incident_key = %s,
|
||||||
|
incident_scope = %s,
|
||||||
|
incident_code = %s,
|
||||||
|
machine_id = %s,
|
||||||
|
context_key = %s,
|
||||||
|
production_order = %s,
|
||||||
|
odoo_workorder_id = %s,
|
||||||
|
operator_name = %s,
|
||||||
|
current_severity = %s,
|
||||||
|
max_severity = %s,
|
||||||
|
last_seen_at = GREATEST(
|
||||||
|
last_seen_at,
|
||||||
|
%s
|
||||||
|
),
|
||||||
|
observation_count =
|
||||||
|
observation_count + 1,
|
||||||
|
latest_detail = %s,
|
||||||
|
latest_recommended_action = %s,
|
||||||
|
review_required = %s,
|
||||||
|
review_owner = %s,
|
||||||
|
review_status = %s,
|
||||||
|
source_payload_json = %s,
|
||||||
|
updated_at = %s
|
||||||
|
WHERE episode_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
row.get("incident_key"),
|
||||||
|
row.get("incident_scope"),
|
||||||
|
row.get("incident_code"),
|
||||||
|
row.get("machine_id"),
|
||||||
|
row.get("context_key"),
|
||||||
|
row.get("production_order"),
|
||||||
|
row.get("odoo_workorder_id"),
|
||||||
|
row.get("operator_name"),
|
||||||
|
row.get("severity"),
|
||||||
|
next_max,
|
||||||
|
observed_at,
|
||||||
|
row.get("detail"),
|
||||||
|
row.get(
|
||||||
|
"recommended_action"
|
||||||
|
),
|
||||||
|
next_required,
|
||||||
|
review_owner,
|
||||||
|
next_status,
|
||||||
|
Json(payload),
|
||||||
|
db_now,
|
||||||
|
existing["episode_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_episodes
|
||||||
|
SET
|
||||||
|
episode_status = 'CLOSED',
|
||||||
|
ended_at = last_seen_at,
|
||||||
|
closed_detected_at = %s,
|
||||||
|
close_reason =
|
||||||
|
'INCIDENT_NO_LONGER_PRESENT',
|
||||||
|
current_severity =
|
||||||
|
max_severity,
|
||||||
|
updated_at = %s
|
||||||
|
WHERE tenant = %s
|
||||||
|
AND site = %s
|
||||||
|
AND episode_status = 'OPEN'
|
||||||
|
AND last_seen_at
|
||||||
|
< %s
|
||||||
|
- (
|
||||||
|
%s
|
||||||
|
* interval '1 second'
|
||||||
|
)
|
||||||
|
AND NOT (
|
||||||
|
incident_fingerprint
|
||||||
|
= ANY(%s::text[])
|
||||||
|
)
|
||||||
|
RETURNING episode_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
db_now,
|
||||||
|
db_now,
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
db_now,
|
||||||
|
int(policy["close_grace_s"]),
|
||||||
|
current_fingerprints,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
closed = len(cursor.fetchall())
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_collector_state (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
collector_name,
|
||||||
|
last_success_at,
|
||||||
|
last_current_count,
|
||||||
|
last_opened_count,
|
||||||
|
last_updated_count,
|
||||||
|
last_closed_count,
|
||||||
|
last_error,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
NULL, %s
|
||||||
|
)
|
||||||
|
ON CONFLICT (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
collector_name
|
||||||
|
)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_success_at =
|
||||||
|
EXCLUDED.last_success_at,
|
||||||
|
last_current_count =
|
||||||
|
EXCLUDED.last_current_count,
|
||||||
|
last_opened_count =
|
||||||
|
EXCLUDED.last_opened_count,
|
||||||
|
last_updated_count =
|
||||||
|
EXCLUDED.last_updated_count,
|
||||||
|
last_closed_count =
|
||||||
|
EXCLUDED.last_closed_count,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at =
|
||||||
|
EXCLUDED.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
COLLECTOR_NAME,
|
||||||
|
db_now,
|
||||||
|
len(deduplicated),
|
||||||
|
opened,
|
||||||
|
updated,
|
||||||
|
closed,
|
||||||
|
db_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"status": "ok",
|
||||||
|
"db_now": db_now.isoformat(),
|
||||||
|
"current_incident_count": len(deduplicated),
|
||||||
|
"opened_count": opened,
|
||||||
|
"updated_count": updated,
|
||||||
|
"closed_count": closed,
|
||||||
|
"close_grace_s": int(
|
||||||
|
policy["close_grace_s"]
|
||||||
|
),
|
||||||
|
"collector_interval_s": int(
|
||||||
|
policy["collector_interval_s"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def record_error(
|
||||||
|
config: Config,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
with connect(config) as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.shopfloor_context_incident_collector_state (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
collector_name,
|
||||||
|
last_error,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
collector_name
|
||||||
|
)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_error =
|
||||||
|
EXCLUDED.last_error,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
COLLECTOR_NAME,
|
||||||
|
str(error)[:4000],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as state_error:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status":
|
||||||
|
"collector_error_state_write_failed",
|
||||||
|
"error": str(error),
|
||||||
|
"state_error": str(state_error),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Persistencia SHADOW de incidencias "
|
||||||
|
"Shop Floor."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mode = parser.add_mutually_exclusive_group(
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
mode.add_argument(
|
||||||
|
"--once",
|
||||||
|
action="store_true",
|
||||||
|
help="Ejecuta un ciclo y termina.",
|
||||||
|
)
|
||||||
|
mode.add_argument(
|
||||||
|
"--watch",
|
||||||
|
action="store_true",
|
||||||
|
help="Ejecuta ciclos continuos.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Intervalo del modo watch. "
|
||||||
|
"Por defecto usa la política SQL."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Lee y muestra, pero no escribe.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
while RUNNING:
|
||||||
|
try:
|
||||||
|
result = process_cycle(
|
||||||
|
config,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
result,
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.once or args.dry_run:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
interval = args.interval
|
||||||
|
if interval is None:
|
||||||
|
interval = int(
|
||||||
|
result.get(
|
||||||
|
"collector_interval_s",
|
||||||
|
15,
|
||||||
|
)
|
||||||
|
or 15
|
||||||
|
)
|
||||||
|
for _ in range(max(interval, 1)):
|
||||||
|
if not RUNNING:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
record_error(config, error)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": VERSION,
|
||||||
|
"status": "error",
|
||||||
|
"error": str(error),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if args.once or args.dry_run:
|
||||||
|
return 1
|
||||||
|
for _ in range(
|
||||||
|
max(args.interval or 15, 1)
|
||||||
|
):
|
||||||
|
if not RUNNING:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
x
Reference in New Issue
Block a user