feat(ucepsa): add canonical context rollout readiness shadow

This commit is contained in:
Victor Fraile Garcia 2026-07-20 14:12:56 +02:00
parent 2efcb750a3
commit ce086b697c
5 changed files with 1939 additions and 2 deletions

View File

@ -0,0 +1,161 @@
# UCEPSA — Readiness de rollout del contexto canónico v0.3.9
## Objetivo
Responder con números a esta pregunta:
> ¿Qué le falta a cada cortadora antes de poder usar el contexto canónico en un
> piloto controlado?
La versión no activa el Ledger oficial. Incluso una máquina en
`READY_FOR_CONTROLLED_SHADOW` conserva:
```text
ready_for_official_ledger = false
official_ledger_eligible = false
```
## Criterios iniciales
Ventana:
```text
7 días
```
Umbrales configurables:
```text
mínimo observado 8 h
contexto confirmado mínimo 90 %
contexto sin confirmar máximo 10 %
conflicto máximo 0 min
revisiones pendientes máximas 0
antigüedad máxima revisión 24 h
DQ crítica máxima 0
sync requerido HEALTHY
colector requerido HEALTHY
```
Estos valores son iniciales y pueden revisarse antes de octubre.
## Estados
```text
TECHNICAL_BLOCKED
INSUFFICIENT_OBSERVATION
REVIEW_BACKLOG
COVERAGE_NOT_READY
READY_FOR_CONTROLLED_SHADOW
```
La evaluación se detiene en el primer bloqueo operativo relevante.
## Beneficios
### Evita activar por intuición
No basta con que un dashboard se vea bien. La vista exige salud técnica,
observación suficiente, ausencia de conflictos, backlog resuelto y cobertura
mínima.
### Convierte el rollout en una lista de acciones
Ejemplos:
```text
CORT-00
REVIEW_BACKLOG
→ preguntar a Producción por el episodio pendiente
CORT-01
INSUFFICIENT_OBSERVATION
→ acumular horas reales
CORT-02
COVERAGE_NOT_READY
→ aumentar uso correcto de Shop Floor
```
### Separa readiness SHADOW de activación oficial
El sistema puede indicar que una máquina está preparada para una prueba
controlada sin permitir todavía que genere pérdidas oficiales.
### Da una evolución medible
Cada semana puede observarse:
```text
sube % confirmado
baja % sin confirmar
desaparece backlog
conflictos siguen en cero
```
## Objetos
```text
context_rollout_readiness_policies
li_canonical_context_readiness_metrics_v1
li_canonical_context_readiness_gate_v1
li_context_rollout_blockers_v1
li_context_rollout_readiness_summary_v1
```
## Despliegue
```bash
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/111_ucepsa_context_rollout_readiness_shadow_v039.sql
docker exec -i mv_ucepsa_postgres_hot sh -lc \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$FILE"
```
## Validación
```bash
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_rollout_readiness_v039.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
machine_rows = 3
distinct_machines = 3
official_metric_rows = 0
negative_observed_hours = 0
negative_confirmed_hours = 0
negative_unconfirmed_hours = 0
negative_conflict_minutes = 0
invalid_confirmed_pct = 0
invalid_unconfirmed_pct = 0
invalid_operator_pct = 0
activity_decomposition_errors = 0
ready_gate_mismatches = 0
official_gate_rows = 0
official_blocker_rows = 0
official_operator_queue_rows = 0
```
## Dashboard
Se añaden al dashboard existente:
```text
Readiness de contexto por máquina
Bloqueos de rollout
Resumen de readiness
```
El UID no cambia y el refresco permanece en 15 segundos.

View File

@ -15,7 +15,7 @@
}
]
},
"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.",
"description": "Supervisión SHADOW de sesiones, incidencias, arbitraje canónico y readiness de rollout. Los criterios muestran qué falta antes de un piloto controlado; nunca habilitan el Ledger oficial.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
@ -1920,6 +1920,323 @@
],
"title": "Cobertura de contexto canónico · últimos 30 días",
"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": "Readiness"
},
"properties": [
{
"id": "mappings",
"value": [
{
"type": "value",
"options": {
"READY_FOR_CONTROLLED_SHADOW": {
"text": "READY SHADOW",
"color": "green",
"index": 0
},
"TECHNICAL_BLOCKED": {
"text": "BLOQUEO TÉCNICO",
"color": "red",
"index": 1
},
"INSUFFICIENT_OBSERVATION": {
"text": "FALTA OBSERVACIÓN",
"color": "blue",
"index": 2
},
"REVIEW_BACKLOG": {
"text": "REVISIÓN PENDIENTE",
"color": "orange",
"index": 3
},
"COVERAGE_NOT_READY": {
"text": "COBERTURA INSUFICIENTE",
"color": "orange",
"index": 4
}
}
}
]
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-background"
}
},
{
"id": "custom.width",
"value": 185
}
]
},
{
"matcher": {
"id": "byName",
"options": "Siguiente acción"
},
"properties": [
{
"id": "custom.width",
"value": 540
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 99
},
"id": 17,
"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 observed_activity_hours AS \"Horas observadas\",\n confirmed_context_pct AS \"% confirmado\",\n unconfirmed_context_pct AS \"% sin confirmar\",\n pending_review_count AS \"Revisiones pendientes\",\n oldest_pending_review_hours AS \"Antigüedad revisión h\",\n recent_conflict_minutes AS \"Conflicto min\",\n critical_session_dq_count AS \"DQ crítica\",\n readiness_status AS \"Readiness\",\n next_action AS \"Siguiente acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_canonical_context_readiness_gate_v1\nORDER BY machine_id",
"refId": "A"
}
],
"title": "Readiness de contexto por máquina · ventana de 7 días",
"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": {
"INFO": {
"text": "INFO",
"color": "blue",
"index": 0
},
"WARNING": {
"text": "WARNING",
"color": "orange",
"index": 1
},
"CRITICAL": {
"text": "CRITICAL",
"color": "red",
"index": 2
}
}
}
]
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
},
{
"id": "custom.width",
"value": 110
}
]
},
{
"matcher": {
"id": "byName",
"options": "Acción"
},
"properties": [
{
"id": "custom.width",
"value": 540
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 109
},
"id": 18,
"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 blocker_severity AS \"Severidad\",\n blocker_code AS \"Criterio\",\n current_value AS \"Valor actual\",\n target_value AS \"Objetivo\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_blockers_v1\nORDER BY\n CASE blocker_severity\n WHEN 'CRITICAL' THEN 1\n WHEN 'WARNING' THEN 2\n ELSE 3\n END,\n machine_id,\n blocker_code",
"refId": "A"
}
],
"title": "Bloqueos de rollout",
"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": []
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 119
},
"id": 19,
"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 overall_readiness_status AS \"Estado global\",\n machine_count AS \"Máquinas\",\n ready_machine_count AS \"Ready SHADOW\",\n technical_blocked_machine_count AS \"Bloqueo técnico\",\n insufficient_observation_machine_count AS \"Falta observación\",\n review_backlog_machine_count AS \"Revisión pendiente\",\n coverage_not_ready_machine_count AS \"Cobertura insuficiente\",\n blocker_count AS \"Bloqueos\",\n critical_blocker_count AS \"Bloqueos críticos\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_rollout_readiness_summary_v1",
"refId": "A"
}
],
"title": "Resumen de readiness del contexto canónico",
"type": "table"
}
],
"refresh": "15s",
@ -1935,7 +2252,10 @@
"v037",
"canonical-context",
"arbitration",
"v038"
"v038",
"readiness",
"rollout-gate",
"v039"
],
"templating": {
"list": []

View File

@ -0,0 +1,315 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-deploy}"
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
REPO_ROOT="${READINESS_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
DASHBOARD_FILE="${READINESS_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
FOLDER_UID="${READINESS_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 context rollout "
"readiness SHADOW v0.3.9"
),
},
)
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",
[],
)
) != 19:
errors.append(
"El dashboard no tiene 19 paneles"
)
if dashboard.get("refresh") != "15s":
errors.append(
"El refresco no está en 15s"
)
if datasource_uids != [datasource_uid]:
errors.append(
f"Datasource inesperado: "
f"{datasource_uids!r}"
)
expected = {
17:
"li_canonical_context_readiness_gate_v1",
18:
"li_context_rollout_blockers_v1",
19:
"li_context_rollout_readiness_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} "
f"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",
[],
)
),
"refresh":
dashboard.get("refresh"),
"datasource_uids":
datasource_uids,
"version":
dashboard.get("version"),
"url":
result.get(
"meta",
{},
).get("url"),
"readiness_panels": {
"machine_gate":
17 in panels,
"blockers":
18 in panels,
"summary":
19 in panels,
},
"errors":
errors,
}
print(json.dumps(
output,
ensure_ascii=False,
indent=2,
))
if errors:
raise SystemExit(1)
else:
raise SystemExit(
"Uso: "
"deploy_context_rollout_readiness_dashboard_v039.sh "
"{deploy|validate}"
)
PY

View File

@ -0,0 +1,179 @@
\pset pager off
\echo '=== 1. Objetos v0.3.9 ==='
SELECT
to_regclass(
'mv_loss_intelligence.context_rollout_readiness_policies'
) AS policy_table,
to_regclass(
'mv_reports_ucepsa_prod.li_canonical_context_readiness_metrics_v1'
) AS metrics_view,
to_regclass(
'mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v1'
) AS gate_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_rollout_blockers_v1'
) AS blockers_view,
to_regclass(
'mv_reports_ucepsa_prod.li_context_rollout_readiness_summary_v1'
) AS summary_view;
\echo '=== 2. Política activa ==='
SELECT *
FROM
mv_loss_intelligence
.context_rollout_readiness_policies
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND policy_code =
'CUTTER_CONTEXT_ROLLOUT_READINESS_V1';
\echo '=== 3. Una fila por máquina ==='
SELECT
COUNT(*) AS machine_rows,
COUNT(
DISTINCT machine_id
) AS distinct_machines,
COUNT(*) FILTER (
WHERE official_ledger_eligible
) AS official_metric_rows
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_metrics_v1;
\echo '=== 4. Rangos y duraciones ==='
SELECT
COUNT(*) FILTER (
WHERE observed_activity_hours < 0
) AS negative_observed_hours,
COUNT(*) FILTER (
WHERE confirmed_context_hours < 0
) AS negative_confirmed_hours,
COUNT(*) FILTER (
WHERE unconfirmed_context_hours < 0
) AS negative_unconfirmed_hours,
COUNT(*) FILTER (
WHERE conflict_context_minutes < 0
) AS negative_conflict_minutes,
COUNT(*) FILTER (
WHERE confirmed_context_pct < 0
OR confirmed_context_pct > 100
) AS invalid_confirmed_pct,
COUNT(*) FILTER (
WHERE unconfirmed_context_pct < 0
OR unconfirmed_context_pct > 100
) AS invalid_unconfirmed_pct,
COUNT(*) FILTER (
WHERE productive_operator_confirmed_pct < 0
OR productive_operator_confirmed_pct > 100
) AS invalid_operator_pct
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_metrics_v1;
\echo '=== 5. Descomposición de actividad ==='
SELECT COUNT(*) AS activity_decomposition_errors
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_metrics_v1
WHERE confirmed_context_hours
+ unconfirmed_context_hours
+ (
conflict_context_minutes
/ 60.0
)
> observed_activity_hours + 0.05;
\echo '=== 6. Gate por máquina ==='
SELECT
machine_id,
observed_activity_hours,
confirmed_context_pct,
unconfirmed_context_pct,
recent_conflict_minutes,
pending_review_count,
oldest_pending_review_hours,
critical_session_dq_count,
sync_health_status,
collector_health_status,
technical_gate_pass,
observation_gate_pass,
review_gate_pass,
coverage_gate_pass,
readiness_status,
readiness_stage,
next_action,
ready_for_official_ledger,
official_ledger_eligible
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1
ORDER BY machine_id;
\echo '=== 7. Estados READY coherentes ==='
SELECT COUNT(*) AS ready_gate_mismatches
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1
WHERE readiness_status =
'READY_FOR_CONTROLLED_SHADOW'
AND NOT (
technical_gate_pass
AND observation_gate_pass
AND review_gate_pass
AND coverage_gate_pass
);
\echo '=== 8. Bloqueos explicables ==='
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_rollout_blockers_v1
ORDER BY
CASE blocker_severity
WHEN 'CRITICAL' THEN 1
WHEN 'WARNING' THEN 2
ELSE 3
END,
machine_id,
blocker_code;
\echo '=== 9. Resumen ==='
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_rollout_readiness_summary_v1;
\echo '=== 10. Nada oficial ==='
SELECT
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1
WHERE ready_for_official_ledger
OR official_ledger_eligible
) AS official_gate_rows,
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_context_rollout_blockers_v1
WHERE official_ledger_eligible
) AS official_blocker_rows,
(
SELECT COUNT(*)
FROM
mv_reports_ucepsa_prod
.li_operator_stop_queue_v2
) AS official_operator_queue_rows;

View File

@ -0,0 +1,962 @@
BEGIN;
CREATE TABLE IF NOT EXISTS
mv_loss_intelligence.context_rollout_readiness_policies (
tenant text NOT NULL,
site text NOT NULL,
policy_code text NOT NULL,
lookback_days integer NOT NULL
CHECK (lookback_days > 0),
minimum_observed_activity_hours numeric NOT NULL
CHECK (minimum_observed_activity_hours >= 0),
minimum_confirmed_context_pct numeric NOT NULL
CHECK (
minimum_confirmed_context_pct >= 0
AND minimum_confirmed_context_pct <= 100
),
maximum_unconfirmed_context_pct numeric NOT NULL
CHECK (
maximum_unconfirmed_context_pct >= 0
AND maximum_unconfirmed_context_pct <= 100
),
maximum_conflict_minutes numeric NOT NULL
CHECK (maximum_conflict_minutes >= 0),
maximum_pending_review_count integer NOT NULL
CHECK (maximum_pending_review_count >= 0),
maximum_oldest_pending_review_hours numeric NOT NULL
CHECK (maximum_oldest_pending_review_hours >= 0),
maximum_critical_session_dq_count integer NOT NULL
CHECK (maximum_critical_session_dq_count >= 0),
require_sync_healthy boolean NOT NULL DEFAULT true,
require_incident_collector_healthy boolean NOT NULL DEFAULT true,
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.context_rollout_readiness_policies IS
'Umbrales SHADOW para medir preparación del contexto canónico antes de cualquier activación controlada. No habilita el Ledger oficial.';
INSERT INTO
mv_loss_intelligence.context_rollout_readiness_policies (
tenant,
site,
policy_code,
lookback_days,
minimum_observed_activity_hours,
minimum_confirmed_context_pct,
maximum_unconfirmed_context_pct,
maximum_conflict_minutes,
maximum_pending_review_count,
maximum_oldest_pending_review_hours,
maximum_critical_session_dq_count,
require_sync_healthy,
require_incident_collector_healthy,
active,
notes
)
VALUES (
'ucepsa',
'ucepsa_onpremise',
'CUTTER_CONTEXT_ROLLOUT_READINESS_V1',
7,
8,
90,
10,
0,
0,
24,
0,
true,
true,
true,
'Criterios iniciales para readiness SHADOW. Son configurables y no constituyen autorización de puesta en producción oficial.'
)
ON CONFLICT (
tenant,
site,
policy_code
)
DO UPDATE SET
lookback_days =
EXCLUDED.lookback_days,
minimum_observed_activity_hours =
EXCLUDED.minimum_observed_activity_hours,
minimum_confirmed_context_pct =
EXCLUDED.minimum_confirmed_context_pct,
maximum_unconfirmed_context_pct =
EXCLUDED.maximum_unconfirmed_context_pct,
maximum_conflict_minutes =
EXCLUDED.maximum_conflict_minutes,
maximum_pending_review_count =
EXCLUDED.maximum_pending_review_count,
maximum_oldest_pending_review_hours =
EXCLUDED.maximum_oldest_pending_review_hours,
maximum_critical_session_dq_count =
EXCLUDED.maximum_critical_session_dq_count,
require_sync_healthy =
EXCLUDED.require_sync_healthy,
require_incident_collector_healthy =
EXCLUDED.require_incident_collector_healthy,
active = true,
notes =
EXCLUDED.notes,
updated_at = now();
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_canonical_context_readiness_metrics_v1
AS
WITH policy AS (
SELECT *
FROM
mv_loss_intelligence
.context_rollout_readiness_policies
WHERE tenant = 'ucepsa'
AND site = 'ucepsa_onpremise'
AND policy_code =
'CUTTER_CONTEXT_ROLLOUT_READINESS_V1'
AND active
ORDER BY updated_at DESC
LIMIT 1
),
machines AS (
SELECT
l.tenant,
l.site,
l.machine_id,
l.telemetry_fresh,
l.machine_running,
l.canonical_context_status
AS live_canonical_context_status,
l.canonical_context_key
AS live_canonical_context_key,
l.canonical_health_status
AS live_canonical_health_status,
l.order_alignment_status
AS live_order_alignment_status,
l.recommended_action
AS live_recommended_action
FROM
mv_reports_ucepsa_prod
.li_machine_canonical_context_live_v1 l
),
window_config AS (
SELECT
p.*,
now() - (
p.lookback_days
* interval '1 day'
) AS window_from,
now() AS window_to
FROM policy p
),
clipped_segments AS (
SELECT
s.machine_id,
s.canonical_context_status,
s.canonical_context_source,
s.canonical_productive_context,
s.individual_attribution_eligible,
s.review_required,
GREATEST(
s.segment_from,
w.window_from
) AS clipped_from,
LEAST(
s.segment_to,
w.window_to
) AS clipped_to,
EXTRACT(
EPOCH FROM (
LEAST(
s.segment_to,
w.window_to
)
- GREATEST(
s.segment_from,
w.window_from
)
)
)::numeric AS clipped_duration_s
FROM
mv_reports_ucepsa_prod
.li_canonical_context_segments_v1 s
CROSS JOIN window_config w
WHERE s.segment_to >
w.window_from
AND s.segment_from <
w.window_to
AND LEAST(
s.segment_to,
w.window_to
) >
GREATEST(
s.segment_from,
w.window_from
)
),
activity_rollup AS (
SELECT
machine_id,
COALESCE(
SUM(clipped_duration_s),
0::numeric
) AS observed_activity_s,
COALESCE(
SUM(clipped_duration_s) FILTER (
WHERE canonical_context_status NOT IN (
'UNCONFIRMED_CONTEXT',
'CONTEXT_CONFLICT',
'UNSUPPORTED_CONTEXT'
)
),
0::numeric
) AS confirmed_context_s,
COALESCE(
SUM(clipped_duration_s) FILTER (
WHERE canonical_productive_context
),
0::numeric
) AS productive_context_s,
COALESCE(
SUM(clipped_duration_s) FILTER (
WHERE canonical_context_status =
'UNCONFIRMED_CONTEXT'
),
0::numeric
) AS unconfirmed_context_s,
COALESCE(
SUM(clipped_duration_s) FILTER (
WHERE canonical_context_status =
'CONTEXT_CONFLICT'
),
0::numeric
) AS conflict_context_s,
COALESCE(
SUM(clipped_duration_s) FILTER (
WHERE canonical_productive_context
AND individual_attribution_eligible
),
0::numeric
) AS operator_confirmed_productive_s,
COUNT(*) AS segment_count,
COUNT(*) FILTER (
WHERE review_required
) AS review_segment_count
FROM clipped_segments
GROUP BY machine_id
),
pending_reviews AS (
SELECT
b.machine_id,
COUNT(*) AS pending_review_count,
MIN(b.first_seen_at)
AS oldest_pending_review_at,
COUNT(*) FILTER (
WHERE b.max_severity =
'CRITICAL'
) AS pending_critical_review_count
FROM
mv_reports_ucepsa_prod
.li_shopfloor_context_incident_backlog_v1 b
WHERE b.machine_id IS NOT NULL
GROUP BY b.machine_id
),
session_dq AS (
SELECT
d.machine_id,
COUNT(*) FILTER (
WHERE d.dq_status =
'CRITICAL'
) AS critical_session_dq_count,
COUNT(*) FILTER (
WHERE d.dq_status =
'WARNING'
) AS warning_session_dq_count
FROM
mv_reports_ucepsa_prod
.li_shopfloor_session_data_quality_v1 d
GROUP BY d.machine_id
),
recent_conflicts AS (
SELECT
c.machine_id,
COUNT(*) AS conflict_segment_count,
COALESCE(
SUM(c.segment_duration_s)
/ 60.0,
0::numeric
) AS conflict_minutes
FROM
mv_reports_ucepsa_prod
.li_context_arbitration_conflicts_v1 c
CROSS JOIN window_config w
WHERE c.segment_to >
w.window_from
AND c.segment_from <
w.window_to
GROUP BY c.machine_id
),
sync_health AS (
SELECT
sync_health_status,
severity AS sync_severity,
seconds_since_success
FROM
mv_reports_ucepsa_prod
.li_shopfloor_sync_health_v1
),
collector_health AS (
SELECT
collector_health_status,
severity AS collector_severity,
seconds_since_success
AS collector_seconds_since_success
FROM
mv_reports_ucepsa_prod
.li_shopfloor_context_incident_collector_health_v1
)
SELECT
m.tenant,
m.site,
m.machine_id,
w.policy_code,
w.lookback_days,
w.window_from,
w.window_to,
w.minimum_observed_activity_hours,
w.minimum_confirmed_context_pct,
w.maximum_unconfirmed_context_pct,
w.maximum_conflict_minutes,
w.maximum_pending_review_count,
w.maximum_oldest_pending_review_hours,
w.maximum_critical_session_dq_count,
w.require_sync_healthy,
w.require_incident_collector_healthy,
m.telemetry_fresh,
m.machine_running,
m.live_canonical_context_status,
m.live_canonical_context_key,
m.live_canonical_health_status,
m.live_order_alignment_status,
m.live_recommended_action,
COALESCE(
a.segment_count,
0
) AS segment_count,
COALESCE(
a.review_segment_count,
0
) AS review_segment_count,
ROUND(
COALESCE(
a.observed_activity_s,
0
) / 3600.0,
2
) AS observed_activity_hours,
ROUND(
COALESCE(
a.confirmed_context_s,
0
) / 3600.0,
2
) AS confirmed_context_hours,
ROUND(
COALESCE(
a.productive_context_s,
0
) / 3600.0,
2
) AS productive_context_hours,
ROUND(
COALESCE(
a.unconfirmed_context_s,
0
) / 3600.0,
2
) AS unconfirmed_context_hours,
ROUND(
COALESCE(
a.conflict_context_s,
0
) / 60.0,
2
) AS conflict_context_minutes,
ROUND(
CASE
WHEN COALESCE(
a.observed_activity_s,
0
) > 0
THEN (
100.0
* COALESCE(
a.confirmed_context_s,
0
)
/ a.observed_activity_s
)
ELSE 0
END::numeric,
2
) AS confirmed_context_pct,
ROUND(
CASE
WHEN COALESCE(
a.observed_activity_s,
0
) > 0
THEN (
100.0
* COALESCE(
a.unconfirmed_context_s,
0
)
/ a.observed_activity_s
)
ELSE 0
END::numeric,
2
) AS unconfirmed_context_pct,
ROUND(
CASE
WHEN COALESCE(
a.productive_context_s,
0
) > 0
THEN (
100.0
* COALESCE(
a.operator_confirmed_productive_s,
0
)
/ a.productive_context_s
)
ELSE 0
END::numeric,
2
) AS productive_operator_confirmed_pct,
COALESCE(
p.pending_review_count,
0
) AS pending_review_count,
COALESCE(
p.pending_critical_review_count,
0
) AS pending_critical_review_count,
p.oldest_pending_review_at,
ROUND(
CASE
WHEN p.oldest_pending_review_at
IS NULL
THEN 0
ELSE EXTRACT(
EPOCH FROM (
now()
- p.oldest_pending_review_at
)
)::numeric / 3600.0
END,
2
) AS oldest_pending_review_hours,
COALESCE(
d.critical_session_dq_count,
0
) AS critical_session_dq_count,
COALESCE(
d.warning_session_dq_count,
0
) AS warning_session_dq_count,
COALESCE(
c.conflict_segment_count,
0
) AS conflict_segment_count,
ROUND(
COALESCE(
c.conflict_minutes,
0
),
2
) AS recent_conflict_minutes,
s.sync_health_status,
s.sync_severity,
s.seconds_since_success
AS sync_seconds_since_success,
ch.collector_health_status,
ch.collector_severity,
ch.collector_seconds_since_success,
false AS official_ledger_eligible,
now() AS observed_at
FROM machines m
CROSS JOIN window_config w
CROSS JOIN sync_health s
CROSS JOIN collector_health ch
LEFT JOIN activity_rollup a
ON a.machine_id =
m.machine_id
LEFT JOIN pending_reviews p
ON p.machine_id =
m.machine_id
LEFT JOIN session_dq d
ON d.machine_id =
m.machine_id
LEFT JOIN recent_conflicts c
ON c.machine_id =
m.machine_id;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_canonical_context_readiness_metrics_v1 IS
'Métricas SHADOW por máquina para decidir qué falta antes de un piloto controlado de contexto canónico. No concede elegibilidad oficial.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v1
AS
SELECT
m.*,
(
(
NOT m.require_sync_healthy
OR m.sync_health_status =
'HEALTHY'
)
AND (
NOT m.require_incident_collector_healthy
OR m.collector_health_status =
'HEALTHY'
)
AND m.critical_session_dq_count <=
m.maximum_critical_session_dq_count
AND m.recent_conflict_minutes <=
m.maximum_conflict_minutes
AND m.live_canonical_health_status <>
'CRITICAL'
) AS technical_gate_pass,
(
m.observed_activity_hours >=
m.minimum_observed_activity_hours
) AS observation_gate_pass,
(
m.pending_review_count <=
m.maximum_pending_review_count
AND m.oldest_pending_review_hours <=
m.maximum_oldest_pending_review_hours
) AS review_gate_pass,
(
m.confirmed_context_pct >=
m.minimum_confirmed_context_pct
AND m.unconfirmed_context_pct <=
m.maximum_unconfirmed_context_pct
) AS coverage_gate_pass,
CASE
WHEN (
(
m.require_sync_healthy
AND m.sync_health_status <>
'HEALTHY'
)
OR (
m.require_incident_collector_healthy
AND m.collector_health_status <>
'HEALTHY'
)
OR m.critical_session_dq_count >
m.maximum_critical_session_dq_count
OR m.recent_conflict_minutes >
m.maximum_conflict_minutes
OR m.live_canonical_health_status =
'CRITICAL'
)
THEN 'TECHNICAL_BLOCKED'
WHEN m.observed_activity_hours <
m.minimum_observed_activity_hours
THEN 'INSUFFICIENT_OBSERVATION'
WHEN (
m.pending_review_count >
m.maximum_pending_review_count
OR m.oldest_pending_review_hours >
m.maximum_oldest_pending_review_hours
)
THEN 'REVIEW_BACKLOG'
WHEN (
m.confirmed_context_pct <
m.minimum_confirmed_context_pct
OR m.unconfirmed_context_pct >
m.maximum_unconfirmed_context_pct
)
THEN 'COVERAGE_NOT_READY'
ELSE 'READY_FOR_CONTROLLED_SHADOW'
END AS readiness_status,
CASE
WHEN (
(
m.require_sync_healthy
AND m.sync_health_status <>
'HEALTHY'
)
OR (
m.require_incident_collector_healthy
AND m.collector_health_status <>
'HEALTHY'
)
)
THEN
'Restablecer la salud de sincronización y del colector.'
WHEN m.critical_session_dq_count >
m.maximum_critical_session_dq_count
THEN
'Resolver errores críticos de calidad en sesiones Shop Floor.'
WHEN m.recent_conflict_minutes >
m.maximum_conflict_minutes
OR m.live_canonical_health_status =
'CRITICAL'
THEN
'Resolver los conflictos de contexto antes de continuar.'
WHEN m.observed_activity_hours <
m.minimum_observed_activity_hours
THEN
'Acumular más horas reales de observación.'
WHEN m.pending_review_count >
m.maximum_pending_review_count
THEN
'Revisar los episodios pendientes con Producción o el responsable técnico.'
WHEN m.oldest_pending_review_hours >
m.maximum_oldest_pending_review_hours
THEN
'Evitar que la revisión más antigua supere el plazo configurado.'
WHEN m.confirmed_context_pct <
m.minimum_confirmed_context_pct
THEN
'Aumentar el uso correcto de Shop Floor o autorizar explícitamente los contextos legacy/manuales reales.'
WHEN m.unconfirmed_context_pct >
m.maximum_unconfirmed_context_pct
THEN
'Reducir actividad física sin contexto confirmado.'
ELSE
'La máquina cumple los criterios SHADOW configurados.'
END AS next_action,
(
CASE
WHEN (
(
m.require_sync_healthy
AND m.sync_health_status <>
'HEALTHY'
)
OR (
m.require_incident_collector_healthy
AND m.collector_health_status <>
'HEALTHY'
)
OR m.critical_session_dq_count >
m.maximum_critical_session_dq_count
OR m.recent_conflict_minutes >
m.maximum_conflict_minutes
OR m.live_canonical_health_status =
'CRITICAL'
)
THEN 1
WHEN m.observed_activity_hours <
m.minimum_observed_activity_hours
THEN 2
WHEN (
m.pending_review_count >
m.maximum_pending_review_count
OR m.oldest_pending_review_hours >
m.maximum_oldest_pending_review_hours
)
THEN 3
WHEN (
m.confirmed_context_pct <
m.minimum_confirmed_context_pct
OR m.unconfirmed_context_pct >
m.maximum_unconfirmed_context_pct
)
THEN 4
ELSE 5
END
) AS readiness_stage,
false AS ready_for_official_ledger,
false AS official_ledger_eligible
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_metrics_v1 m;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v1 IS
'Puerta de readiness SHADOW por máquina. READY_FOR_CONTROLLED_SHADOW no significa activación oficial y ready_for_official_ledger permanece false.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_rollout_blockers_v1
AS
SELECT
g.machine_id,
'SYNC_HEALTH'::text
AS blocker_code,
'CRITICAL'::text
AS blocker_severity,
g.sync_health_status
AS current_value,
'HEALTHY'::text
AS target_value,
'Restablecer la sincronización Shop Floor.'::text
AS recommended_action,
false AS official_ledger_eligible
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.require_sync_healthy
AND g.sync_health_status <>
'HEALTHY'
UNION ALL
SELECT
g.machine_id,
'INCIDENT_COLLECTOR_HEALTH',
'CRITICAL',
g.collector_health_status,
'HEALTHY',
'Restablecer el colector de episodios de incidencia.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.require_incident_collector_healthy
AND g.collector_health_status <>
'HEALTHY'
UNION ALL
SELECT
g.machine_id,
'CRITICAL_SESSION_DQ',
'CRITICAL',
g.critical_session_dq_count::text,
g.maximum_critical_session_dq_count::text,
'Resolver las sesiones Shop Floor con errores críticos.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.critical_session_dq_count >
g.maximum_critical_session_dq_count
UNION ALL
SELECT
g.machine_id,
'CONTEXT_CONFLICT_MINUTES',
'CRITICAL',
g.recent_conflict_minutes::text,
g.maximum_conflict_minutes::text,
'Resolver los solapamientos entre fuentes autoritativas.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.recent_conflict_minutes >
g.maximum_conflict_minutes
UNION ALL
SELECT
g.machine_id,
'OBSERVED_ACTIVITY_HOURS',
'INFO',
g.observed_activity_hours::text,
g.minimum_observed_activity_hours::text,
'Acumular más horas reales de observación.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.observed_activity_hours <
g.minimum_observed_activity_hours
UNION ALL
SELECT
g.machine_id,
'PENDING_REVIEW_COUNT',
'WARNING',
g.pending_review_count::text,
g.maximum_pending_review_count::text,
'Resolver el backlog de revisión de incidencias.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.pending_review_count >
g.maximum_pending_review_count
UNION ALL
SELECT
g.machine_id,
'OLDEST_PENDING_REVIEW_HOURS',
'WARNING',
g.oldest_pending_review_hours::text,
g.maximum_oldest_pending_review_hours::text,
'Revisar el episodio pendiente más antiguo.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.oldest_pending_review_hours >
g.maximum_oldest_pending_review_hours
UNION ALL
SELECT
g.machine_id,
'CONFIRMED_CONTEXT_PCT',
'WARNING',
g.confirmed_context_pct::text,
g.minimum_confirmed_context_pct::text,
'Aumentar la actividad con contexto canónico confirmado.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.confirmed_context_pct <
g.minimum_confirmed_context_pct
UNION ALL
SELECT
g.machine_id,
'UNCONFIRMED_CONTEXT_PCT',
'WARNING',
g.unconfirmed_context_pct::text,
g.maximum_unconfirmed_context_pct::text,
'Reducir actividad física sin contexto confirmado.',
false
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1 g
WHERE g.unconfirmed_context_pct >
g.maximum_unconfirmed_context_pct;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_rollout_blockers_v1 IS
'Lista explicable de criterios que impiden que una máquina alcance READY_FOR_CONTROLLED_SHADOW.';
CREATE OR REPLACE VIEW
mv_reports_ucepsa_prod.li_context_rollout_readiness_summary_v1
AS
WITH gate AS (
SELECT *
FROM
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1
),
blockers AS (
SELECT *
FROM
mv_reports_ucepsa_prod
.li_context_rollout_blockers_v1
)
SELECT
now() AS observed_at,
COUNT(*) AS machine_count,
COUNT(*) FILTER (
WHERE readiness_status =
'READY_FOR_CONTROLLED_SHADOW'
) AS ready_machine_count,
COUNT(*) FILTER (
WHERE readiness_status =
'TECHNICAL_BLOCKED'
) AS technical_blocked_machine_count,
COUNT(*) FILTER (
WHERE readiness_status =
'INSUFFICIENT_OBSERVATION'
) AS insufficient_observation_machine_count,
COUNT(*) FILTER (
WHERE readiness_status =
'REVIEW_BACKLOG'
) AS review_backlog_machine_count,
COUNT(*) FILTER (
WHERE readiness_status =
'COVERAGE_NOT_READY'
) AS coverage_not_ready_machine_count,
(
SELECT COUNT(*)
FROM blockers
) AS blocker_count,
(
SELECT COUNT(*)
FROM blockers
WHERE blocker_severity =
'CRITICAL'
) AS critical_blocker_count,
CASE
WHEN COUNT(*) FILTER (
WHERE readiness_status =
'TECHNICAL_BLOCKED'
) > 0
THEN 'TECHNICAL_BLOCKED'
WHEN COUNT(*) FILTER (
WHERE readiness_status =
'READY_FOR_CONTROLLED_SHADOW'
) = COUNT(*)
THEN 'READY_FOR_CONTROLLED_SHADOW'
ELSE 'NOT_READY'
END AS overall_readiness_status,
false AS ready_for_official_ledger,
false AS official_ledger_eligible
FROM gate;
COMMENT ON VIEW
mv_reports_ucepsa_prod.li_context_rollout_readiness_summary_v1 IS
'Resumen global de readiness del contexto canónico. No autoriza activación oficial.';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname =
'grafana_ucepsa_ro'
) THEN
GRANT SELECT
ON
mv_loss_intelligence
.context_rollout_readiness_policies
TO grafana_ucepsa_ro;
GRANT SELECT
ON
mv_reports_ucepsa_prod
.li_canonical_context_readiness_metrics_v1
TO grafana_ucepsa_ro;
GRANT SELECT
ON
mv_reports_ucepsa_prod
.li_canonical_context_readiness_gate_v1
TO grafana_ucepsa_ro;
GRANT SELECT
ON
mv_reports_ucepsa_prod
.li_context_rollout_blockers_v1
TO grafana_ucepsa_ro;
GRANT SELECT
ON
mv_reports_ucepsa_prod
.li_context_rollout_readiness_summary_v1
TO grafana_ucepsa_ro;
END IF;
END
$$;
COMMIT;