feat(ucepsa): add canonical context arbitration shadow
This commit is contained in:
parent
7e8a676d53
commit
f9095e6a7b
@ -0,0 +1,186 @@
|
|||||||
|
# UCEPSA — Arbitraje canónico de contexto SHADOW v0.3.8
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Convertir varias fuentes posibles de contexto en una decisión única y
|
||||||
|
reproducible por tramo temporal.
|
||||||
|
|
||||||
|
```text
|
||||||
|
una fuente autoritativa
|
||||||
|
→ contexto canónico
|
||||||
|
|
||||||
|
cero fuentes autoritativas + actividad
|
||||||
|
→ UNCONFIRMED_CONTEXT
|
||||||
|
|
||||||
|
más de una fuente autoritativa
|
||||||
|
→ CONTEXT_CONFLICT
|
||||||
|
```
|
||||||
|
|
||||||
|
La versión continúa íntegramente en SHADOW.
|
||||||
|
|
||||||
|
## Fuentes autoritativas
|
||||||
|
|
||||||
|
```text
|
||||||
|
ODOO_SHOPFLOOR
|
||||||
|
LEGACY_ERP autorizado
|
||||||
|
MANUAL_AUTHORIZED
|
||||||
|
MAINTENANCE
|
||||||
|
TEST_SETUP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidencias auxiliares
|
||||||
|
|
||||||
|
```text
|
||||||
|
ODOO_ORDER_CONTEXT
|
||||||
|
INFERRED_SHADOW
|
||||||
|
```
|
||||||
|
|
||||||
|
Una orden publicada o una inferencia nunca se promueven por sí solas a contexto
|
||||||
|
canónico.
|
||||||
|
|
||||||
|
## Beneficios operativos
|
||||||
|
|
||||||
|
### Una sola respuesta por tramo
|
||||||
|
|
||||||
|
Antes, una consulta podía usar orden Odoo, otra la sesión Shop Floor y otra el
|
||||||
|
contexto legacy. Ahora todas pueden consultar la misma clave canónica.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CORT-02
|
||||||
|
ODOO_SHOPFLOOR:WORKORDER:340
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evita doble contabilización
|
||||||
|
|
||||||
|
Si alguien abre un contexto legacy mientras continúa una sesión Shop Floor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Shop Floor + legacy
|
||||||
|
→ CONTEXT_CONFLICT
|
||||||
|
→ ningún paro es elegible
|
||||||
|
```
|
||||||
|
|
||||||
|
El sistema no aplica una prioridad silenciosa.
|
||||||
|
|
||||||
|
### Protege frente a falsas pérdidas
|
||||||
|
|
||||||
|
CORT-00 en marcha sin contexto explícito permanece:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UNCONFIRMED_CONTEXT
|
||||||
|
```
|
||||||
|
|
||||||
|
No se inventa una orden, un operario ni una pérdida económica.
|
||||||
|
|
||||||
|
### Permite convivir durante la migración
|
||||||
|
|
||||||
|
Una orden fabricada con el sistema anterior puede usar un contexto
|
||||||
|
`LEGACY_ERP` autorizado sin obligar a fingir que pasó por Shop Floor.
|
||||||
|
|
||||||
|
### Simplifica el producto
|
||||||
|
|
||||||
|
Las capas posteriores dejan de implementar reglas diferentes para Odoo,
|
||||||
|
legacy y manual. Consultan:
|
||||||
|
|
||||||
|
```text
|
||||||
|
canonical_context_key
|
||||||
|
canonical_context_source
|
||||||
|
canonical_context_status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Facilita la activación futura
|
||||||
|
|
||||||
|
La misma capa permite medir cobertura y conflictos antes de decidir si puede
|
||||||
|
alimentar el Loss Ledger oficial.
|
||||||
|
|
||||||
|
## Nuevos objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_arbitration_source_policies
|
||||||
|
|
||||||
|
li_canonical_context_candidates_v1
|
||||||
|
li_canonical_context_segments_v1
|
||||||
|
li_context_arbitration_conflicts_v1
|
||||||
|
li_machine_canonical_context_live_v1
|
||||||
|
li_canonical_context_coverage_v1
|
||||||
|
li_stop_canonical_context_slices_v1
|
||||||
|
li_stop_canonical_context_shadow_v1
|
||||||
|
li_canonical_context_diagnostics_v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/110_ucepsa_canonical_context_arbitration_shadow_v038.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
No es necesario detener ningún servicio.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_canonical_context_arbitration_v038.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
|
||||||
|
candidate_key_collisions = 0
|
||||||
|
invalid_segment_intervals = 0
|
||||||
|
negative_segment_durations = 0
|
||||||
|
one_candidate_without_canonical_key = 0
|
||||||
|
ambiguous_with_canonical_key = 0
|
||||||
|
supporting_promoted_to_canonical = 0
|
||||||
|
conflict_without_review = 0
|
||||||
|
live_machine_rows = 3
|
||||||
|
distinct_live_machines = 3
|
||||||
|
official_live_rows = 0
|
||||||
|
stop_duration_overflow_count = 0
|
||||||
|
open_stops_marked_eligible = 0
|
||||||
|
nonproductive_stops_marked_eligible = 0
|
||||||
|
ambiguous_stops_marked_eligible = 0
|
||||||
|
official_canonical_stop_rows = 0
|
||||||
|
official_canonical_segment_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lectura esperada
|
||||||
|
|
||||||
|
Mientras la situación actual continúe:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CORT-00
|
||||||
|
→ UNCONFIRMED_CONTEXT
|
||||||
|
|
||||||
|
CORT-01
|
||||||
|
→ NO_ACTIVE_CONTEXT
|
||||||
|
|
||||||
|
CORT-02
|
||||||
|
→ ODOO_SHOPFLOOR
|
||||||
|
→ ODOO_SHOPFLOOR:WORKORDER:340
|
||||||
|
```
|
||||||
|
|
||||||
|
Si las máquinas cambian durante la validación, los estados pueden variar, pero
|
||||||
|
las invariantes deben mantenerse.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se amplía el dashboard existente con:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Contexto canónico vivo por máquina
|
||||||
|
Conflictos de arbitraje · últimas 24 horas
|
||||||
|
Cobertura de contexto canónico · últimos 30 días
|
||||||
|
```
|
||||||
|
|
||||||
|
El refresco queda fijado en 15 segundos.
|
||||||
@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"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.",
|
"description": "Supervisión SHADOW de máquina, Odoo Shop Floor, contextos explícitos, histórico de incidencias y arbitraje canónico. Cero fuentes autoritativas queda sin confirmar y más de una queda bloqueada por conflicto. No alimenta el Ledger oficial.",
|
||||||
"editable": true,
|
"editable": true,
|
||||||
"fiscalYearStartMonth": 0,
|
"fiscalYearStartMonth": 0,
|
||||||
"graphTooltip": 1,
|
"graphTooltip": 1,
|
||||||
@ -1614,9 +1614,315 @@
|
|||||||
],
|
],
|
||||||
"title": "Resumen diario de incidencias · últimos 7 días",
|
"title": "Resumen diario de incidencias · últimos 7 días",
|
||||||
"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": "Salud"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "mappings",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"OK": {
|
||||||
|
"text": "OK",
|
||||||
|
"color": "green",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
"INFO": {
|
||||||
|
"text": "INFO",
|
||||||
|
"color": "blue",
|
||||||
|
"index": 1
|
||||||
|
},
|
||||||
|
"WARNING": {
|
||||||
|
"text": "WARNING",
|
||||||
|
"color": "orange",
|
||||||
|
"index": 2
|
||||||
|
},
|
||||||
|
"CRITICAL": {
|
||||||
|
"text": "CRITICAL",
|
||||||
|
"color": "red",
|
||||||
|
"index": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.cellOptions",
|
||||||
|
"value": {
|
||||||
|
"type": "color-text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 110
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Contexto canónico"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 290
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Acción"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 520
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 71
|
||||||
|
},
|
||||||
|
"id": 14,
|
||||||
|
"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 machine_id AS \"Máquina\",\n CASE\n WHEN machine_running THEN 'Sí'\n ELSE 'No'\n END AS \"Marcha\",\n CASE canonical_context_status\n WHEN 'ODOO_SHOPFLOOR'\n THEN 'Odoo Shop Floor'\n WHEN 'LEGACY_ERP'\n THEN 'Producción legacy autorizada'\n WHEN 'MANUAL_AUTHORIZED'\n THEN 'Producción manual autorizada'\n WHEN 'MAINTENANCE'\n THEN 'Mantenimiento'\n WHEN 'TEST_SETUP'\n THEN 'Prueba / ajuste'\n WHEN 'UNCONFIRMED_CONTEXT'\n THEN 'Actividad sin contexto confirmado'\n WHEN 'CONTEXT_CONFLICT'\n THEN 'Conflicto de contextos'\n WHEN 'NO_ACTIVE_CONTEXT'\n THEN 'Sin contexto activo'\n ELSE canonical_context_status\n END AS \"Contexto canónico\",\n COALESCE(\n canonical_order_ref,\n '—'\n ) AS \"Orden\",\n COALESCE(\n canonical_odoo_workorder_id::text,\n '—'\n ) AS \"Workorder\",\n COALESCE(\n operator_name,\n operator_candidate_name,\n '—'\n ) AS \"Operario\",\n authoritative_candidate_count\n AS \"Fuentes autoritativas\",\n order_alignment_status\n AS \"Coherencia Odoo\",\n canonical_health_status\n AS \"Salud\",\n recommended_action\n AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_machine_canonical_context_live_v1\nORDER BY machine_id",
|
||||||
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"refresh": "5s",
|
"title": "Contexto canónico vivo por máquina",
|
||||||
|
"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 de conflicto"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 330
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Acción"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 520
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 81
|
||||||
|
},
|
||||||
|
"id": 15,
|
||||||
|
"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 segment_from AS \"Inicio\",\n segment_to AS \"Fin\",\n ROUND(\n segment_duration_s / 60.0,\n 2\n ) AS \"Duración min\",\n machine_id AS \"Máquina\",\n conflict_type AS \"Tipo de conflicto\",\n array_to_string(\n authoritative_context_sources,\n ', '\n ) AS \"Fuentes\",\n array_to_string(\n authoritative_context_keys,\n ', '\n ) AS \"Claves\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_arbitration_conflicts_v1\nWHERE segment_to >=\n now() - interval '24 hours'\nORDER BY segment_from DESC\nLIMIT 100",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Conflictos de arbitraje · ú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": "Contexto"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 260
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 90
|
||||||
|
},
|
||||||
|
"id": 16,
|
||||||
|
"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 machine_id AS \"Máquina\",\n CASE canonical_context_status\n WHEN 'ODOO_SHOPFLOOR'\n THEN 'Odoo Shop Floor'\n WHEN 'LEGACY_ERP'\n THEN 'Legacy autorizada'\n WHEN 'MANUAL_AUTHORIZED'\n THEN 'Manual autorizada'\n WHEN 'MAINTENANCE'\n THEN 'Mantenimiento'\n WHEN 'TEST_SETUP'\n THEN 'Prueba / ajuste'\n WHEN 'UNCONFIRMED_CONTEXT'\n THEN 'Sin confirmar'\n WHEN 'CONTEXT_CONFLICT'\n THEN 'Conflicto'\n ELSE canonical_context_status\n END AS \"Contexto\",\n segment_count AS \"Segmentos\",\n review_segment_count AS \"A revisar\",\n hours AS \"Horas\",\n machine_activity_pct AS \"% actividad\"\nFROM\n mv_reports_ucepsa_prod\n .li_canonical_context_coverage_v1\nORDER BY\n machine_id,\n hours DESC",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Cobertura de contexto canónico · últimos 30 días",
|
||||||
|
"type": "table"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "15s",
|
||||||
"schemaVersion": 42,
|
"schemaVersion": 42,
|
||||||
"tags": [
|
"tags": [
|
||||||
"mesavault",
|
"mesavault",
|
||||||
@ -1626,7 +1932,10 @@
|
|||||||
"shadow",
|
"shadow",
|
||||||
"presentation-v0362",
|
"presentation-v0362",
|
||||||
"incident-history",
|
"incident-history",
|
||||||
"v037"
|
"v037",
|
||||||
|
"canonical-context",
|
||||||
|
"arbitration",
|
||||||
|
"v038"
|
||||||
],
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": []
|
"list": []
|
||||||
|
|||||||
257
ucepsa/edge-oee-demo/ops/deploy_canonical_context_arbitration_dashboard_v038.sh
Executable file
257
ucepsa/edge-oee-demo/ops/deploy_canonical_context_arbitration_dashboard_v038.sh
Executable file
@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${CANONICAL_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${CANONICAL_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${CANONICAL_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 canonical context "
|
||||||
|
"arbitration SHADOW v0.3.8"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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", [])) != 16:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 16 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
14: "li_machine_canonical_context_live_v1",
|
||||||
|
15: "li_context_arbitration_conflicts_v1",
|
||||||
|
16: "li_canonical_context_coverage_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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está alineado a 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
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", [])
|
||||||
|
),
|
||||||
|
"refresh": dashboard.get("refresh"),
|
||||||
|
"datasource_uids": datasource_uids,
|
||||||
|
"version": dashboard.get("version"),
|
||||||
|
"url": result.get("meta", {}).get("url"),
|
||||||
|
"canonical_panels": {
|
||||||
|
"live": 14 in panels,
|
||||||
|
"conflicts": 15 in panels,
|
||||||
|
"coverage": 16 in panels,
|
||||||
|
},
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: "
|
||||||
|
"deploy_canonical_context_arbitration_dashboard_v038.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
@ -0,0 +1,268 @@
|
|||||||
|
\pset pager off
|
||||||
|
|
||||||
|
\echo '=== 1. Objetos v0.3.8 ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_arbitration_source_policies'
|
||||||
|
) AS policy_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_canonical_context_candidates_v1'
|
||||||
|
) AS candidates_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_canonical_context_segments_v1'
|
||||||
|
) AS segments_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_arbitration_conflicts_v1'
|
||||||
|
) AS conflicts_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_machine_canonical_context_live_v1'
|
||||||
|
) AS live_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_canonical_context_coverage_v1'
|
||||||
|
) AS coverage_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_stop_canonical_context_slices_v1'
|
||||||
|
) AS stop_slices_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_stop_canonical_context_shadow_v1'
|
||||||
|
) AS stop_shadow_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_canonical_context_diagnostics_v1'
|
||||||
|
) AS diagnostics_view;
|
||||||
|
|
||||||
|
\echo '=== 2. Política de fuentes ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
source_type,
|
||||||
|
candidate_role,
|
||||||
|
source_rank,
|
||||||
|
productive_context,
|
||||||
|
active
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_arbitration_source_policies
|
||||||
|
WHERE tenant = 'ucepsa'
|
||||||
|
AND site = 'ucepsa_onpremise'
|
||||||
|
AND policy_code =
|
||||||
|
'CUTTER_CANONICAL_CONTEXT_V1'
|
||||||
|
ORDER BY source_rank;
|
||||||
|
|
||||||
|
\echo '=== 3. Claves de candidato sin colisiones ==='
|
||||||
|
|
||||||
|
WITH duplicated AS (
|
||||||
|
SELECT
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
context_key,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_candidates_v1
|
||||||
|
GROUP BY
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
context_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) AS candidate_key_collisions
|
||||||
|
FROM duplicated;
|
||||||
|
|
||||||
|
\echo '=== 4. Invariantes temporales de segmentos ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE segment_to <= segment_from
|
||||||
|
) AS invalid_segment_intervals,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE segment_duration_s < 0
|
||||||
|
) AS negative_segment_durations
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_segments_v1;
|
||||||
|
|
||||||
|
\echo '=== 5. Semántica canónica ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE authoritative_candidate_count = 1
|
||||||
|
AND canonical_context_key IS NULL
|
||||||
|
) AS one_candidate_without_canonical_key,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE authoritative_candidate_count <> 1
|
||||||
|
AND canonical_context_key IS NOT NULL
|
||||||
|
) AS ambiguous_with_canonical_key,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE canonical_context_source IN (
|
||||||
|
'ODOO_ORDER_CONTEXT',
|
||||||
|
'INFERRED_SHADOW'
|
||||||
|
)
|
||||||
|
) AS supporting_promoted_to_canonical,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE canonical_context_status =
|
||||||
|
'CONTEXT_CONFLICT'
|
||||||
|
AND NOT review_required
|
||||||
|
) AS conflict_without_review
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_segments_v1;
|
||||||
|
|
||||||
|
\echo '=== 6. Una fila viva por máquina ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS live_machine_rows,
|
||||||
|
COUNT(
|
||||||
|
DISTINCT machine_id
|
||||||
|
) AS distinct_live_machines,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE official_ledger_eligible
|
||||||
|
) AS official_live_rows
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_machine_canonical_context_live_v1;
|
||||||
|
|
||||||
|
\echo '=== 7. Estado vivo actual ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
machine_id,
|
||||||
|
telemetry_fresh,
|
||||||
|
machine_running,
|
||||||
|
published_production_order,
|
||||||
|
published_workorder_id,
|
||||||
|
authoritative_candidate_count,
|
||||||
|
authoritative_context_sources,
|
||||||
|
canonical_context_key,
|
||||||
|
canonical_context_source,
|
||||||
|
canonical_context_status,
|
||||||
|
canonical_order_ref,
|
||||||
|
canonical_odoo_workorder_id,
|
||||||
|
operator_name,
|
||||||
|
operator_candidate_name,
|
||||||
|
order_alignment_status,
|
||||||
|
canonical_health_status,
|
||||||
|
recommended_action,
|
||||||
|
official_ledger_eligible
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_machine_canonical_context_live_v1
|
||||||
|
ORDER BY machine_id;
|
||||||
|
|
||||||
|
\echo '=== 8. Candidatos históricos conocidos ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
source_type,
|
||||||
|
candidate_role,
|
||||||
|
context_key,
|
||||||
|
machine_id,
|
||||||
|
order_ref,
|
||||||
|
odoo_workorder_id,
|
||||||
|
context_from,
|
||||||
|
context_to,
|
||||||
|
context_record_status,
|
||||||
|
authorization_status,
|
||||||
|
operator_name,
|
||||||
|
operator_candidate_name,
|
||||||
|
official_eligible
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_candidates_v1
|
||||||
|
WHERE odoo_workorder_id IN (
|
||||||
|
87,
|
||||||
|
810,
|
||||||
|
340
|
||||||
|
)
|
||||||
|
OR production_context_session_id = 1
|
||||||
|
ORDER BY
|
||||||
|
context_from,
|
||||||
|
source_rank,
|
||||||
|
context_key;
|
||||||
|
|
||||||
|
\echo '=== 9. Conflictos de arbitraje ==='
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_arbitration_conflicts_v1
|
||||||
|
WHERE segment_to >=
|
||||||
|
now() - interval '24 hours'
|
||||||
|
ORDER BY segment_from DESC
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
\echo '=== 10. Duración de slices no supera el paro raw ==='
|
||||||
|
|
||||||
|
WITH totals AS (
|
||||||
|
SELECT
|
||||||
|
source_stop_id,
|
||||||
|
MAX(raw_duration_s)
|
||||||
|
AS raw_duration_s,
|
||||||
|
SUM(segment_duration_s)
|
||||||
|
AS sliced_duration_s
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_stop_canonical_context_slices_v1
|
||||||
|
GROUP BY source_stop_id
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) AS stop_duration_overflow_count
|
||||||
|
FROM totals
|
||||||
|
WHERE sliced_duration_s >
|
||||||
|
raw_duration_s + 1::numeric;
|
||||||
|
|
||||||
|
\echo '=== 11. Paros abiertos y contextos no productivos no elegibles ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE source_status = 'OPEN'
|
||||||
|
AND technically_eligible
|
||||||
|
) AS open_stops_marked_eligible,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE NOT canonical_productive_context
|
||||||
|
AND technically_eligible
|
||||||
|
) AS nonproductive_stops_marked_eligible,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE authoritative_candidate_count <> 1
|
||||||
|
AND technically_eligible
|
||||||
|
) AS ambiguous_stops_marked_eligible
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_stop_canonical_context_shadow_v1;
|
||||||
|
|
||||||
|
\echo '=== 12. Nada oficial ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_stop_canonical_context_shadow_v1
|
||||||
|
WHERE official_ledger_eligible
|
||||||
|
) AS official_canonical_stop_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_segments_v1
|
||||||
|
WHERE official_ledger_eligible
|
||||||
|
) AS official_canonical_segment_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_operator_stop_queue_v2
|
||||||
|
) AS official_operator_queue_rows;
|
||||||
|
|
||||||
|
\echo '=== 13. Diagnóstico 24 horas ==='
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_diagnostics_v1;
|
||||||
|
|
||||||
|
\echo '=== 14. Cobertura canónica 30 días ==='
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_coverage_v1
|
||||||
|
ORDER BY
|
||||||
|
machine_id,
|
||||||
|
hours DESC;
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user