feat(ucepsa): add root context review case queue shadow
This commit is contained in:
parent
9dccf500e2
commit
1fca1d6232
@ -0,0 +1,186 @@
|
|||||||
|
# UCEPSA — Cola de casos raíz SHADOW v0.3.10
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Reducir la intervención humana a una decisión por problema real.
|
||||||
|
|
||||||
|
Ejemplo actual:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episodio 7 incidencia de máquina
|
||||||
|
episodio 8 evidencia workorder 649
|
||||||
|
episodio 9 evidencia workorder 698
|
||||||
|
```
|
||||||
|
|
||||||
|
La cola humana debe presentar:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 caso raíz
|
||||||
|
CORT-00
|
||||||
|
Varias órdenes Shop Floor abiertas
|
||||||
|
Evidencias: 7, 8 y 9
|
||||||
|
```
|
||||||
|
|
||||||
|
No se elimina ninguna evidencia.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```text
|
||||||
|
episodios técnicos
|
||||||
|
→ constructor de casos raíz
|
||||||
|
→ una pregunta humana
|
||||||
|
→ revisión auditada
|
||||||
|
→ cascada de revisión a episodios asociados
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agrupación inicial
|
||||||
|
|
||||||
|
`MULTIPLE_OPEN_SESSIONS` se agrupa cuando:
|
||||||
|
|
||||||
|
```text
|
||||||
|
misma máquina
|
||||||
|
misma familia
|
||||||
|
intervalos solapados o separados <= 120 s
|
||||||
|
```
|
||||||
|
|
||||||
|
Los demás episodios generan inicialmente un caso individual.
|
||||||
|
|
||||||
|
## Revisión
|
||||||
|
|
||||||
|
Clasificaciones de caso:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ODOO_DOUBLE_START_ONE_VALID
|
||||||
|
ODOO_DOUBLE_START_BOTH_INVALID
|
||||||
|
ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED
|
||||||
|
AUTHORIZED_LEGACY_PRODUCTION
|
||||||
|
TEST_OR_SETUP
|
||||||
|
RESIDUAL_MATERIAL
|
||||||
|
ODOO_START_OMITTED
|
||||||
|
DATA_ISSUE
|
||||||
|
NOT_RELEVANT
|
||||||
|
OTHER
|
||||||
|
```
|
||||||
|
|
||||||
|
Una revisión de caso:
|
||||||
|
|
||||||
|
- actualiza el caso raíz;
|
||||||
|
- crea una revisión auditada para cada episodio pendiente;
|
||||||
|
- elimina la duplicación del backlog;
|
||||||
|
- no modifica Odoo;
|
||||||
|
- no crea pérdidas oficiales.
|
||||||
|
|
||||||
|
## Objetos
|
||||||
|
|
||||||
|
```text
|
||||||
|
context_review_case_policies
|
||||||
|
context_review_cases
|
||||||
|
context_review_case_evidence
|
||||||
|
context_review_case_reviews
|
||||||
|
context_review_case_builder_state
|
||||||
|
|
||||||
|
li_context_review_case_history_v1
|
||||||
|
li_context_review_case_backlog_v1
|
||||||
|
li_context_review_case_evidence_v1
|
||||||
|
li_context_review_case_builder_health_v1
|
||||||
|
|
||||||
|
li_canonical_context_readiness_metrics_v2
|
||||||
|
li_canonical_context_readiness_gate_v2
|
||||||
|
li_context_rollout_blockers_v2
|
||||||
|
li_context_rollout_readiness_summary_v2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/112_ucepsa_context_root_case_queue_shadow_v0310.sql
|
||||||
|
|
||||||
|
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< "$FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||||
|
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
"$TARGET/tools/context_review_case_builder.py" \
|
||||||
|
"$RUNTIME/tools/"
|
||||||
|
|
||||||
|
install -m 0644 \
|
||||||
|
"$TARGET/tools/context_review_case_admin.py" \
|
||||||
|
"$RUNTIME/tools/"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constructor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh
|
||||||
|
|
||||||
|
"$MANAGER" dry-run
|
||||||
|
"$MANAGER" once
|
||||||
|
"$MANAGER" start
|
||||||
|
sleep 35
|
||||||
|
"$MANAGER" status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_root_case_queue_v0310.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
|
||||||
|
evidence_link_duplicates = 0
|
||||||
|
review_episodes_without_case = 0
|
||||||
|
root_case_count_7_8_9 = 1
|
||||||
|
evidence_count_7_8_9 = 3
|
||||||
|
ready_v2_gate_mismatches = 0
|
||||||
|
official_case_rows = 0
|
||||||
|
official_gate_rows = 0
|
||||||
|
official_operator_queue_rows = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consola
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin list
|
||||||
|
"$MANAGER" admin show --case-id <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuando Juan Pablo confirme la orden real del conflicto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$MANAGER" admin review \
|
||||||
|
--case-id <id> \
|
||||||
|
--classification ODOO_DOUBLE_START_ONE_VALID \
|
||||||
|
--selected-order-ref "WH/MO/00706" \
|
||||||
|
--selected-workorder-id 698 \
|
||||||
|
--reviewed-by "Juan Pablo" \
|
||||||
|
--notes "Confirma que WH/MO/00706 era la orden real y WH/MO/00660 quedó abierta por error."
|
||||||
|
```
|
||||||
|
|
||||||
|
No ejecutar hasta recibir confirmación.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Se mantienen los dos dashboards existentes. El dashboard de salud añade:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Casos raíz pendientes
|
||||||
|
Evidencias asociadas
|
||||||
|
Historial de casos raíz
|
||||||
|
```
|
||||||
|
|
||||||
|
El panel readiness utiliza v2 y cuenta decisiones humanas, no evidencias.
|
||||||
@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"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.",
|
"description": "Supervisión SHADOW de sesiones, incidencias, arbitraje, readiness y casos raíz. Producción revisa una decisión por problema real mientras las evidencias técnicas se conservan para auditoría.",
|
||||||
"editable": true,
|
"editable": true,
|
||||||
"fiscalYearStartMonth": 0,
|
"fiscalYearStartMonth": 0,
|
||||||
"graphTooltip": 1,
|
"graphTooltip": 1,
|
||||||
@ -1384,7 +1384,7 @@
|
|||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Incidencias pendientes de revisión",
|
"title": "Evidencias técnicas pendientes · auditoría",
|
||||||
"type": "table"
|
"type": "table"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -2045,11 +2045,11 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"rawQuery": true,
|
"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",
|
"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_root_case_count AS \"Casos raíz pendientes\",\n pending_review_count AS \"Evidencias pendientes\",\n oldest_pending_root_case_hours\n AS \"Antigüedad caso 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_v2\nORDER BY machine_id",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Readiness de contexto por máquina · ventana de 7 días",
|
"title": "Readiness por máquina · casos raíz",
|
||||||
"type": "table"
|
"type": "table"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -2166,7 +2166,7 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"rawQuery": true,
|
"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",
|
"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_v2\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"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -2231,12 +2231,384 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"rawQuery": true,
|
"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",
|
"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\n AS \"Bloqueo técnico\",\n insufficient_observation_machine_count\n AS \"Falta observación\",\n review_backlog_machine_count\n AS \"Revisión pendiente\",\n coverage_not_ready_machine_count\n AS \"Cobertura insuficiente\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\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_v2",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Resumen de readiness del contexto canónico",
|
"title": "Resumen de readiness del contexto canónico",
|
||||||
"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": "Problema raíz"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 380
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Pregunta"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 520
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Acción"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 520
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 11,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 126
|
||||||
|
},
|
||||||
|
"id": 20,
|
||||||
|
"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 case_id AS \"Caso\",\n CASE case_status\n WHEN 'OPEN' THEN 'Abierto'\n ELSE 'Cerrado'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n case_title AS \"Problema raíz\",\n array_to_string(\n production_orders,\n ', '\n ) AS \"Órdenes\",\n array_to_string(\n odoo_workorder_ids,\n ', '\n ) AS \"Workorders\",\n linked_evidence_count AS \"Evidencias\",\n CASE review_owner\n WHEN 'PRODUCTION' THEN 'Producción'\n WHEN 'TECHNICAL' THEN 'Técnico'\n ELSE 'Conjunto'\n END AS \"Responsable\",\n first_seen_at AS \"Inicio\",\n case_duration_min AS \"Duración min\",\n question_text AS \"Pregunta\",\n recommended_action AS \"Acción\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_review_case_backlog_v1\nORDER BY backlog_rank\nLIMIT 50",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Casos raíz 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": "Evidencia"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 420
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 137
|
||||||
|
},
|
||||||
|
"id": 21,
|
||||||
|
"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 case_id AS \"Caso\",\n evidence_role AS \"Rol\",\n episode_id AS \"Episodio\",\n incident_scope AS \"Ámbito\",\n incident_title AS \"Evidencia\",\n COALESCE(\n production_order,\n '—'\n ) AS \"Orden\",\n COALESCE(\n odoo_workorder_id::text,\n '—'\n ) AS \"Workorder\",\n episode_status AS \"Estado\",\n episode_duration_min AS \"Duración min\",\n episode_severity AS \"Severidad\",\n episode_review_status AS \"Revisión\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_review_case_evidence_v1\nWHERE case_review_status =\n 'PENDING'\nORDER BY\n case_id,\n episode_id\nLIMIT 200",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Evidencias asociadas a los casos raíz",
|
||||||
|
"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": "Problema raíz"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.width",
|
||||||
|
"value": 420
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 147
|
||||||
|
},
|
||||||
|
"id": 22,
|
||||||
|
"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 case_id AS \"Caso\",\n CASE case_status\n WHEN 'OPEN' THEN 'Abierto'\n ELSE 'Cerrado'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n case_title AS \"Problema raíz\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n case_duration_min AS \"Duración min\",\n linked_evidence_count AS \"Evidencias\",\n review_status AS \"Revisión\",\n COALESCE(\n resolution_classification,\n '—'\n ) AS \"Resolución\",\n COALESCE(\n selected_order_ref,\n '—'\n ) AS \"Orden válida\",\n COALESCE(\n selected_workorder_id::text,\n '—'\n ) AS \"WO válido\",\n COALESCE(\n reviewed_by,\n '—'\n ) AS \"Revisado por\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_review_case_history_v1\nWHERE first_seen_at >=\n now() - interval '7 days'\nORDER BY first_seen_at DESC\nLIMIT 100",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Historial de casos raíz · últimos 7 días",
|
||||||
|
"type": "table"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"refresh": "15s",
|
"refresh": "15s",
|
||||||
@ -2255,7 +2627,10 @@
|
|||||||
"v038",
|
"v038",
|
||||||
"readiness",
|
"readiness",
|
||||||
"rollout-gate",
|
"rollout-gate",
|
||||||
"v039"
|
"v039",
|
||||||
|
"root-cases",
|
||||||
|
"human-governance",
|
||||||
|
"v0310"
|
||||||
],
|
],
|
||||||
"templating": {
|
"templating": {
|
||||||
"list": []
|
"list": []
|
||||||
|
|||||||
298
ucepsa/edge-oee-demo/ops/deploy_context_root_case_dashboard_v0310.sh
Executable file
298
ucepsa/edge-oee-demo/ops/deploy_context_root_case_dashboard_v0310.sh
Executable file
@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-deploy}"
|
||||||
|
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||||
|
REPO_ROOT="${ROOT_CASE_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||||
|
DASHBOARD_FILE="${ROOT_CASE_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||||
|
FOLDER_UID="${ROOT_CASE_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 root case queue "
|
||||||
|
"SHADOW v0.3.10"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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", [])) != 22:
|
||||||
|
errors.append(
|
||||||
|
"El dashboard no tiene 22 paneles"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dashboard.get("refresh") != "15s":
|
||||||
|
errors.append(
|
||||||
|
"El refresco no está en 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if datasource_uids != [datasource_uid]:
|
||||||
|
errors.append(
|
||||||
|
f"Datasource inesperado: {datasource_uids!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
20: "li_context_review_case_backlog_v1",
|
||||||
|
21: "li_context_review_case_evidence_v1",
|
||||||
|
22: "li_context_review_case_history_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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
readiness_sql = (
|
||||||
|
panels.get(17, {})
|
||||||
|
.get("targets", [{}])[0]
|
||||||
|
.get("rawSql", "")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
"li_canonical_context_readiness_gate_v2"
|
||||||
|
not in readiness_sql
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"El panel readiness no usa gate v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"),
|
||||||
|
"root_case_panels": {
|
||||||
|
"backlog": 20 in panels,
|
||||||
|
"evidence": 21 in panels,
|
||||||
|
"history": 22 in panels,
|
||||||
|
"readiness_v2":
|
||||||
|
"li_canonical_context_readiness_gate_v2"
|
||||||
|
in readiness_sql,
|
||||||
|
},
|
||||||
|
"errors":
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
output,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"Uso: "
|
||||||
|
"deploy_context_root_case_dashboard_v0310.sh "
|
||||||
|
"{deploy|validate}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
125
ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh
Executable file
125
ucepsa/edge-oee-demo/ops/manage_context_review_case_builder_v0310.sh
Executable file
@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-status}"
|
||||||
|
ROOT="${CONTEXT_CASE_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||||
|
CONTAINER="${CONTEXT_CASE_CONTAINER:-mv_ucepsa_context_review_case_builder}"
|
||||||
|
NETWORK="${CONTEXT_CASE_NETWORK:-mv_ucepsa_net}"
|
||||||
|
INTERVAL="${CONTEXT_CASE_INTERVAL_S:-15}"
|
||||||
|
POSTGRES_CONTAINER="${CONTEXT_CASE_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
|
||||||
|
TENANT_OVERRIDE="${CONTEXT_CASE_TENANT_OVERRIDE:-ucepsa}"
|
||||||
|
SITE_OVERRIDE="${CONTEXT_CASE_SITE_OVERRIDE:-ucepsa_onpremise}"
|
||||||
|
PGHOST_OVERRIDE="${CONTEXT_CASE_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
IMAGE="$(
|
||||||
|
docker inspect \
|
||||||
|
mv_ucepsa_odoo_order_state_pg_sink \
|
||||||
|
--format '{{.Config.Image}}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
PGDATABASE_OVERRIDE="${CONTEXT_CASE_PGDATABASE_OVERRIDE:-$(
|
||||||
|
docker inspect "$POSTGRES_CONTAINER" \
|
||||||
|
--format '{{range .Config.Env}}{{println .}}{{end}}' \
|
||||||
|
| sed -n 's/^POSTGRES_DB=//p' \
|
||||||
|
| head -n 1
|
||||||
|
)}"
|
||||||
|
|
||||||
|
if [[ -z "$PGDATABASE_OVERRIDE" ]]; then
|
||||||
|
echo "ERROR: no se pudo determinar POSTGRES_DB." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
common_args=(
|
||||||
|
--network "$NETWORK"
|
||||||
|
--env-file .env
|
||||||
|
-e "TENANT_OVERRIDE=$TENANT_OVERRIDE"
|
||||||
|
-e "SITE_OVERRIDE=$SITE_OVERRIDE"
|
||||||
|
-e "PGHOST=$PGHOST_OVERRIDE"
|
||||||
|
-e "PGDATABASE=$PGDATABASE_OVERRIDE"
|
||||||
|
-v "$ROOT:/app:ro"
|
||||||
|
-w /app
|
||||||
|
--entrypoint python
|
||||||
|
)
|
||||||
|
|
||||||
|
start_builder() {
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_builder.py \
|
||||||
|
--watch \
|
||||||
|
--interval "$INTERVAL"
|
||||||
|
|
||||||
|
docker ps \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_builder_once() {
|
||||||
|
docker run --rm -i \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_builder.py \
|
||||||
|
--once
|
||||||
|
}
|
||||||
|
|
||||||
|
run_builder_dry() {
|
||||||
|
docker run --rm -i \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_builder.py \
|
||||||
|
--once \
|
||||||
|
--dry-run
|
||||||
|
}
|
||||||
|
|
||||||
|
run_admin() {
|
||||||
|
shift
|
||||||
|
docker run --rm -i \
|
||||||
|
"${common_args[@]}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
tools/context_review_case_admin.py \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
start)
|
||||||
|
start_builder
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
docker stop "$CONTAINER"
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
docker rm -f "$CONTAINER" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
start_builder
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
docker ps -a \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}' \
|
||||||
|
| grep -E "$CONTAINER|NAMES"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
docker logs --tail 200 -f "$CONTAINER"
|
||||||
|
;;
|
||||||
|
once)
|
||||||
|
run_builder_once
|
||||||
|
;;
|
||||||
|
dry-run)
|
||||||
|
run_builder_dry
|
||||||
|
;;
|
||||||
|
admin)
|
||||||
|
run_admin "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo \
|
||||||
|
"Uso: $0 {start|stop|restart|status|logs|once|dry-run|admin ...}" \
|
||||||
|
>&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
\pset pager off
|
||||||
|
|
||||||
|
\echo '=== 1. Objetos v0.3.10 ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_review_case_policies'
|
||||||
|
) AS policy_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_review_cases'
|
||||||
|
) AS cases_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_review_case_evidence'
|
||||||
|
) AS evidence_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_review_case_reviews'
|
||||||
|
) AS reviews_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_loss_intelligence.context_review_case_builder_state'
|
||||||
|
) AS state_table,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_review_case_history_v1'
|
||||||
|
) AS history_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_review_case_backlog_v1'
|
||||||
|
) AS backlog_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_review_case_evidence_v1'
|
||||||
|
) AS evidence_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_context_review_case_builder_health_v1'
|
||||||
|
) AS health_view,
|
||||||
|
to_regclass(
|
||||||
|
'mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v2'
|
||||||
|
) AS readiness_v2_view;
|
||||||
|
|
||||||
|
\echo '=== 2. Salud del constructor ==='
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_review_case_builder_health_v1;
|
||||||
|
|
||||||
|
\echo '=== 3. Evidencias no pertenecen a varios casos ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS evidence_link_duplicates
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
episode_id,
|
||||||
|
COUNT(*) AS n
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence.context_review_case_evidence
|
||||||
|
GROUP BY episode_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) duplicated;
|
||||||
|
|
||||||
|
\echo '=== 4. Episodios recientes con revisión no quedan sin caso ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS review_episodes_without_case
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1 h
|
||||||
|
WHERE h.review_required
|
||||||
|
AND (
|
||||||
|
h.episode_status = 'OPEN'
|
||||||
|
OR h.first_seen_at >=
|
||||||
|
now() - interval '30 days'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence.context_review_case_evidence e
|
||||||
|
WHERE e.episode_id =
|
||||||
|
h.episode_id
|
||||||
|
);
|
||||||
|
|
||||||
|
\echo '=== 5. Conflicto 7/8/9 agrupado en un caso raíz ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
COUNT(
|
||||||
|
DISTINCT case_id
|
||||||
|
) AS root_case_count_7_8_9,
|
||||||
|
COUNT(*) AS evidence_count_7_8_9
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence.context_review_case_evidence
|
||||||
|
WHERE episode_id IN (
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9
|
||||||
|
);
|
||||||
|
|
||||||
|
\echo '=== 6. Detalle del caso raíz 7/8/9 ==='
|
||||||
|
|
||||||
|
SELECT DISTINCT
|
||||||
|
c.case_id,
|
||||||
|
c.case_family,
|
||||||
|
c.machine_id,
|
||||||
|
c.case_title,
|
||||||
|
c.question_text,
|
||||||
|
c.case_status,
|
||||||
|
c.max_severity,
|
||||||
|
c.linked_evidence_count,
|
||||||
|
c.episode_ids,
|
||||||
|
c.production_orders,
|
||||||
|
c.odoo_workorder_ids,
|
||||||
|
c.review_owner,
|
||||||
|
c.review_status,
|
||||||
|
c.official_eligible
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_review_case_history_v1 c
|
||||||
|
JOIN
|
||||||
|
mv_loss_intelligence.context_review_case_evidence e
|
||||||
|
ON e.case_id =
|
||||||
|
c.case_id
|
||||||
|
WHERE e.episode_id IN (
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9
|
||||||
|
);
|
||||||
|
|
||||||
|
\echo '=== 7. Backlog humano por caso raíz ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
backlog_rank,
|
||||||
|
case_id,
|
||||||
|
case_status,
|
||||||
|
max_severity,
|
||||||
|
machine_id,
|
||||||
|
case_title,
|
||||||
|
linked_evidence_count,
|
||||||
|
production_orders,
|
||||||
|
odoo_workorder_ids,
|
||||||
|
review_owner,
|
||||||
|
review_status,
|
||||||
|
question_text
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_review_case_backlog_v1
|
||||||
|
ORDER BY backlog_rank
|
||||||
|
LIMIT 50;
|
||||||
|
|
||||||
|
\echo '=== 8. Readiness v2 ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
machine_id,
|
||||||
|
pending_review_count
|
||||||
|
AS pending_episode_review_count,
|
||||||
|
pending_root_case_count,
|
||||||
|
pending_critical_root_case_count,
|
||||||
|
oldest_pending_root_case_hours,
|
||||||
|
builder_health_status,
|
||||||
|
technical_gate_pass,
|
||||||
|
review_gate_pass,
|
||||||
|
readiness_status,
|
||||||
|
next_action,
|
||||||
|
ready_for_official_ledger,
|
||||||
|
official_ledger_eligible
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_readiness_gate_v2
|
||||||
|
ORDER BY machine_id;
|
||||||
|
|
||||||
|
\echo '=== 9. READY v2 coherente ==='
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS ready_v2_gate_mismatches
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_readiness_gate_v2
|
||||||
|
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 '=== 10. Nada oficial ==='
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence.context_review_cases
|
||||||
|
WHERE official_eligible
|
||||||
|
) AS official_case_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_canonical_context_readiness_gate_v2
|
||||||
|
WHERE ready_for_official_ledger
|
||||||
|
OR official_ledger_eligible
|
||||||
|
) AS official_gate_rows,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod.li_operator_stop_queue_v2
|
||||||
|
) AS official_operator_queue_rows;
|
||||||
File diff suppressed because it is too large
Load Diff
481
ucepsa/edge-oee-demo/tools/context_review_case_admin.py
Executable file
481
ucepsa/edge-oee-demo/tools/context_review_case_admin.py
Executable file
@ -0,0 +1,481 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Administración de casos raíz v0.3.10."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
|
CLASSIFICATIONS = (
|
||||||
|
"ODOO_DOUBLE_START_ONE_VALID",
|
||||||
|
"ODOO_DOUBLE_START_BOTH_INVALID",
|
||||||
|
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED",
|
||||||
|
"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():
|
||||||
|
database = env_first("PGDATABASE", "POSTGRES_DB")
|
||||||
|
user = env_first("PGUSER", "POSTGRES_USER")
|
||||||
|
password = env_first("PGPASSWORD", "POSTGRES_PASSWORD")
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("PGDATABASE", database),
|
||||||
|
("PGUSER", user),
|
||||||
|
("PGPASSWORD", password),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Faltan variables: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=env_first(
|
||||||
|
"PGHOST",
|
||||||
|
default="mv_ucepsa_postgres_hot",
|
||||||
|
),
|
||||||
|
port=int(
|
||||||
|
env_first("PGPORT", default="5432")
|
||||||
|
or "5432"
|
||||||
|
),
|
||||||
|
dbname=database,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
connect_timeout=10,
|
||||||
|
application_name="context_review_case_admin_v0310",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_json(value: Any) -> None:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_cases(limit: int) -> None:
|
||||||
|
with connect() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
backlog_rank,
|
||||||
|
case_id,
|
||||||
|
case_status,
|
||||||
|
max_severity,
|
||||||
|
first_seen_at,
|
||||||
|
last_seen_at,
|
||||||
|
case_duration_min,
|
||||||
|
machine_id,
|
||||||
|
case_title,
|
||||||
|
production_orders,
|
||||||
|
odoo_workorder_ids,
|
||||||
|
linked_evidence_count,
|
||||||
|
review_owner,
|
||||||
|
review_status,
|
||||||
|
question_text,
|
||||||
|
recommended_action
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_review_case_backlog_v1
|
||||||
|
ORDER BY backlog_rank
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
print_json(
|
||||||
|
[dict(row) for row in cursor.fetchall()]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_case(case_id: int) -> None:
|
||||||
|
with connect() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_review_case_history_v1
|
||||||
|
WHERE case_id = %s
|
||||||
|
""",
|
||||||
|
(case_id,),
|
||||||
|
)
|
||||||
|
case = cursor.fetchone()
|
||||||
|
if not case:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No existe el caso {case_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_review_case_evidence_v1
|
||||||
|
WHERE case_id = %s
|
||||||
|
ORDER BY episode_id
|
||||||
|
""",
|
||||||
|
(case_id,),
|
||||||
|
)
|
||||||
|
evidence = [
|
||||||
|
dict(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_reviews
|
||||||
|
WHERE case_id = %s
|
||||||
|
ORDER BY reviewed_at, review_id
|
||||||
|
""",
|
||||||
|
(case_id,),
|
||||||
|
)
|
||||||
|
reviews = [
|
||||||
|
dict(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
print_json({
|
||||||
|
"case": dict(case),
|
||||||
|
"evidence": evidence,
|
||||||
|
"reviews": reviews,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def episode_classification(
|
||||||
|
root_classification: str,
|
||||||
|
) -> str:
|
||||||
|
mapping = {
|
||||||
|
"ODOO_DOUBLE_START_ONE_VALID":
|
||||||
|
"DATA_ISSUE",
|
||||||
|
"ODOO_DOUBLE_START_BOTH_INVALID":
|
||||||
|
"DATA_ISSUE",
|
||||||
|
"ODOO_CONSECUTIVE_ORDERS_NOT_CLOSED":
|
||||||
|
"DATA_ISSUE",
|
||||||
|
"AUTHORIZED_LEGACY_PRODUCTION":
|
||||||
|
"AUTHORIZED_LEGACY_PRODUCTION",
|
||||||
|
"TEST_OR_SETUP":
|
||||||
|
"TEST_OR_SETUP",
|
||||||
|
"RESIDUAL_MATERIAL":
|
||||||
|
"RESIDUAL_MATERIAL",
|
||||||
|
"ODOO_START_OMITTED":
|
||||||
|
"ODOO_START_OMITTED",
|
||||||
|
"DATA_ISSUE":
|
||||||
|
"DATA_ISSUE",
|
||||||
|
"NOT_RELEVANT":
|
||||||
|
"NOT_RELEVANT",
|
||||||
|
"OTHER":
|
||||||
|
"OTHER",
|
||||||
|
}
|
||||||
|
return mapping[root_classification]
|
||||||
|
|
||||||
|
|
||||||
|
def review_case(
|
||||||
|
case_id: int,
|
||||||
|
classification: str,
|
||||||
|
reviewed_by: str,
|
||||||
|
notes: str | None,
|
||||||
|
selected_order_ref: str | None,
|
||||||
|
selected_workorder_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
case_status = (
|
||||||
|
"DISMISSED"
|
||||||
|
if classification == "NOT_RELEVANT"
|
||||||
|
else "REVIEWED"
|
||||||
|
)
|
||||||
|
episode_status = (
|
||||||
|
"DISMISSED"
|
||||||
|
if classification == "NOT_RELEVANT"
|
||||||
|
else "REVIEWED"
|
||||||
|
)
|
||||||
|
episode_class = episode_classification(
|
||||||
|
classification
|
||||||
|
)
|
||||||
|
|
||||||
|
with connect() as connection:
|
||||||
|
with connection.cursor(
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_cases
|
||||||
|
WHERE case_id = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(case_id,),
|
||||||
|
)
|
||||||
|
previous = cursor.fetchone()
|
||||||
|
if not previous:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No existe el caso {case_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_reviews (
|
||||||
|
case_id,
|
||||||
|
previous_values,
|
||||||
|
new_review_status,
|
||||||
|
resolution_classification,
|
||||||
|
selected_order_ref,
|
||||||
|
selected_workorder_id,
|
||||||
|
reviewed_by,
|
||||||
|
notes,
|
||||||
|
evidence_json
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
case_id,
|
||||||
|
Json(dict(previous)),
|
||||||
|
case_status,
|
||||||
|
classification,
|
||||||
|
selected_order_ref,
|
||||||
|
selected_workorder_id,
|
||||||
|
reviewed_by,
|
||||||
|
notes,
|
||||||
|
Json({
|
||||||
|
"source":
|
||||||
|
"context_review_case_admin_v0310",
|
||||||
|
"cascade_to_episode_reviews":
|
||||||
|
True,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
case_review = dict(cursor.fetchone())
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
h.*
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_evidence e
|
||||||
|
JOIN
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1 h
|
||||||
|
ON h.episode_id =
|
||||||
|
e.episode_id
|
||||||
|
WHERE e.case_id = %s
|
||||||
|
ORDER BY h.episode_id
|
||||||
|
""",
|
||||||
|
(case_id,),
|
||||||
|
)
|
||||||
|
episodes = [
|
||||||
|
dict(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cascaded = []
|
||||||
|
for episode in episodes:
|
||||||
|
if episode["review_status"] != "PENDING":
|
||||||
|
continue
|
||||||
|
|
||||||
|
cascade_notes = (
|
||||||
|
f"Caso raíz {case_id}: "
|
||||||
|
f"{notes or classification}. "
|
||||||
|
f"Orden seleccionada: "
|
||||||
|
f"{selected_order_ref or '—'}; "
|
||||||
|
f"workorder seleccionado: "
|
||||||
|
f"{selected_workorder_id or '—'}."
|
||||||
|
)
|
||||||
|
|
||||||
|
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 review_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
episode["episode_id"],
|
||||||
|
Json({
|
||||||
|
"review_status":
|
||||||
|
episode["review_status"],
|
||||||
|
"review_classification":
|
||||||
|
episode[
|
||||||
|
"review_classification"
|
||||||
|
],
|
||||||
|
"reviewed_by":
|
||||||
|
episode["reviewed_by"],
|
||||||
|
"reviewed_at":
|
||||||
|
episode["reviewed_at"],
|
||||||
|
"review_notes":
|
||||||
|
episode["review_notes"],
|
||||||
|
}),
|
||||||
|
episode_status,
|
||||||
|
episode_class,
|
||||||
|
reviewed_by,
|
||||||
|
cascade_notes,
|
||||||
|
Json({
|
||||||
|
"source_case_id": case_id,
|
||||||
|
"root_classification":
|
||||||
|
classification,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cascaded.append({
|
||||||
|
"episode_id":
|
||||||
|
episode["episode_id"],
|
||||||
|
"review_id":
|
||||||
|
cursor.fetchone()["review_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_context_review_case_history_v1
|
||||||
|
WHERE case_id = %s
|
||||||
|
""",
|
||||||
|
(case_id,),
|
||||||
|
)
|
||||||
|
current = dict(cursor.fetchone())
|
||||||
|
|
||||||
|
print_json({
|
||||||
|
"case_review": case_review,
|
||||||
|
"case": current,
|
||||||
|
"cascaded_episode_reviews": cascaded,
|
||||||
|
"warning": (
|
||||||
|
"La revisión documenta la decisión y "
|
||||||
|
"cierra el backlog humano. No modifica Odoo "
|
||||||
|
"ni habilita el Ledger oficial."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Administración de casos raíz."
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(
|
||||||
|
dest="command",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
list_cmd = sub.add_parser("list")
|
||||||
|
list_cmd.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
show_cmd = sub.add_parser("show")
|
||||||
|
show_cmd.add_argument(
|
||||||
|
"--case-id",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
review_cmd = sub.add_parser("review")
|
||||||
|
review_cmd.add_argument(
|
||||||
|
"--case-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")
|
||||||
|
review_cmd.add_argument("--selected-order-ref")
|
||||||
|
review_cmd.add_argument(
|
||||||
|
"--selected-workorder-id",
|
||||||
|
type=int,
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
if args.command == "list":
|
||||||
|
list_cases(args.limit)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "show":
|
||||||
|
show_case(args.case_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "review":
|
||||||
|
review_case(
|
||||||
|
case_id=args.case_id,
|
||||||
|
classification=args.classification,
|
||||||
|
reviewed_by=args.reviewed_by,
|
||||||
|
notes=args.notes,
|
||||||
|
selected_order_ref=
|
||||||
|
args.selected_order_ref,
|
||||||
|
selected_workorder_id=
|
||||||
|
args.selected_workorder_id,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
raise RuntimeError("Comando no soportado")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
987
ucepsa/edge-oee-demo/tools/context_review_case_builder.py
Executable file
987
ucepsa/edge-oee-demo/tools/context_review_case_builder.py
Executable file
@ -0,0 +1,987 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Constructor persistente de casos raíz de revisión v0.3.10."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = "0.3.10"
|
||||||
|
BUILDER_NAME = "context_review_case_builder_v0310"
|
||||||
|
POLICY_CODE = "CONTEXT_ROOT_CASE_QUEUE_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_database: str
|
||||||
|
pg_user: str
|
||||||
|
pg_password: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
database = env_first("PGDATABASE", "POSTGRES_DB")
|
||||||
|
user = env_first("PGUSER", "POSTGRES_USER")
|
||||||
|
password = env_first("PGPASSWORD", "POSTGRES_PASSWORD")
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("PGDATABASE", database),
|
||||||
|
("PGUSER", user),
|
||||||
|
("PGPASSWORD", password),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Faltan variables: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
tenant=env_first(
|
||||||
|
"TENANT_OVERRIDE",
|
||||||
|
"TENANT",
|
||||||
|
default="ucepsa",
|
||||||
|
)
|
||||||
|
or "ucepsa",
|
||||||
|
site=env_first(
|
||||||
|
"SITE_OVERRIDE",
|
||||||
|
"SITE",
|
||||||
|
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_database=str(database),
|
||||||
|
pg_user=str(user),
|
||||||
|
pg_password=str(password),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connect(config: Config):
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=config.pg_host,
|
||||||
|
port=config.pg_port,
|
||||||
|
dbname=config.pg_database,
|
||||||
|
user=config.pg_user,
|
||||||
|
password=config.pg_password,
|
||||||
|
connect_timeout=10,
|
||||||
|
application_name=BUILDER_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def severity_rank(value: str | None) -> int:
|
||||||
|
return {
|
||||||
|
"WARNING": 1,
|
||||||
|
"CRITICAL": 2,
|
||||||
|
}.get(value or "", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def case_family(episode: dict[str, Any]) -> str:
|
||||||
|
code = str(episode["incident_code"])
|
||||||
|
|
||||||
|
if code == "MULTIPLE_OPEN_SESSIONS":
|
||||||
|
return "MULTIPLE_OPEN_SESSIONS"
|
||||||
|
if code == "RUNNING_WITHOUT_SHOPFLOOR_SESSION":
|
||||||
|
return "UNCONFIRMED_ACTIVITY"
|
||||||
|
if code in {
|
||||||
|
"ORDER_WITHOUT_SHOPFLOOR_SESSION",
|
||||||
|
"RUNNING_WITH_ORDER_NO_SHOPFLOOR_SESSION",
|
||||||
|
}:
|
||||||
|
return "ORDER_WITHOUT_SESSION"
|
||||||
|
if code in {
|
||||||
|
"OPEN_SESSION_ORDER_MISMATCH",
|
||||||
|
"WORKORDER_SESSION_STATE_MISMATCH",
|
||||||
|
}:
|
||||||
|
return "ORDER_SESSION_MISMATCH"
|
||||||
|
if code.startswith("SHOPFLOOR_SYNC_"):
|
||||||
|
return "SYNC_HEALTH"
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def family_owner(family: str) -> str:
|
||||||
|
if family == "MULTIPLE_OPEN_SESSIONS":
|
||||||
|
return "JOINT"
|
||||||
|
if family in {
|
||||||
|
"UNCONFIRMED_ACTIVITY",
|
||||||
|
"ORDER_WITHOUT_SESSION",
|
||||||
|
}:
|
||||||
|
return "PRODUCTION"
|
||||||
|
return "TECHNICAL"
|
||||||
|
|
||||||
|
|
||||||
|
def family_title(family: str) -> str:
|
||||||
|
return {
|
||||||
|
"MULTIPLE_OPEN_SESSIONS":
|
||||||
|
"Varias órdenes Shop Floor abiertas en una máquina",
|
||||||
|
"UNCONFIRMED_ACTIVITY":
|
||||||
|
"Actividad de máquina sin contexto confirmado",
|
||||||
|
"ORDER_WITHOUT_SESSION":
|
||||||
|
"Orden Odoo sin sesión Shop Floor utilizable",
|
||||||
|
"ORDER_SESSION_MISMATCH":
|
||||||
|
"Orden Odoo y sesión Shop Floor no coinciden",
|
||||||
|
"SYNC_HEALTH":
|
||||||
|
"Problema de sincronización Shop Floor",
|
||||||
|
}.get(
|
||||||
|
family,
|
||||||
|
family.replace("_", " ").title(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def question_text(
|
||||||
|
family: str,
|
||||||
|
machine_id: str | None,
|
||||||
|
orders: list[str],
|
||||||
|
) -> str:
|
||||||
|
machine = machine_id or "la instalación"
|
||||||
|
order_text = (
|
||||||
|
", ".join(orders)
|
||||||
|
if orders
|
||||||
|
else "las órdenes implicadas"
|
||||||
|
)
|
||||||
|
|
||||||
|
if family == "MULTIPLE_OPEN_SESSIONS":
|
||||||
|
return (
|
||||||
|
f"¿Cuál de {order_text} era la orden realmente "
|
||||||
|
f"ejecutada en {machine} y qué debe hacerse con "
|
||||||
|
"las demás?"
|
||||||
|
)
|
||||||
|
if family == "UNCONFIRMED_ACTIVITY":
|
||||||
|
return (
|
||||||
|
f"¿La actividad de {machine} correspondía a "
|
||||||
|
"sistema antiguo, prueba o ajuste, material "
|
||||||
|
"residual o un inicio omitido en Shop Floor?"
|
||||||
|
)
|
||||||
|
if family == "ORDER_WITHOUT_SESSION":
|
||||||
|
return (
|
||||||
|
f"¿La orden publicada en {machine} llegó a "
|
||||||
|
"iniciarse realmente en Shop Floor?"
|
||||||
|
)
|
||||||
|
if family == "ORDER_SESSION_MISMATCH":
|
||||||
|
return (
|
||||||
|
f"¿Qué orden estaba realmente ejecutándose en "
|
||||||
|
f"{machine}?"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"¿Cuál es la causa operativa del caso detectado "
|
||||||
|
f"en {machine}?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recommended_action(family: str) -> str:
|
||||||
|
return {
|
||||||
|
"MULTIPLE_OPEN_SESSIONS":
|
||||||
|
"Confirmar la orden real y corregir en Odoo las órdenes abiertas por error.",
|
||||||
|
"UNCONFIRMED_ACTIVITY":
|
||||||
|
"Clasificar el intervalo y crear contexto explícito solo cuando exista confirmación humana.",
|
||||||
|
"ORDER_WITHOUT_SESSION":
|
||||||
|
"Confirmar si la orden era real o una transición breve de Odoo.",
|
||||||
|
"ORDER_SESSION_MISMATCH":
|
||||||
|
"Corregir Odoo o el mapeo antes de atribuir producción o paros.",
|
||||||
|
"SYNC_HEALTH":
|
||||||
|
"Resolver el problema técnico antes de utilizar datos nuevos.",
|
||||||
|
}.get(
|
||||||
|
family,
|
||||||
|
"Revisar la evidencia técnica y documentar una única resolución.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def episode_interval(
|
||||||
|
episode: dict[str, Any],
|
||||||
|
) -> tuple[datetime, datetime]:
|
||||||
|
start = episode["first_seen_at"]
|
||||||
|
end = (
|
||||||
|
episode.get("ended_at")
|
||||||
|
or episode.get("last_seen_at")
|
||||||
|
or start
|
||||||
|
)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_episodes(
|
||||||
|
episodes: list[dict[str, Any]],
|
||||||
|
grouping_window_s: int,
|
||||||
|
) -> list[list[dict[str, Any]]]:
|
||||||
|
grouped: dict[
|
||||||
|
tuple[str | None, str],
|
||||||
|
list[dict[str, Any]],
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
for episode in episodes:
|
||||||
|
family = case_family(episode)
|
||||||
|
grouped.setdefault(
|
||||||
|
(
|
||||||
|
episode.get("machine_id"),
|
||||||
|
family,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
).append(episode)
|
||||||
|
|
||||||
|
clusters: list[list[dict[str, Any]]] = []
|
||||||
|
grace = timedelta(seconds=grouping_window_s)
|
||||||
|
|
||||||
|
for (_, family), family_episodes in grouped.items():
|
||||||
|
ordered = sorted(
|
||||||
|
family_episodes,
|
||||||
|
key=lambda row: (
|
||||||
|
row["first_seen_at"],
|
||||||
|
row["episode_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if family != "MULTIPLE_OPEN_SESSIONS":
|
||||||
|
clusters.extend([[row] for row in ordered])
|
||||||
|
continue
|
||||||
|
|
||||||
|
current: list[dict[str, Any]] = []
|
||||||
|
current_end: datetime | None = None
|
||||||
|
|
||||||
|
for row in ordered:
|
||||||
|
start, end = episode_interval(row)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not current
|
||||||
|
or current_end is None
|
||||||
|
or start <= current_end + grace
|
||||||
|
):
|
||||||
|
current.append(row)
|
||||||
|
current_end = (
|
||||||
|
end
|
||||||
|
if current_end is None
|
||||||
|
else max(current_end, end)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
clusters.append(current)
|
||||||
|
current = [row]
|
||||||
|
current_end = end
|
||||||
|
|
||||||
|
if current:
|
||||||
|
clusters.append(current)
|
||||||
|
|
||||||
|
return clusters
|
||||||
|
|
||||||
|
|
||||||
|
def existing_case_for_cluster(
|
||||||
|
cursor,
|
||||||
|
episode_ids: list[int],
|
||||||
|
) -> int | None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT case_id
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_evidence
|
||||||
|
WHERE episode_id =
|
||||||
|
ANY(%s::bigint[])
|
||||||
|
ORDER BY case_id
|
||||||
|
""",
|
||||||
|
(episode_ids,),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
if len(rows) > 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Las evidencias del mismo cluster ya pertenecen "
|
||||||
|
f"a varios casos: {rows!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
int(rows[0]["case_id"])
|
||||||
|
if rows
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def case_key(
|
||||||
|
family: str,
|
||||||
|
machine_id: str | None,
|
||||||
|
episode_ids: list[int],
|
||||||
|
) -> str:
|
||||||
|
first_episode = min(episode_ids)
|
||||||
|
|
||||||
|
if family == "MULTIPLE_OPEN_SESSIONS":
|
||||||
|
return (
|
||||||
|
"ROOT:MULTIPLE_OPEN_SESSIONS:"
|
||||||
|
f"{machine_id or 'GLOBAL'}:"
|
||||||
|
f"{first_episode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"ROOT:EPISODE:{first_episode}"
|
||||||
|
|
||||||
|
|
||||||
|
def mirrored_review_state(
|
||||||
|
cluster: list[dict[str, Any]],
|
||||||
|
) -> tuple[str, str | None, str | None, datetime | None, str | None]:
|
||||||
|
statuses = {
|
||||||
|
str(row["review_status"])
|
||||||
|
for row in cluster
|
||||||
|
}
|
||||||
|
|
||||||
|
if "PENDING" in statuses:
|
||||||
|
return "PENDING", None, None, None, None
|
||||||
|
|
||||||
|
reviewed = [
|
||||||
|
row for row in cluster
|
||||||
|
if row["review_status"] == "REVIEWED"
|
||||||
|
]
|
||||||
|
if reviewed:
|
||||||
|
latest = max(
|
||||||
|
reviewed,
|
||||||
|
key=lambda row: (
|
||||||
|
row.get("reviewed_at")
|
||||||
|
or row["last_seen_at"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
classification = latest.get(
|
||||||
|
"review_classification"
|
||||||
|
)
|
||||||
|
mapped = (
|
||||||
|
classification
|
||||||
|
if classification in {
|
||||||
|
"AUTHORIZED_LEGACY_PRODUCTION",
|
||||||
|
"TEST_OR_SETUP",
|
||||||
|
"RESIDUAL_MATERIAL",
|
||||||
|
"ODOO_START_OMITTED",
|
||||||
|
"DATA_ISSUE",
|
||||||
|
"NOT_RELEVANT",
|
||||||
|
"OTHER",
|
||||||
|
}
|
||||||
|
else "OTHER"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"REVIEWED",
|
||||||
|
mapped,
|
||||||
|
latest.get("reviewed_by"),
|
||||||
|
latest.get("reviewed_at"),
|
||||||
|
latest.get("review_notes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if statuses == {"DISMISSED"}:
|
||||||
|
return (
|
||||||
|
"DISMISSED",
|
||||||
|
"NOT_RELEVANT",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"Todas las evidencias estaban descartadas.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return "NOT_REQUIRED", None, None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_role(episode: dict[str, Any]) -> str:
|
||||||
|
scope = episode.get("incident_scope")
|
||||||
|
|
||||||
|
if scope == "MACHINE":
|
||||||
|
return "MACHINE_ROOT"
|
||||||
|
if scope == "SESSION":
|
||||||
|
return "SESSION_EVIDENCE"
|
||||||
|
return "PRIMARY_EPISODE"
|
||||||
|
|
||||||
|
|
||||||
|
def process_cycle(
|
||||||
|
config: Config,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
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"{BUILDER_NAME}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not cursor.fetchone()["locked"]:
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"status": "skipped_lock_busy",
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
builder_interval_s,
|
||||||
|
grouping_window_s,
|
||||||
|
history_lookback_days
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_policies
|
||||||
|
WHERE tenant = %s
|
||||||
|
AND site = %s
|
||||||
|
AND policy_code = %s
|
||||||
|
AND active
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
POLICY_CODE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
policy = cursor.fetchone()
|
||||||
|
if not policy:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No existe política activa {POLICY_CODE!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM
|
||||||
|
mv_reports_ucepsa_prod
|
||||||
|
.li_shopfloor_context_incident_history_v1
|
||||||
|
WHERE review_required
|
||||||
|
AND (
|
||||||
|
episode_status = 'OPEN'
|
||||||
|
OR first_seen_at >=
|
||||||
|
now()
|
||||||
|
- (
|
||||||
|
%s
|
||||||
|
* interval '1 day'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
machine_id NULLS FIRST,
|
||||||
|
first_seen_at,
|
||||||
|
episode_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(
|
||||||
|
policy["history_lookback_days"]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
episodes = [
|
||||||
|
dict(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
clusters = cluster_episodes(
|
||||||
|
episodes,
|
||||||
|
int(policy["grouping_window_s"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
preview = []
|
||||||
|
link_count = 0
|
||||||
|
|
||||||
|
for cluster in clusters:
|
||||||
|
ids = sorted(
|
||||||
|
int(row["episode_id"])
|
||||||
|
for row in cluster
|
||||||
|
)
|
||||||
|
family = case_family(cluster[0])
|
||||||
|
machine_id = cluster[0].get(
|
||||||
|
"machine_id"
|
||||||
|
)
|
||||||
|
orders = sorted({
|
||||||
|
str(row["production_order"])
|
||||||
|
for row in cluster
|
||||||
|
if row.get("production_order")
|
||||||
|
})
|
||||||
|
first_seen = min(
|
||||||
|
row["first_seen_at"]
|
||||||
|
for row in cluster
|
||||||
|
)
|
||||||
|
last_seen = max(
|
||||||
|
row["last_seen_at"]
|
||||||
|
for row in cluster
|
||||||
|
)
|
||||||
|
open_rows = [
|
||||||
|
row for row in cluster
|
||||||
|
if row["episode_status"] == "OPEN"
|
||||||
|
]
|
||||||
|
ended_at = (
|
||||||
|
None
|
||||||
|
if open_rows
|
||||||
|
else max(
|
||||||
|
row.get("ended_at")
|
||||||
|
or row["last_seen_at"]
|
||||||
|
for row in cluster
|
||||||
|
)
|
||||||
|
)
|
||||||
|
max_severity = max(
|
||||||
|
(
|
||||||
|
str(row["max_severity"])
|
||||||
|
for row in cluster
|
||||||
|
),
|
||||||
|
key=severity_rank,
|
||||||
|
)
|
||||||
|
review_status, classification, reviewed_by, reviewed_at, notes = (
|
||||||
|
mirrored_review_state(cluster)
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_case_id = existing_case_for_cluster(
|
||||||
|
cursor,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
key = case_key(
|
||||||
|
family,
|
||||||
|
machine_id,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"case_id": existing_case_id,
|
||||||
|
"case_key": key,
|
||||||
|
"case_family": family,
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"episode_ids": ids,
|
||||||
|
"case_status": (
|
||||||
|
"OPEN"
|
||||||
|
if open_rows
|
||||||
|
else "CLOSED"
|
||||||
|
),
|
||||||
|
"first_seen_at": first_seen,
|
||||||
|
"last_seen_at": last_seen,
|
||||||
|
"ended_at": ended_at,
|
||||||
|
"max_severity": max_severity,
|
||||||
|
"review_status": review_status,
|
||||||
|
"orders": orders,
|
||||||
|
}
|
||||||
|
preview.append(item)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
|
||||||
|
owner = family_owner(family)
|
||||||
|
title = family_title(family)
|
||||||
|
question = question_text(
|
||||||
|
family,
|
||||||
|
machine_id,
|
||||||
|
orders,
|
||||||
|
)
|
||||||
|
action = recommended_action(family)
|
||||||
|
|
||||||
|
if existing_case_id is None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_cases (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
case_key,
|
||||||
|
case_family,
|
||||||
|
root_incident_code,
|
||||||
|
machine_id,
|
||||||
|
case_title,
|
||||||
|
question_text,
|
||||||
|
recommended_action,
|
||||||
|
case_status,
|
||||||
|
max_severity,
|
||||||
|
first_seen_at,
|
||||||
|
last_seen_at,
|
||||||
|
ended_at,
|
||||||
|
evidence_count,
|
||||||
|
review_owner,
|
||||||
|
review_required,
|
||||||
|
review_status,
|
||||||
|
resolution_classification,
|
||||||
|
reviewed_by,
|
||||||
|
reviewed_at,
|
||||||
|
review_notes,
|
||||||
|
official_eligible
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, 0,
|
||||||
|
%s, true, %s,
|
||||||
|
%s, %s, %s, %s, false
|
||||||
|
)
|
||||||
|
RETURNING case_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
key,
|
||||||
|
family,
|
||||||
|
str(
|
||||||
|
cluster[0][
|
||||||
|
"incident_code"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
machine_id,
|
||||||
|
title,
|
||||||
|
question,
|
||||||
|
action,
|
||||||
|
(
|
||||||
|
"OPEN"
|
||||||
|
if open_rows
|
||||||
|
else "CLOSED"
|
||||||
|
),
|
||||||
|
max_severity,
|
||||||
|
first_seen,
|
||||||
|
last_seen,
|
||||||
|
ended_at,
|
||||||
|
owner,
|
||||||
|
review_status,
|
||||||
|
classification,
|
||||||
|
reviewed_by,
|
||||||
|
reviewed_at,
|
||||||
|
notes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
case_id = int(
|
||||||
|
cursor.fetchone()["case_id"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
case_id = existing_case_id
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_cases
|
||||||
|
SET
|
||||||
|
case_family = %s,
|
||||||
|
root_incident_code = %s,
|
||||||
|
machine_id = %s,
|
||||||
|
case_title = %s,
|
||||||
|
question_text = %s,
|
||||||
|
recommended_action = %s,
|
||||||
|
case_status = %s,
|
||||||
|
max_severity = %s,
|
||||||
|
first_seen_at = LEAST(
|
||||||
|
first_seen_at,
|
||||||
|
%s
|
||||||
|
),
|
||||||
|
last_seen_at = GREATEST(
|
||||||
|
last_seen_at,
|
||||||
|
%s
|
||||||
|
),
|
||||||
|
ended_at = %s,
|
||||||
|
review_owner = %s,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE case_id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
family,
|
||||||
|
str(
|
||||||
|
cluster[0][
|
||||||
|
"incident_code"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
machine_id,
|
||||||
|
title,
|
||||||
|
question,
|
||||||
|
action,
|
||||||
|
(
|
||||||
|
"OPEN"
|
||||||
|
if open_rows
|
||||||
|
else "CLOSED"
|
||||||
|
),
|
||||||
|
max_severity,
|
||||||
|
first_seen,
|
||||||
|
last_seen,
|
||||||
|
ended_at,
|
||||||
|
owner,
|
||||||
|
case_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in cluster:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_evidence (
|
||||||
|
case_id,
|
||||||
|
episode_id,
|
||||||
|
evidence_role
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (episode_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
evidence_role =
|
||||||
|
EXCLUDED.evidence_role
|
||||||
|
WHERE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_evidence
|
||||||
|
.case_id =
|
||||||
|
EXCLUDED.case_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
case_id,
|
||||||
|
int(row["episode_id"]),
|
||||||
|
evidence_role(row),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
link_count += cursor.rowcount
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_cases c
|
||||||
|
SET
|
||||||
|
evidence_count = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_evidence e
|
||||||
|
WHERE e.case_id =
|
||||||
|
c.case_id
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE c.case_id = %s
|
||||||
|
""",
|
||||||
|
(case_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"status": "dry_run",
|
||||||
|
"episode_count": len(episodes),
|
||||||
|
"case_count": len(clusters),
|
||||||
|
"cases": preview,
|
||||||
|
"builder_interval_s": int(
|
||||||
|
policy["builder_interval_s"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO
|
||||||
|
mv_loss_intelligence
|
||||||
|
.context_review_case_builder_state (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
builder_name,
|
||||||
|
last_success_at,
|
||||||
|
last_episode_count,
|
||||||
|
last_case_count,
|
||||||
|
last_link_count,
|
||||||
|
last_error,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, now(),
|
||||||
|
%s, %s, %s, NULL, now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
builder_name
|
||||||
|
)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_success_at =
|
||||||
|
EXCLUDED.last_success_at,
|
||||||
|
last_episode_count =
|
||||||
|
EXCLUDED.last_episode_count,
|
||||||
|
last_case_count =
|
||||||
|
EXCLUDED.last_case_count,
|
||||||
|
last_link_count =
|
||||||
|
EXCLUDED.last_link_count,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
BUILDER_NAME,
|
||||||
|
len(episodes),
|
||||||
|
len(clusters),
|
||||||
|
link_count,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": VERSION,
|
||||||
|
"status": "ok",
|
||||||
|
"episode_count": len(episodes),
|
||||||
|
"case_count": len(clusters),
|
||||||
|
"link_count": link_count,
|
||||||
|
"builder_interval_s": int(
|
||||||
|
policy["builder_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
|
||||||
|
.context_review_case_builder_state (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
builder_name,
|
||||||
|
last_error,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (
|
||||||
|
tenant,
|
||||||
|
site,
|
||||||
|
builder_name
|
||||||
|
)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_error =
|
||||||
|
EXCLUDED.last_error,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.tenant,
|
||||||
|
config.site,
|
||||||
|
BUILDER_NAME,
|
||||||
|
str(error)[:4000],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as state_error:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status":
|
||||||
|
"builder_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=(
|
||||||
|
"Construye casos raíz SHADOW a partir "
|
||||||
|
"de episodios técnicos."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mode = parser.add_mutually_exclusive_group(
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
mode.add_argument("--once", action="store_true")
|
||||||
|
mode.add_argument("--watch", action="store_true")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--interval", type=int)
|
||||||
|
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
|
||||||
|
or int(
|
||||||
|
result.get(
|
||||||
|
"builder_interval_s",
|
||||||
|
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