feat(ucepsa): consolidate root cases into human decisions shadow
This commit is contained in:
parent
1fca1d6232
commit
72c57f4ce9
@ -0,0 +1,160 @@
|
||||
# UCEPSA — Decisiones humanas consolidadas SHADOW v0.3.11
|
||||
|
||||
## Objetivo
|
||||
|
||||
Reducir la carga de revisión:
|
||||
|
||||
```text
|
||||
10 episodios técnicos
|
||||
→ 7 casos raíz
|
||||
→ aproximadamente 3 decisiones humanas
|
||||
```
|
||||
|
||||
No se elimina ningún intervalo, caso o evidencia.
|
||||
|
||||
## Agrupación inicial
|
||||
|
||||
### Actividad sin contexto
|
||||
|
||||
```text
|
||||
misma máquina
|
||||
+ mismo día local Europe/Madrid
|
||||
+ separación <= 15 min
|
||||
→ una decisión humana
|
||||
```
|
||||
|
||||
### Conflictos de arranque Odoo
|
||||
|
||||
```text
|
||||
MULTIPLE_OPEN_SESSIONS
|
||||
+ ORDER_WITHOUT_SESSION
|
||||
+ misma máquina
|
||||
+ separación <= 5 min
|
||||
→ una decisión humana
|
||||
```
|
||||
|
||||
No se inventan turnos horarios porque todavía no existe un calendario de turnos
|
||||
confirmado. El día local actúa como frontera conservadora.
|
||||
|
||||
## Casos esperados
|
||||
|
||||
```text
|
||||
CORT-00 casos 1/2/3/4
|
||||
→ una decisión sobre sistema antiguo
|
||||
|
||||
CORT-00 casos 5/6
|
||||
→ una decisión WH/MO/00660 vs WH/MO/00706
|
||||
|
||||
CORT-02 casos 7/8
|
||||
→ una decisión sobre actividad posterior sin sesión
|
||||
```
|
||||
|
||||
## Revisión
|
||||
|
||||
La revisión de una decisión:
|
||||
|
||||
- registra una única respuesta humana;
|
||||
- revisa únicamente los casos raíz pendientes;
|
||||
- revisa únicamente los episodios pendientes;
|
||||
- conserva las revisiones ya existentes;
|
||||
- no modifica Odoo;
|
||||
- no crea contextos retrospectivos automáticamente;
|
||||
- no habilita el Ledger oficial.
|
||||
|
||||
## Despliegue
|
||||
|
||||
```bash
|
||||
FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/113_ucepsa_context_decision_groups_shadow_v0311.sql
|
||||
|
||||
docker exec -i mv_ucepsa_postgres_hot sh -lc \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
< "$FILE"
|
||||
```
|
||||
|
||||
No hay servicio nuevo: la agrupación es determinista mediante vistas SQL.
|
||||
|
||||
## Runtime
|
||||
|
||||
```bash
|
||||
RUNTIME=/srv/mesavault/edge-oee-ucepsa
|
||||
TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo
|
||||
|
||||
install -m 0644 \
|
||||
"$TARGET/tools/context_decision_group_admin.py" \
|
||||
"$RUNTIME/tools/"
|
||||
```
|
||||
|
||||
## Validación
|
||||
|
||||
```bash
|
||||
VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_decision_groups_v0311.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
|
||||
root_case_assignment_duplicates = 0
|
||||
recent_root_cases_without_decision_group = 0
|
||||
|
||||
decision_group_count_1_2_3_4 = 1
|
||||
linked_case_count_1_2_3_4 = 4
|
||||
|
||||
decision_group_count_5_6 = 1
|
||||
linked_case_count_5_6 = 2
|
||||
|
||||
decision_group_count_7_8 = 1
|
||||
linked_case_count_7_8 = 2
|
||||
|
||||
decision_group_count_exceeds_root_case_count = 0
|
||||
ready_v3_gate_mismatches = 0
|
||||
|
||||
official_decision_group_rows = 0
|
||||
official_gate_rows = 0
|
||||
official_operator_queue_rows = 0
|
||||
```
|
||||
|
||||
## Consola
|
||||
|
||||
```bash
|
||||
MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
|
||||
|
||||
"$MANAGER" list --limit 50
|
||||
"$MANAGER" show \
|
||||
--decision-group-key 'DECISION:...'
|
||||
```
|
||||
|
||||
No revisar el grupo de doble arranque hasta recibir la respuesta de Juan Pablo.
|
||||
|
||||
Cuando responda:
|
||||
|
||||
```bash
|
||||
"$MANAGER" review \
|
||||
--decision-group-key 'DECISION:ODOO_ORDER_START_CONFLICT:CORT-00:20260720:5' \
|
||||
--classification ODOO_DOUBLE_START_ONE_VALID \
|
||||
--selected-order-ref "WH/MO/00706" \
|
||||
--selected-workorder-id 698 \
|
||||
--reviewed-by "Juan Pablo" \
|
||||
--notes "Confirma la orden real y la orden abierta por error."
|
||||
```
|
||||
|
||||
## Dashboard
|
||||
|
||||
Se mantiene el mismo UID y se añaden:
|
||||
|
||||
```text
|
||||
Decisiones humanas pendientes
|
||||
Casos raíz incluidos en cada decisión
|
||||
Historial de decisiones humanas
|
||||
```
|
||||
|
||||
Readiness v3 compara:
|
||||
|
||||
```text
|
||||
evidencias pendientes
|
||||
casos raíz pendientes
|
||||
decisiones humanas pendientes
|
||||
```
|
||||
@ -15,7 +15,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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.",
|
||||
"description": "Supervisión SHADOW de sesiones, incidencias, arbitraje, casos raíz y decisiones humanas consolidadas. La carga de revisión se mide por preguntas operativas, conservando la auditoría de casos y evidencias.",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
@ -2045,11 +2045,11 @@
|
||||
"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_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",
|
||||
"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_decision_group_count\n AS \"Decisiones pendientes\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n pending_review_count\n AS \"Evidencias pendientes\",\n oldest_pending_decision_group_hours\n AS \"Antigüedad decisió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_v3\nORDER BY machine_id",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Readiness por máquina · casos raíz",
|
||||
"title": "Readiness por máquina · decisiones humanas",
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
@ -2166,7 +2166,7 @@
|
||||
"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_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",
|
||||
"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_v3\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"
|
||||
}
|
||||
],
|
||||
@ -2231,7 +2231,7 @@
|
||||
"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\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",
|
||||
"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_decision_group_count\n AS \"Decisiones pendientes\",\n pending_root_case_count\n AS \"Casos raíz pendientes\",\n pending_episode_review_count\n AS \"Evidencias 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_v3",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@ -2609,6 +2609,378 @@
|
||||
],
|
||||
"title": "Historial de casos raíz · últimos 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": {
|
||||
"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": "Pregunta operativa"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.width",
|
||||
"value": 400
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": 157
|
||||
},
|
||||
"id": 23,
|
||||
"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 decision_group_key AS \"Decisión\",\n CASE decision_status\n WHEN 'OPEN' THEN 'Abierta'\n ELSE 'Cerrada'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Pregunta operativa\",\n array_to_string(\n root_case_ids,\n ', '\n ) AS \"Casos 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 pending_root_case_count\n AS \"Casos pendientes\",\n reviewed_root_case_count\n AS \"Casos revisados\",\n array_to_string(\n known_resolution_classifications,\n ', '\n ) AS \"Resolución conocida\",\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 decision_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_decision_group_backlog_v1\nORDER BY backlog_rank\nLIMIT 50",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Decisiones humanas pendientes",
|
||||
"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": "Caso"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.width",
|
||||
"value": 400
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 168
|
||||
},
|
||||
"id": 24,
|
||||
"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 decision_group_key AS \"Decisión\",\n decision_case_role AS \"Rol\",\n case_id AS \"Caso raíz\",\n case_title AS \"Caso\",\n case_status AS \"Estado\",\n case_severity AS \"Severidad\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n case_duration_min AS \"Duración min\",\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_status AS \"Revisión\",\n COALESCE(\n case_resolution_classification,\n '—'\n ) AS \"Resolución\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_decision_group_cases_v1\nWHERE decision_review_status =\n 'PENDING'\nORDER BY\n decision_group_key,\n case_id\nLIMIT 200",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Casos raíz incluidos en cada decisió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": "Pregunta operativa"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.width",
|
||||
"value": 420
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 178
|
||||
},
|
||||
"id": 25,
|
||||
"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 decision_group_key AS \"Decisión\",\n CASE decision_status\n WHEN 'OPEN' THEN 'Abierta'\n ELSE 'Cerrada'\n END AS \"Estado\",\n max_severity AS \"Severidad\",\n machine_id AS \"Máquina\",\n decision_title AS \"Pregunta operativa\",\n first_seen_at AS \"Inicio\",\n ended_at AS \"Fin\",\n decision_duration_min AS \"Duración min\",\n linked_root_case_count AS \"Casos raíz\",\n pending_root_case_count AS \"Pendientes\",\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_decision_group_history_v1\nWHERE first_seen_at >=\n now() - interval '7 days'\nORDER BY first_seen_at DESC\nLIMIT 100",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Historial de decisiones humanas · últimos 7 días",
|
||||
"type": "table"
|
||||
}
|
||||
],
|
||||
"refresh": "15s",
|
||||
@ -2630,7 +3002,10 @@
|
||||
"v039",
|
||||
"root-cases",
|
||||
"human-governance",
|
||||
"v0310"
|
||||
"v0310",
|
||||
"decision-groups",
|
||||
"temporal-consolidation",
|
||||
"v0311"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
|
||||
275
ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh
Executable file
275
ucepsa/edge-oee-demo/ops/deploy_context_decision_groups_dashboard_v0311.sh
Executable file
@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ACTION="${1:-deploy}"
|
||||
GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}"
|
||||
REPO_ROOT="${DECISION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}"
|
||||
DASHBOARD_FILE="${DECISION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}"
|
||||
FOLDER_UID="${DECISION_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
|
||||
echo "ERROR: falta GRAFANA_API_TOKEN." >&2
|
||||
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 decision groups "
|
||||
"SHADOW v0.3.11"
|
||||
),
|
||||
},
|
||||
)
|
||||
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", [])) != 25:
|
||||
errors.append(
|
||||
"El dashboard no tiene 25 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 = {
|
||||
23: "li_context_decision_group_backlog_v1",
|
||||
24: "li_context_decision_group_cases_v1",
|
||||
25: "li_context_decision_group_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} no consulta {fragment}"
|
||||
)
|
||||
|
||||
readiness_sql = (
|
||||
panels.get(17, {})
|
||||
.get("targets", [{}])[0]
|
||||
.get("rawSql", "")
|
||||
)
|
||||
if (
|
||||
"li_canonical_context_readiness_gate_v3"
|
||||
not in readiness_sql
|
||||
):
|
||||
errors.append(
|
||||
"El panel readiness no usa gate v3"
|
||||
)
|
||||
|
||||
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"),
|
||||
"decision_group_panels": {
|
||||
"backlog": 23 in panels,
|
||||
"root_cases": 24 in panels,
|
||||
"history": 25 in panels,
|
||||
"readiness_v3":
|
||||
"li_canonical_context_readiness_gate_v3"
|
||||
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_decision_groups_dashboard_v0311.sh "
|
||||
"{deploy|validate}"
|
||||
)
|
||||
PY
|
||||
47
ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
Executable file
47
ucepsa/edge-oee-demo/ops/manage_context_decision_groups_v0311.sh
Executable file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ACTION="${1:-list}"
|
||||
ROOT="${CONTEXT_DECISION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}"
|
||||
NETWORK="${CONTEXT_DECISION_NETWORK:-mv_ucepsa_net}"
|
||||
POSTGRES_CONTAINER="${CONTEXT_DECISION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}"
|
||||
TENANT_OVERRIDE="${CONTEXT_DECISION_TENANT_OVERRIDE:-ucepsa}"
|
||||
SITE_OVERRIDE="${CONTEXT_DECISION_SITE_OVERRIDE:-ucepsa_onpremise}"
|
||||
PGHOST_OVERRIDE="${CONTEXT_DECISION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
IMAGE="$(
|
||||
docker inspect \
|
||||
mv_ucepsa_odoo_order_state_pg_sink \
|
||||
--format '{{.Config.Image}}'
|
||||
)"
|
||||
|
||||
PGDATABASE_OVERRIDE="${CONTEXT_DECISION_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
|
||||
|
||||
shift || true
|
||||
|
||||
docker run --rm -i \
|
||||
--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 \
|
||||
"$IMAGE" \
|
||||
tools/context_decision_group_admin.py \
|
||||
"$ACTION" \
|
||||
"$@"
|
||||
@ -0,0 +1,221 @@
|
||||
\pset pager off
|
||||
|
||||
\echo '=== 1. Objetos v0.3.11 ==='
|
||||
|
||||
SELECT
|
||||
to_regclass(
|
||||
'mv_loss_intelligence.context_decision_group_policies'
|
||||
) AS policy_table,
|
||||
to_regclass(
|
||||
'mv_loss_intelligence.context_decision_group_reviews'
|
||||
) AS reviews_table,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_context_decision_group_case_assignments_v1'
|
||||
) AS assignments_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_context_decision_group_history_v1'
|
||||
) AS history_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_context_decision_group_backlog_v1'
|
||||
) AS backlog_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_context_decision_group_cases_v1'
|
||||
) AS cases_view,
|
||||
to_regclass(
|
||||
'mv_reports_ucepsa_prod.li_canonical_context_readiness_gate_v3'
|
||||
) AS readiness_v3_view;
|
||||
|
||||
\echo '=== 2. Política de consolidación ==='
|
||||
|
||||
SELECT
|
||||
source_case_family,
|
||||
decision_family,
|
||||
max_gap_s,
|
||||
require_same_local_day,
|
||||
review_owner,
|
||||
timezone_name,
|
||||
active
|
||||
FROM
|
||||
mv_loss_intelligence
|
||||
.context_decision_group_policies
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
AND policy_code =
|
||||
'CONTEXT_DECISION_GROUPS_V1'
|
||||
ORDER BY source_case_family;
|
||||
|
||||
\echo '=== 3. Cada caso raíz aparece una sola vez ==='
|
||||
|
||||
SELECT COUNT(*) AS root_case_assignment_duplicates
|
||||
FROM (
|
||||
SELECT
|
||||
case_id,
|
||||
COUNT(*) AS n
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_case_assignments_v1
|
||||
GROUP BY case_id
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicated;
|
||||
|
||||
\echo '=== 4. Casos recientes sin decisión consolidada ==='
|
||||
|
||||
SELECT COUNT(*) AS recent_root_cases_without_decision_group
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_review_case_history_v1 c
|
||||
WHERE c.review_required
|
||||
AND (
|
||||
c.case_status = 'OPEN'
|
||||
OR c.first_seen_at >=
|
||||
now() - interval '30 days'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_case_assignments_v1 a
|
||||
WHERE a.case_id =
|
||||
c.case_id
|
||||
);
|
||||
|
||||
\echo '=== 5. CORT-00 actividad sin contexto: casos 1/2/3/4 ==='
|
||||
|
||||
SELECT
|
||||
COUNT(
|
||||
DISTINCT decision_group_key
|
||||
) AS decision_group_count_1_2_3_4,
|
||||
COUNT(*) AS linked_case_count_1_2_3_4
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_case_assignments_v1
|
||||
WHERE case_id IN (
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
);
|
||||
|
||||
\echo '=== 6. CORT-00 doble arranque: casos 5/6 ==='
|
||||
|
||||
SELECT
|
||||
COUNT(
|
||||
DISTINCT decision_group_key
|
||||
) AS decision_group_count_5_6,
|
||||
COUNT(*) AS linked_case_count_5_6
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_case_assignments_v1
|
||||
WHERE case_id IN (
|
||||
5,
|
||||
6
|
||||
);
|
||||
|
||||
\echo '=== 7. CORT-02 actividad posterior: casos 7/8 ==='
|
||||
|
||||
SELECT
|
||||
COUNT(
|
||||
DISTINCT decision_group_key
|
||||
) AS decision_group_count_7_8,
|
||||
COUNT(*) AS linked_case_count_7_8
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_case_assignments_v1
|
||||
WHERE case_id IN (
|
||||
7,
|
||||
8
|
||||
);
|
||||
|
||||
\echo '=== 8. Decisiones humanas pendientes ==='
|
||||
|
||||
SELECT
|
||||
backlog_rank,
|
||||
decision_group_key,
|
||||
decision_status,
|
||||
max_severity,
|
||||
machine_id,
|
||||
decision_family,
|
||||
decision_title,
|
||||
root_case_ids,
|
||||
production_orders,
|
||||
odoo_workorder_ids,
|
||||
pending_root_case_count,
|
||||
reviewed_root_case_count,
|
||||
known_resolution_classifications,
|
||||
review_owner,
|
||||
review_status,
|
||||
question_text
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_backlog_v1
|
||||
ORDER BY backlog_rank
|
||||
LIMIT 50;
|
||||
|
||||
\echo '=== 9. Comparación de carga humana ==='
|
||||
|
||||
SELECT
|
||||
machine_id,
|
||||
pending_review_count
|
||||
AS pending_episode_review_count,
|
||||
pending_root_case_count,
|
||||
pending_decision_group_count,
|
||||
pending_critical_decision_group_count,
|
||||
oldest_pending_decision_group_hours,
|
||||
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_v3
|
||||
ORDER BY machine_id;
|
||||
|
||||
\echo '=== 10. Consolidación nunca aumenta la carga humana ==='
|
||||
|
||||
SELECT COUNT(*) AS decision_group_count_exceeds_root_case_count
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_canonical_context_readiness_gate_v3
|
||||
WHERE pending_decision_group_count >
|
||||
pending_root_case_count;
|
||||
|
||||
\echo '=== 11. READY v3 coherente ==='
|
||||
|
||||
SELECT COUNT(*) AS ready_v3_gate_mismatches
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_canonical_context_readiness_gate_v3
|
||||
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 '=== 12. Nada oficial ==='
|
||||
|
||||
SELECT
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_history_v1
|
||||
WHERE official_eligible
|
||||
) AS official_decision_group_rows,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_canonical_context_readiness_gate_v3
|
||||
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
650
ucepsa/edge-oee-demo/tools/context_decision_group_admin.py
Executable file
650
ucepsa/edge-oee-demo/tools/context_decision_group_admin.py
Executable file
@ -0,0 +1,650 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Administración de decisiones humanas consolidadas v0.3.11."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
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_decision_group_admin_v0311"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): json_safe(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [
|
||||
json_safe(item)
|
||||
for item in value
|
||||
]
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
|
||||
def print_json(value: Any) -> None:
|
||||
print(
|
||||
json.dumps(
|
||||
json_safe(value),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_groups(limit: int) -> None:
|
||||
with connect() as connection:
|
||||
with connection.cursor(
|
||||
cursor_factory=RealDictCursor,
|
||||
) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
backlog_rank,
|
||||
decision_group_key,
|
||||
decision_status,
|
||||
max_severity,
|
||||
first_seen_at,
|
||||
last_seen_at,
|
||||
decision_duration_min,
|
||||
machine_id,
|
||||
decision_title,
|
||||
root_case_ids,
|
||||
production_orders,
|
||||
odoo_workorder_ids,
|
||||
linked_root_case_count,
|
||||
pending_root_case_count,
|
||||
reviewed_root_case_count,
|
||||
known_resolution_classifications,
|
||||
review_owner,
|
||||
review_status,
|
||||
question_text,
|
||||
recommended_action
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_backlog_v1
|
||||
ORDER BY backlog_rank
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
print_json(
|
||||
[dict(row) for row in cursor.fetchall()]
|
||||
)
|
||||
|
||||
|
||||
def show_group(decision_group_key: str) -> None:
|
||||
with connect() as connection:
|
||||
with connection.cursor(
|
||||
cursor_factory=RealDictCursor,
|
||||
) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_history_v1
|
||||
WHERE decision_group_key = %s
|
||||
""",
|
||||
(decision_group_key,),
|
||||
)
|
||||
group = cursor.fetchone()
|
||||
if not group:
|
||||
raise RuntimeError(
|
||||
"No existe la decisión "
|
||||
f"{decision_group_key!r}"
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_cases_v1
|
||||
WHERE decision_group_key = %s
|
||||
ORDER BY case_id
|
||||
""",
|
||||
(decision_group_key,),
|
||||
)
|
||||
cases = [
|
||||
dict(row)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM
|
||||
mv_loss_intelligence
|
||||
.context_decision_group_reviews
|
||||
WHERE tenant = %s
|
||||
AND site = %s
|
||||
AND decision_group_key = %s
|
||||
ORDER BY reviewed_at, review_id
|
||||
""",
|
||||
(
|
||||
group["tenant"],
|
||||
group["site"],
|
||||
decision_group_key,
|
||||
),
|
||||
)
|
||||
reviews = [
|
||||
dict(row)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
print_json({
|
||||
"decision_group": dict(group),
|
||||
"root_cases": cases,
|
||||
"reviews": reviews,
|
||||
})
|
||||
|
||||
|
||||
def episode_classification(
|
||||
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[classification]
|
||||
|
||||
|
||||
def review_group(
|
||||
decision_group_key: str,
|
||||
classification: str,
|
||||
reviewed_by: str,
|
||||
notes: str | None,
|
||||
selected_order_ref: str | None,
|
||||
selected_workorder_id: int | None,
|
||||
) -> None:
|
||||
if (
|
||||
classification ==
|
||||
"ODOO_DOUBLE_START_ONE_VALID"
|
||||
and (
|
||||
not selected_order_ref
|
||||
or selected_workorder_id is None
|
||||
)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"ODOO_DOUBLE_START_ONE_VALID exige "
|
||||
"--selected-order-ref y "
|
||||
"--selected-workorder-id."
|
||||
)
|
||||
|
||||
new_status = (
|
||||
"DISMISSED"
|
||||
if classification == "NOT_RELEVANT"
|
||||
else "REVIEWED"
|
||||
)
|
||||
episode_status = new_status
|
||||
episode_class = episode_classification(
|
||||
classification
|
||||
)
|
||||
|
||||
with connect() as connection:
|
||||
with connection.cursor(
|
||||
cursor_factory=RealDictCursor,
|
||||
) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_history_v1
|
||||
WHERE decision_group_key = %s
|
||||
""",
|
||||
(decision_group_key,),
|
||||
)
|
||||
group = cursor.fetchone()
|
||||
if not group:
|
||||
raise RuntimeError(
|
||||
"No existe la decisión "
|
||||
f"{decision_group_key!r}"
|
||||
)
|
||||
|
||||
if group["review_status"] != "PENDING":
|
||||
raise RuntimeError(
|
||||
"La decisión ya no está pendiente: "
|
||||
f"{group['review_status']}"
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO
|
||||
mv_loss_intelligence
|
||||
.context_decision_group_reviews (
|
||||
tenant,
|
||||
site,
|
||||
decision_group_key,
|
||||
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, %s, %s
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
group["tenant"],
|
||||
group["site"],
|
||||
decision_group_key,
|
||||
Json(json_safe(dict(group))),
|
||||
new_status,
|
||||
classification,
|
||||
selected_order_ref,
|
||||
selected_workorder_id,
|
||||
reviewed_by,
|
||||
notes,
|
||||
Json({
|
||||
"source":
|
||||
"context_decision_group_admin_v0311",
|
||||
"cascade_to_root_cases":
|
||||
True,
|
||||
"cascade_to_episodes":
|
||||
True,
|
||||
}),
|
||||
),
|
||||
)
|
||||
group_review = dict(
|
||||
cursor.fetchone()
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_cases_v1
|
||||
WHERE decision_group_key = %s
|
||||
ORDER BY case_id
|
||||
""",
|
||||
(decision_group_key,),
|
||||
)
|
||||
cases = [
|
||||
dict(row)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
cascaded_cases = []
|
||||
cascaded_episodes = []
|
||||
|
||||
for case in cases:
|
||||
if (
|
||||
case["case_review_status"]
|
||||
!= "PENDING"
|
||||
):
|
||||
continue
|
||||
|
||||
case_notes = (
|
||||
f"Decisión consolidada "
|
||||
f"{decision_group_key}: "
|
||||
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
|
||||
.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 review_id
|
||||
""",
|
||||
(
|
||||
case["case_id"],
|
||||
Json(json_safe({
|
||||
"review_status":
|
||||
case[
|
||||
"case_review_status"
|
||||
],
|
||||
"resolution_classification":
|
||||
case[
|
||||
"case_resolution_classification"
|
||||
],
|
||||
"reviewed_by":
|
||||
case["case_reviewed_by"],
|
||||
"reviewed_at":
|
||||
case["case_reviewed_at"],
|
||||
"review_notes":
|
||||
case["case_review_notes"],
|
||||
})),
|
||||
new_status,
|
||||
classification,
|
||||
selected_order_ref,
|
||||
selected_workorder_id,
|
||||
reviewed_by,
|
||||
case_notes,
|
||||
Json({
|
||||
"source_decision_group_key":
|
||||
decision_group_key,
|
||||
}),
|
||||
),
|
||||
)
|
||||
cascaded_cases.append({
|
||||
"case_id": case["case_id"],
|
||||
"review_id":
|
||||
cursor.fetchone()["review_id"],
|
||||
})
|
||||
|
||||
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["case_id"],),
|
||||
)
|
||||
episodes = [
|
||||
dict(row)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
for episode in episodes:
|
||||
if (
|
||||
episode["review_status"]
|
||||
!= "PENDING"
|
||||
):
|
||||
continue
|
||||
|
||||
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(json_safe({
|
||||
"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,
|
||||
case_notes,
|
||||
Json({
|
||||
"source_decision_group_key":
|
||||
decision_group_key,
|
||||
"source_case_id":
|
||||
case["case_id"],
|
||||
"root_classification":
|
||||
classification,
|
||||
}),
|
||||
),
|
||||
)
|
||||
cascaded_episodes.append({
|
||||
"episode_id":
|
||||
episode["episode_id"],
|
||||
"review_id":
|
||||
cursor.fetchone()[
|
||||
"review_id"
|
||||
],
|
||||
})
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM
|
||||
mv_reports_ucepsa_prod
|
||||
.li_context_decision_group_history_v1
|
||||
WHERE decision_group_key = %s
|
||||
""",
|
||||
(decision_group_key,),
|
||||
)
|
||||
current = dict(cursor.fetchone())
|
||||
|
||||
print_json({
|
||||
"decision_group_review":
|
||||
group_review,
|
||||
"decision_group":
|
||||
current,
|
||||
"cascaded_root_case_reviews":
|
||||
cascaded_cases,
|
||||
"cascaded_episode_reviews":
|
||||
cascaded_episodes,
|
||||
"warning": (
|
||||
"La decisión consolida la revisión. "
|
||||
"No modifica Odoo, no crea contextos "
|
||||
"retrospectivos automáticamente y no "
|
||||
"habilita el Ledger oficial."
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Administración de decisiones "
|
||||
"humanas consolidadas."
|
||||
)
|
||||
)
|
||||
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(
|
||||
"--decision-group-key",
|
||||
required=True,
|
||||
)
|
||||
|
||||
review_cmd = sub.add_parser("review")
|
||||
review_cmd.add_argument(
|
||||
"--decision-group-key",
|
||||
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_groups(args.limit)
|
||||
return 0
|
||||
|
||||
if args.command == "show":
|
||||
show_group(
|
||||
args.decision_group_key
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.command == "review":
|
||||
review_group(
|
||||
decision_group_key=
|
||||
args.decision_group_key,
|
||||
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())
|
||||
Loading…
x
Reference in New Issue
Block a user