feat(ucepsa): add multi-source production context shadow v0.3
This commit is contained in:
parent
36d67d99b0
commit
d83bacefc4
@ -0,0 +1,29 @@
|
||||
# ADR — Contexto productivo multifuente durante migraciones ERP
|
||||
|
||||
## Estado
|
||||
|
||||
Aceptado para UCEPSA en modo SHADOW.
|
||||
|
||||
## Problema
|
||||
|
||||
Una máquina puede producir legítimamente sin orden Odoo porque la planta mantiene
|
||||
temporalmente actividad en el ERP anterior.
|
||||
|
||||
Tratar todo `RUNNING_WITHOUT_VALID_ORDER` como error o pérdida produciría falsos
|
||||
positivos y dañaría la implantación.
|
||||
|
||||
## Decisión
|
||||
|
||||
MESAVAULT separa:
|
||||
|
||||
- verdad física: WISE/RevPi;
|
||||
- contexto productivo: Odoo, legacy, manual, mantenimiento o prueba;
|
||||
- pérdida: solo tras intersectar actividad, contexto confirmado y política económica.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- Odoo sigue siendo la fuente preferida.
|
||||
- Legacy se acepta como excepción explícita.
|
||||
- Sin contexto confirmado se genera una tarea de revisión, no un coste.
|
||||
- Las balizas no cambian de semántica.
|
||||
- El porcentaje Odoo/legacy/sin contexto se convierte en indicador de migración.
|
||||
@ -0,0 +1,80 @@
|
||||
# UCEPSA — LossIntelligence Production Context SHADOW v0.3
|
||||
|
||||
## Decisión de arquitectura
|
||||
|
||||
Durante la migración conviven dos vías válidas:
|
||||
|
||||
1. Odoo Shop Floor, estándar preferido.
|
||||
2. Producción legacy o manual autorizada.
|
||||
|
||||
`máquina en marcha + sin orden Odoo` no se convierte automáticamente en pérdida.
|
||||
|
||||
Primero se clasifica el contexto:
|
||||
|
||||
- ODOO_CONTEXT;
|
||||
- LEGACY_AUTHORIZED;
|
||||
- MANUAL_AUTHORIZED;
|
||||
- MAINTENANCE;
|
||||
- TEST_SETUP;
|
||||
- UNCONFIRMED_CONTEXT;
|
||||
- CONTEXT_CONFLICT.
|
||||
|
||||
## Protección de las balizas
|
||||
|
||||
Esta versión no modifica:
|
||||
|
||||
- `beacon_controller.py`;
|
||||
- el publicador `order_state`;
|
||||
- DO0 azul;
|
||||
- DO1 rojo.
|
||||
|
||||
Azul fijo continúa significando exclusivamente orden Odoo válida.
|
||||
|
||||
Una producción legacy puede seguir provocando azul intermitente porque está fuera del
|
||||
flujo Odoo, aunque MESAVAULT la reconozca como legacy autorizada.
|
||||
|
||||
## Flujo preventivo recomendado
|
||||
|
||||
Antes de producir por el ERP anterior, producción abre un contexto:
|
||||
|
||||
```bash
|
||||
python tools/li_context_admin.py open \
|
||||
--machine CORT-02 \
|
||||
--source LEGACY_ERP \
|
||||
--external-order-ref LEGACY-1234 \
|
||||
--product-name "Referencia legacy" \
|
||||
--authorized-by "Juan Pablo" \
|
||||
--notes "Producción autorizada fuera de Odoo durante migración"
|
||||
```
|
||||
|
||||
Al finalizar:
|
||||
|
||||
```bash
|
||||
python tools/li_context_admin.py close \
|
||||
--session-id 1 \
|
||||
--closed-by "Juan Pablo"
|
||||
```
|
||||
|
||||
## Flujo retrospectivo
|
||||
|
||||
Si no se abrió previamente, la vista `li_context_review_queue_v1` muestra un único
|
||||
bloque físico. Tras confirmar el intervalo se crea una sesión retrospectiva.
|
||||
|
||||
## Seguridad SHADOW
|
||||
|
||||
Mientras `deployment_config.mode = SHADOW`, la herramienta fuerza:
|
||||
|
||||
```text
|
||||
official_eligible = false
|
||||
```
|
||||
|
||||
Por tanto, ningún contexto manual puede crear por sí solo una pérdida económica oficial.
|
||||
|
||||
## Qué no resuelve todavía
|
||||
|
||||
- La identidad activa de la operaria en el flujo personalizado de Shop Floor.
|
||||
- El cierre real con declaración de producción.
|
||||
- La extensión de los paros v2 para usar contexto legacy.
|
||||
|
||||
La siguiente subfase será `v0.3.1`: cruzar los paros con los contextos validados tras
|
||||
comprobar que la apertura/cierre legacy funciona correctamente.
|
||||
@ -0,0 +1,128 @@
|
||||
\pset pager off
|
||||
|
||||
\echo '=== 1. Política de contexto ==='
|
||||
SELECT
|
||||
policy_code,
|
||||
activity_gap_s,
|
||||
activity_tail_s,
|
||||
stale_open_context_s,
|
||||
allow_legacy_context,
|
||||
require_legacy_authorization,
|
||||
allow_inferred_for_official
|
||||
FROM mv_loss_intelligence.production_context_policies
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
ORDER BY id DESC;
|
||||
|
||||
\echo '=== 2. Seguridad SHADOW ==='
|
||||
SELECT
|
||||
mode,
|
||||
actual_go_live_at
|
||||
FROM mv_loss_intelligence.deployment_config
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
AND scope_code = 'CUTTERS';
|
||||
|
||||
SELECT count(*) AS official_context_sessions_in_shadow
|
||||
FROM mv_loss_intelligence.production_context_sessions s
|
||||
JOIN mv_loss_intelligence.deployment_config d
|
||||
ON d.tenant = s.tenant
|
||||
AND d.site = s.site
|
||||
AND d.scope_code = 'CUTTERS'
|
||||
WHERE d.mode = 'SHADOW'
|
||||
AND s.official_eligible = true;
|
||||
|
||||
\echo '=== 3. Estado actual de migración ==='
|
||||
SELECT
|
||||
machine_id,
|
||||
operational_state,
|
||||
migration_context_state,
|
||||
migration_context_label,
|
||||
telemetry_fresh,
|
||||
machine_running,
|
||||
order_present,
|
||||
order_coherent,
|
||||
production_order,
|
||||
explicit_context_source,
|
||||
explicit_order_ref,
|
||||
explicit_context_started_at
|
||||
FROM mv_reports_ucepsa_prod.machine_migration_context_state_v1
|
||||
ORDER BY machine_id;
|
||||
|
||||
\echo '=== 4. Contextos explícitos ==='
|
||||
SELECT
|
||||
context_session_id,
|
||||
machine_id,
|
||||
source_type,
|
||||
source_ref,
|
||||
external_order_ref,
|
||||
product_name,
|
||||
operator_employee_id,
|
||||
operator_name,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
authorization_status,
|
||||
confidence,
|
||||
official_eligible,
|
||||
authorized_by,
|
||||
closed_by
|
||||
FROM mv_loss_intelligence.production_context_sessions
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
ORDER BY context_session_id DESC
|
||||
LIMIT 30;
|
||||
|
||||
\echo '=== 5. Invariantes de actividad: deben devolver 0 ==='
|
||||
SELECT count(*) AS invalid_activity_rows
|
||||
FROM mv_reports_ucepsa_prod.li_machine_running_activity_v1
|
||||
WHERE activity_to <= activity_from
|
||||
OR activity_duration_s <= 0;
|
||||
|
||||
SELECT count(*) AS invalid_context_segment_rows
|
||||
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
WHERE segment_to <= segment_from
|
||||
OR segment_duration_s <= 0;
|
||||
|
||||
\echo '=== 6. Los segmentos no superan la actividad: debe devolver 0 ==='
|
||||
WITH totals AS (
|
||||
SELECT
|
||||
machine_id,
|
||||
activity_island_id,
|
||||
max(activity_duration_s) AS activity_duration_s,
|
||||
sum(segment_duration_s) AS segmented_duration_s
|
||||
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
GROUP BY machine_id, activity_island_id
|
||||
)
|
||||
SELECT count(*) AS segment_overflow_rows
|
||||
FROM totals
|
||||
WHERE segmented_duration_s > activity_duration_s + 0.01;
|
||||
|
||||
\echo '=== 7. Cobertura de contexto últimos 30 días ==='
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_production_context_coverage_v1
|
||||
ORDER BY machine_id, hours DESC;
|
||||
|
||||
\echo '=== 8. Cola de revisión de contexto ==='
|
||||
SELECT
|
||||
review_rank,
|
||||
context_review_key,
|
||||
machine_id,
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_min,
|
||||
context_status,
|
||||
allowed_decisions
|
||||
FROM mv_reports_ucepsa_prod.li_context_review_queue_v1
|
||||
ORDER BY review_rank
|
||||
LIMIT 30;
|
||||
|
||||
\echo '=== 9. Calidad de dato v3 ==='
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_data_quality_v3
|
||||
ORDER BY check_code;
|
||||
|
||||
\echo '=== 10. Las balizas no forman parte de esta migración ==='
|
||||
SELECT
|
||||
'INFO' AS status,
|
||||
'099/100 no modifican beacon_controller, order_state publisher ni lógica DO0/DO1.' AS description;
|
||||
@ -0,0 +1,193 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.production_context_policies (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
policy_code TEXT NOT NULL,
|
||||
activity_gap_s NUMERIC(12,2) NOT NULL DEFAULT 90
|
||||
CHECK (activity_gap_s > 0),
|
||||
activity_tail_s NUMERIC(12,2) NOT NULL DEFAULT 30
|
||||
CHECK (activity_tail_s >= 0),
|
||||
stale_open_context_s NUMERIC(14,2) NOT NULL DEFAULT 43200
|
||||
CHECK (stale_open_context_s > 0),
|
||||
allow_legacy_context BOOLEAN NOT NULL DEFAULT true,
|
||||
require_legacy_authorization BOOLEAN NOT NULL DEFAULT true,
|
||||
allow_inferred_for_official BOOLEAN NOT NULL DEFAULT false,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant, site, policy_code)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE mv_loss_intelligence.production_context_policies IS
|
||||
'Políticas reutilizables para segmentar actividad física y reconocer contextos productivos Odoo, legacy, mantenimiento o prueba.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.production_context_sessions (
|
||||
context_session_id BIGSERIAL PRIMARY KEY,
|
||||
tenant TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL
|
||||
CHECK (source_type IN (
|
||||
'ODOO_SHOPFLOOR',
|
||||
'LEGACY_ERP',
|
||||
'MANUAL_AUTHORIZED',
|
||||
'MAINTENANCE',
|
||||
'TEST_SETUP',
|
||||
'INFERRED_SHADOW'
|
||||
)),
|
||||
source_ref TEXT,
|
||||
external_order_ref TEXT,
|
||||
odoo_production_id BIGINT,
|
||||
odoo_workorder_id BIGINT,
|
||||
product_id BIGINT,
|
||||
product_default_code TEXT,
|
||||
product_name TEXT,
|
||||
operator_employee_id BIGINT,
|
||||
operator_name TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'OPEN'
|
||||
CHECK (status IN ('OPEN', 'CLOSED', 'CANCELLED', 'SUPERSEDED')),
|
||||
authorization_status TEXT NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (authorization_status IN (
|
||||
'PENDING',
|
||||
'AUTHORIZED',
|
||||
'AUTO_CONFIRMED',
|
||||
'REJECTED'
|
||||
)),
|
||||
confidence TEXT NOT NULL DEFAULT 'LOW'
|
||||
CHECK (confidence IN ('HIGH', 'MEDIUM', 'LOW')),
|
||||
official_eligible BOOLEAN NOT NULL DEFAULT false,
|
||||
authorized_by TEXT,
|
||||
authorized_at TIMESTAMPTZ,
|
||||
closed_by TEXT,
|
||||
notes TEXT,
|
||||
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (ended_at IS NULL OR ended_at > started_at),
|
||||
CHECK (
|
||||
authorization_status NOT IN ('AUTHORIZED', 'AUTO_CONFIRMED')
|
||||
OR authorized_at IS NOT NULL
|
||||
),
|
||||
CHECK (
|
||||
status <> 'CLOSED'
|
||||
OR ended_at IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE mv_loss_intelligence.production_context_sessions IS
|
||||
'Contextos productivos explícitos. Durante la migración permite registrar producción legacy autorizada sin fingir que existe una orden Odoo.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_li_context_sessions_machine_time
|
||||
ON mv_loss_intelligence.production_context_sessions
|
||||
(tenant, site, machine_id, started_at DESC, ended_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_li_context_sessions_status
|
||||
ON mv_loss_intelligence.production_context_sessions
|
||||
(tenant, site, status, authorization_status, source_type);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_li_context_sessions_one_open_explicit
|
||||
ON mv_loss_intelligence.production_context_sessions
|
||||
(tenant, site, machine_id)
|
||||
WHERE status = 'OPEN'
|
||||
AND source_type IN (
|
||||
'LEGACY_ERP',
|
||||
'MANUAL_AUTHORIZED',
|
||||
'MAINTENANCE',
|
||||
'TEST_SETUP'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mv_loss_intelligence.production_context_reviews (
|
||||
review_id BIGSERIAL PRIMARY KEY,
|
||||
tenant TEXT NOT NULL,
|
||||
site TEXT NOT NULL,
|
||||
activity_source_key TEXT NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
activity_started_at TIMESTAMPTZ NOT NULL,
|
||||
activity_ended_at TIMESTAMPTZ NOT NULL,
|
||||
decision_type TEXT NOT NULL
|
||||
CHECK (decision_type IN (
|
||||
'CONFIRM_LEGACY',
|
||||
'CONFIRM_MANUAL',
|
||||
'CONFIRM_MAINTENANCE',
|
||||
'CONFIRM_TEST_SETUP',
|
||||
'ODOO_PROCESS_GAP',
|
||||
'UNKNOWN_CONTEXT',
|
||||
'IGNORE'
|
||||
)),
|
||||
created_context_session_id BIGINT
|
||||
REFERENCES mv_loss_intelligence.production_context_sessions(context_session_id),
|
||||
reviewed_by TEXT NOT NULL,
|
||||
reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
notes TEXT,
|
||||
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (tenant, site, activity_source_key)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE mv_loss_intelligence.production_context_reviews IS
|
||||
'Decisiones retrospectivas sobre bloques de actividad física que inicialmente no tenían contexto Odoo ni legacy registrado.';
|
||||
|
||||
ALTER TABLE mv_loss_intelligence.loss_ledger
|
||||
ADD COLUMN IF NOT EXISTS production_context_session_id BIGINT
|
||||
REFERENCES mv_loss_intelligence.production_context_sessions(context_session_id),
|
||||
ADD COLUMN IF NOT EXISTS production_context_source TEXT,
|
||||
ADD COLUMN IF NOT EXISTS production_context_confidence TEXT;
|
||||
|
||||
INSERT INTO mv_loss_intelligence.production_context_policies (
|
||||
tenant,
|
||||
site,
|
||||
policy_code,
|
||||
activity_gap_s,
|
||||
activity_tail_s,
|
||||
stale_open_context_s,
|
||||
allow_legacy_context,
|
||||
require_legacy_authorization,
|
||||
allow_inferred_for_official,
|
||||
is_active,
|
||||
notes
|
||||
)
|
||||
VALUES (
|
||||
'ucepsa',
|
||||
'ucepsa_onpremise',
|
||||
'CUTTER_CONTEXT_V1',
|
||||
90,
|
||||
30,
|
||||
43200,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
'La actividad física se separa con huecos >90 s y se prolonga 30 s tras la última muestra activa. Legacy es válido solo si está autorizado. La inferencia nunca alimenta el Ledger oficial.'
|
||||
)
|
||||
ON CONFLICT (tenant, site, policy_code)
|
||||
DO UPDATE SET
|
||||
activity_gap_s = EXCLUDED.activity_gap_s,
|
||||
activity_tail_s = EXCLUDED.activity_tail_s,
|
||||
stale_open_context_s = EXCLUDED.stale_open_context_s,
|
||||
allow_legacy_context = EXCLUDED.allow_legacy_context,
|
||||
require_legacy_authorization = EXCLUDED.require_legacy_authorization,
|
||||
allow_inferred_for_official = EXCLUDED.allow_inferred_for_official,
|
||||
is_active = true,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = now();
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro'
|
||||
) THEN
|
||||
GRANT USAGE ON SCHEMA mv_loss_intelligence TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_loss_intelligence.production_context_policies
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_loss_intelligence.production_context_sessions
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_loss_intelligence.production_context_reviews
|
||||
TO grafana_ucepsa_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@ -0,0 +1,705 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_machine_running_activity_v1 AS
|
||||
WITH policy AS (
|
||||
SELECT
|
||||
activity_gap_s::double precision AS activity_gap_s,
|
||||
activity_tail_s::double precision AS activity_tail_s
|
||||
FROM mv_loss_intelligence.production_context_policies
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
AND policy_code = 'CUTTER_CONTEXT_V1'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
), active_samples AS (
|
||||
SELECT
|
||||
r.tenant,
|
||||
r.site,
|
||||
r.machine_id,
|
||||
r.asset,
|
||||
r.ingest_ts,
|
||||
CASE
|
||||
WHEN lower(COALESCE(
|
||||
r.payload_json ->> 'machine_running',
|
||||
'false'
|
||||
)) IN ('true', 't', '1', 'yes')
|
||||
THEN true
|
||||
ELSE false
|
||||
END AS machine_running,
|
||||
COALESCE(
|
||||
NULLIF(r.payload_json ->> 'cycle_rate_ppm', '')::numeric,
|
||||
0
|
||||
) AS cycle_rate_ppm,
|
||||
p.activity_gap_s,
|
||||
p.activity_tail_s,
|
||||
lag(r.ingest_ts) OVER (
|
||||
PARTITION BY r.tenant, r.site, r.machine_id
|
||||
ORDER BY r.ingest_ts
|
||||
) AS previous_active_ts
|
||||
FROM mv_hot.edge_oee_eastron_readings r
|
||||
CROSS JOIN policy p
|
||||
WHERE r.tenant = 'ucepsa'
|
||||
AND r.site = 'ucepsa_onpremise'
|
||||
AND r.machine_id IS NOT NULL
|
||||
AND r.comm_ok = true
|
||||
AND lower(COALESCE(
|
||||
r.payload_json ->> 'sample_valid',
|
||||
'true'
|
||||
)) NOT IN ('false', 'f', '0', 'no')
|
||||
AND (
|
||||
lower(COALESCE(
|
||||
r.payload_json ->> 'machine_running',
|
||||
'false'
|
||||
)) IN ('true', 't', '1', 'yes')
|
||||
OR COALESCE(
|
||||
NULLIF(r.payload_json ->> 'cycle_rate_ppm', '')::numeric,
|
||||
0
|
||||
) > 0
|
||||
)
|
||||
), marked AS (
|
||||
SELECT
|
||||
s.*,
|
||||
CASE
|
||||
WHEN previous_active_ts IS NULL THEN 1
|
||||
WHEN ingest_ts - previous_active_ts
|
||||
> make_interval(secs => activity_gap_s) THEN 1
|
||||
ELSE 0
|
||||
END AS starts_new_activity
|
||||
FROM active_samples s
|
||||
), islanded AS (
|
||||
SELECT
|
||||
m.*,
|
||||
sum(starts_new_activity) OVER (
|
||||
PARTITION BY tenant, site, machine_id
|
||||
ORDER BY ingest_ts
|
||||
ROWS UNBOUNDED PRECEDING
|
||||
) AS activity_island_id
|
||||
FROM marked m
|
||||
)
|
||||
SELECT
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
max(asset) AS asset,
|
||||
activity_island_id,
|
||||
'ACTIVITY:' || machine_id || ':' || activity_island_id::text
|
||||
AS activity_source_key,
|
||||
min(ingest_ts) AS activity_from,
|
||||
max(ingest_ts)
|
||||
+ make_interval(secs => max(activity_tail_s)) AS activity_to,
|
||||
count(*) AS sample_count,
|
||||
round(
|
||||
EXTRACT(EPOCH FROM (
|
||||
max(ingest_ts)
|
||||
+ make_interval(secs => max(activity_tail_s))
|
||||
- min(ingest_ts)
|
||||
))::numeric,
|
||||
2
|
||||
) AS activity_duration_s,
|
||||
max(activity_gap_s)::numeric(12,2) AS activity_gap_s,
|
||||
max(activity_tail_s)::numeric(12,2) AS activity_tail_s
|
||||
FROM islanded
|
||||
GROUP BY tenant, site, machine_id, activity_island_id;
|
||||
|
||||
COMMENT ON VIEW mv_reports_ucepsa_prod.li_machine_running_activity_v1 IS
|
||||
'Bloques de actividad física real: marcha o ciclos, separados por huecos superiores a 90 segundos. No presupone que exista Odoo.';
|
||||
|
||||
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_production_context_intervals_v1 AS
|
||||
WITH explicit_contexts AS (
|
||||
SELECT
|
||||
s.tenant,
|
||||
s.site,
|
||||
s.machine_id,
|
||||
'SESSION:' || s.context_session_id::text AS context_key,
|
||||
s.context_session_id,
|
||||
s.source_type AS context_source,
|
||||
CASE s.source_type
|
||||
WHEN 'ODOO_SHOPFLOOR' THEN 5
|
||||
WHEN 'LEGACY_ERP' THEN 20
|
||||
WHEN 'MANUAL_AUTHORIZED' THEN 30
|
||||
WHEN 'MAINTENANCE' THEN 40
|
||||
WHEN 'TEST_SETUP' THEN 50
|
||||
ELSE 90
|
||||
END AS context_priority,
|
||||
s.started_at AS context_from,
|
||||
COALESCE(s.ended_at, now()) AS context_to,
|
||||
s.external_order_ref AS order_ref,
|
||||
s.odoo_production_id,
|
||||
s.odoo_workorder_id,
|
||||
s.product_id,
|
||||
s.product_default_code,
|
||||
s.product_name,
|
||||
s.operator_employee_id,
|
||||
s.operator_name,
|
||||
s.confidence,
|
||||
true AS context_confirmed,
|
||||
s.official_eligible AS official_loss_eligible,
|
||||
s.authorization_status,
|
||||
s.status,
|
||||
s.notes,
|
||||
s.evidence_json
|
||||
FROM mv_loss_intelligence.production_context_sessions s
|
||||
WHERE s.tenant = 'ucepsa'
|
||||
AND s.site = 'ucepsa_onpremise'
|
||||
AND s.status IN ('OPEN', 'CLOSED')
|
||||
AND s.authorization_status IN ('AUTHORIZED', 'AUTO_CONFIRMED')
|
||||
AND COALESCE(s.ended_at, now()) > s.started_at
|
||||
), odoo_contexts AS (
|
||||
SELECT
|
||||
o.tenant,
|
||||
o.site,
|
||||
o.machine_id,
|
||||
'ODOO:' || o.machine_id || ':' || o.id::text AS context_key,
|
||||
NULL::bigint AS context_session_id,
|
||||
'ODOO_ORDER_CONTEXT'::text AS context_source,
|
||||
10 AS context_priority,
|
||||
o.effective_from AS context_from,
|
||||
COALESCE(o.effective_to, now()) AS context_to,
|
||||
o.production_order AS order_ref,
|
||||
o.production_id AS odoo_production_id,
|
||||
NULLIF(o.workorder_id, '')::bigint AS odoo_workorder_id,
|
||||
o.product_id,
|
||||
NULL::text AS product_default_code,
|
||||
o.product AS product_name,
|
||||
NULL::bigint AS operator_employee_id,
|
||||
NULL::text AS operator_name,
|
||||
'HIGH'::text AS confidence,
|
||||
true AS context_confirmed,
|
||||
false AS official_loss_eligible,
|
||||
'AUTO_CONFIRMED'::text AS authorization_status,
|
||||
CASE
|
||||
WHEN o.effective_to IS NULL THEN 'OPEN'
|
||||
ELSE 'CLOSED'
|
||||
END AS status,
|
||||
'Contexto de orden Odoo válido. Aún no equivale a sesión oficial de operaria.'::text
|
||||
AS notes,
|
||||
jsonb_build_object(
|
||||
'order_state_event_id', o.id,
|
||||
'odoo_state', o.odoo_state,
|
||||
'workorder_state', o.workorder_state,
|
||||
'qty_production', o.qty_production
|
||||
) AS evidence_json
|
||||
FROM mv_reports_ucepsa_prod.li_order_state_intervals_v1 o
|
||||
WHERE o.tenant = 'ucepsa'
|
||||
AND o.site = 'ucepsa_onpremise'
|
||||
AND o.order_present = true
|
||||
AND o.order_coherent = true
|
||||
AND COALESCE(o.effective_to, now()) > o.effective_from
|
||||
)
|
||||
SELECT * FROM explicit_contexts
|
||||
UNION ALL
|
||||
SELECT * FROM odoo_contexts;
|
||||
|
||||
COMMENT ON VIEW mv_reports_ucepsa_prod.li_production_context_intervals_v1 IS
|
||||
'Contextos reconocidos para la migración: orden Odoo válida y sesiones explícitas legacy/manual/mantenimiento/prueba. Odoo order context todavía no se considera sesión oficial de operaria.';
|
||||
|
||||
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 AS
|
||||
WITH activities AS (
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_machine_running_activity_v1
|
||||
), overlaps AS (
|
||||
SELECT
|
||||
a.tenant,
|
||||
a.site,
|
||||
a.machine_id,
|
||||
a.asset,
|
||||
a.activity_island_id,
|
||||
a.activity_source_key,
|
||||
a.activity_from,
|
||||
a.activity_to,
|
||||
a.activity_duration_s,
|
||||
c.context_key,
|
||||
c.context_session_id,
|
||||
c.context_source,
|
||||
c.context_priority,
|
||||
c.context_from,
|
||||
c.context_to,
|
||||
c.order_ref,
|
||||
c.odoo_production_id,
|
||||
c.odoo_workorder_id,
|
||||
c.product_id,
|
||||
c.product_default_code,
|
||||
c.product_name,
|
||||
c.operator_employee_id,
|
||||
c.operator_name,
|
||||
c.confidence,
|
||||
c.context_confirmed,
|
||||
c.official_loss_eligible,
|
||||
c.authorization_status,
|
||||
c.status,
|
||||
c.notes,
|
||||
c.evidence_json,
|
||||
greatest(a.activity_from, c.context_from) AS overlap_from,
|
||||
least(a.activity_to, c.context_to) AS overlap_to
|
||||
FROM activities a
|
||||
JOIN mv_reports_ucepsa_prod.li_production_context_intervals_v1 c
|
||||
ON c.tenant = a.tenant
|
||||
AND c.site = a.site
|
||||
AND c.machine_id = a.machine_id
|
||||
AND a.activity_to > c.context_from
|
||||
AND a.activity_from < c.context_to
|
||||
WHERE least(a.activity_to, c.context_to)
|
||||
> greatest(a.activity_from, c.context_from)
|
||||
), boundaries AS (
|
||||
SELECT
|
||||
a.tenant,
|
||||
a.site,
|
||||
a.machine_id,
|
||||
a.asset,
|
||||
a.activity_island_id,
|
||||
a.activity_source_key,
|
||||
a.activity_from,
|
||||
a.activity_to,
|
||||
a.activity_duration_s,
|
||||
a.activity_from AS boundary_at
|
||||
FROM activities a
|
||||
UNION
|
||||
SELECT
|
||||
a.tenant,
|
||||
a.site,
|
||||
a.machine_id,
|
||||
a.asset,
|
||||
a.activity_island_id,
|
||||
a.activity_source_key,
|
||||
a.activity_from,
|
||||
a.activity_to,
|
||||
a.activity_duration_s,
|
||||
a.activity_to AS boundary_at
|
||||
FROM activities a
|
||||
UNION
|
||||
SELECT
|
||||
o.tenant,
|
||||
o.site,
|
||||
o.machine_id,
|
||||
o.asset,
|
||||
o.activity_island_id,
|
||||
o.activity_source_key,
|
||||
o.activity_from,
|
||||
o.activity_to,
|
||||
o.activity_duration_s,
|
||||
o.overlap_from AS boundary_at
|
||||
FROM overlaps o
|
||||
UNION
|
||||
SELECT
|
||||
o.tenant,
|
||||
o.site,
|
||||
o.machine_id,
|
||||
o.asset,
|
||||
o.activity_island_id,
|
||||
o.activity_source_key,
|
||||
o.activity_from,
|
||||
o.activity_to,
|
||||
o.activity_duration_s,
|
||||
o.overlap_to AS boundary_at
|
||||
FROM overlaps o
|
||||
), distinct_boundaries AS (
|
||||
SELECT DISTINCT
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
asset,
|
||||
activity_island_id,
|
||||
activity_source_key,
|
||||
activity_from,
|
||||
activity_to,
|
||||
activity_duration_s,
|
||||
boundary_at
|
||||
FROM boundaries
|
||||
), raw_segments AS (
|
||||
SELECT
|
||||
b.*,
|
||||
lead(boundary_at) OVER (
|
||||
PARTITION BY
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
activity_island_id
|
||||
ORDER BY boundary_at
|
||||
) AS next_boundary_at
|
||||
FROM distinct_boundaries b
|
||||
), segments AS (
|
||||
SELECT
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
asset,
|
||||
activity_island_id,
|
||||
activity_source_key,
|
||||
activity_from,
|
||||
activity_to,
|
||||
activity_duration_s,
|
||||
boundary_at AS segment_from,
|
||||
next_boundary_at AS segment_to
|
||||
FROM raw_segments
|
||||
WHERE next_boundary_at IS NOT NULL
|
||||
AND next_boundary_at > boundary_at
|
||||
), segment_context_rows AS (
|
||||
SELECT
|
||||
s.*,
|
||||
o.context_key,
|
||||
o.context_session_id,
|
||||
o.context_source,
|
||||
o.context_priority,
|
||||
o.order_ref,
|
||||
o.odoo_production_id,
|
||||
o.odoo_workorder_id,
|
||||
o.product_id,
|
||||
o.product_default_code,
|
||||
o.product_name,
|
||||
o.operator_employee_id,
|
||||
o.operator_name,
|
||||
o.confidence,
|
||||
o.context_confirmed,
|
||||
o.official_loss_eligible,
|
||||
o.authorization_status,
|
||||
o.status,
|
||||
o.notes,
|
||||
o.evidence_json,
|
||||
row_number() OVER (
|
||||
PARTITION BY
|
||||
s.tenant,
|
||||
s.site,
|
||||
s.machine_id,
|
||||
s.activity_island_id,
|
||||
s.segment_from,
|
||||
s.segment_to
|
||||
ORDER BY
|
||||
o.context_priority NULLS LAST,
|
||||
o.context_key NULLS LAST
|
||||
) AS context_rank
|
||||
FROM segments s
|
||||
LEFT JOIN overlaps o
|
||||
ON o.tenant = s.tenant
|
||||
AND o.site = s.site
|
||||
AND o.machine_id = s.machine_id
|
||||
AND o.activity_island_id = s.activity_island_id
|
||||
AND s.segment_to > o.overlap_from
|
||||
AND s.segment_from < o.overlap_to
|
||||
), aggregated AS (
|
||||
SELECT
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
max(asset) AS asset,
|
||||
activity_island_id,
|
||||
max(activity_source_key) AS activity_source_key,
|
||||
max(activity_from) AS activity_from,
|
||||
max(activity_to) AS activity_to,
|
||||
max(activity_duration_s) AS activity_duration_s,
|
||||
segment_from,
|
||||
segment_to,
|
||||
count(DISTINCT context_key) AS context_count,
|
||||
max(context_key) FILTER (WHERE context_rank = 1) AS primary_context_key,
|
||||
max(context_session_id) FILTER (WHERE context_rank = 1)
|
||||
AS primary_context_session_id,
|
||||
max(context_source) FILTER (WHERE context_rank = 1)
|
||||
AS primary_context_source,
|
||||
max(order_ref) FILTER (WHERE context_rank = 1) AS order_ref,
|
||||
max(odoo_production_id) FILTER (WHERE context_rank = 1)
|
||||
AS odoo_production_id,
|
||||
max(odoo_workorder_id) FILTER (WHERE context_rank = 1)
|
||||
AS odoo_workorder_id,
|
||||
max(product_id) FILTER (WHERE context_rank = 1) AS product_id,
|
||||
max(product_default_code) FILTER (WHERE context_rank = 1)
|
||||
AS product_default_code,
|
||||
max(product_name) FILTER (WHERE context_rank = 1) AS product_name,
|
||||
max(operator_employee_id) FILTER (WHERE context_rank = 1)
|
||||
AS operator_employee_id,
|
||||
max(operator_name) FILTER (WHERE context_rank = 1) AS operator_name,
|
||||
max(confidence) FILTER (WHERE context_rank = 1) AS confidence,
|
||||
bool_or(context_confirmed) FILTER (WHERE context_key IS NOT NULL)
|
||||
AS any_context_confirmed,
|
||||
bool_or(official_loss_eligible) FILTER (WHERE context_key IS NOT NULL)
|
||||
AS any_official_loss_eligible,
|
||||
array_remove(
|
||||
array_agg(context_source ORDER BY context_priority, context_key),
|
||||
NULL
|
||||
) AS context_sources
|
||||
FROM segment_context_rows
|
||||
GROUP BY
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
activity_island_id,
|
||||
segment_from,
|
||||
segment_to
|
||||
)
|
||||
SELECT
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
asset,
|
||||
activity_island_id,
|
||||
activity_source_key,
|
||||
activity_from,
|
||||
activity_to,
|
||||
activity_duration_s,
|
||||
segment_from,
|
||||
segment_to,
|
||||
EXTRACT(EPOCH FROM (segment_to - segment_from))::numeric
|
||||
AS segment_duration_s,
|
||||
context_count,
|
||||
primary_context_key,
|
||||
primary_context_session_id,
|
||||
primary_context_source,
|
||||
order_ref,
|
||||
odoo_production_id,
|
||||
odoo_workorder_id,
|
||||
product_id,
|
||||
product_default_code,
|
||||
product_name,
|
||||
operator_employee_id,
|
||||
operator_name,
|
||||
confidence,
|
||||
COALESCE(any_context_confirmed, false) AS context_confirmed,
|
||||
CASE
|
||||
WHEN context_count = 1
|
||||
THEN COALESCE(any_official_loss_eligible, false)
|
||||
ELSE false
|
||||
END AS official_loss_eligible,
|
||||
context_sources,
|
||||
CASE
|
||||
WHEN context_count = 0 THEN 'UNCONFIRMED_CONTEXT'
|
||||
WHEN context_count > 1 THEN 'CONTEXT_CONFLICT'
|
||||
WHEN primary_context_source = 'ODOO_ORDER_CONTEXT'
|
||||
THEN 'ODOO_CONTEXT'
|
||||
WHEN primary_context_source = 'LEGACY_ERP'
|
||||
THEN 'LEGACY_AUTHORIZED'
|
||||
WHEN primary_context_source = 'MANUAL_AUTHORIZED'
|
||||
THEN 'MANUAL_AUTHORIZED'
|
||||
WHEN primary_context_source = 'MAINTENANCE'
|
||||
THEN 'MAINTENANCE'
|
||||
WHEN primary_context_source = 'TEST_SETUP'
|
||||
THEN 'TEST_SETUP'
|
||||
WHEN primary_context_source = 'ODOO_SHOPFLOOR'
|
||||
THEN 'ODOO_SHOPFLOOR'
|
||||
ELSE 'INFERRED_SHADOW'
|
||||
END AS context_status,
|
||||
CASE
|
||||
WHEN context_count = 0
|
||||
THEN 'Confirmar si era legacy autorizada, prueba, mantenimiento o hueco de proceso Odoo.'
|
||||
WHEN context_count > 1
|
||||
THEN 'Hay más de un contexto simultáneo. Revisar antes de usar económicamente.'
|
||||
WHEN primary_context_source = 'ODOO_ORDER_CONTEXT'
|
||||
THEN 'Orden Odoo válida; falta todavía la sesión oficial de operaria.'
|
||||
ELSE 'Contexto explícito reconocido.'
|
||||
END AS context_reason
|
||||
FROM aggregated;
|
||||
|
||||
COMMENT ON VIEW mv_reports_ucepsa_prod.li_running_activity_context_segments_v1 IS
|
||||
'Segmenta toda actividad física por contexto. Sin contexto queda como bloque revisable, no como pérdida económica.';
|
||||
|
||||
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_context_review_queue_v1 AS
|
||||
SELECT
|
||||
row_number() OVER (
|
||||
ORDER BY
|
||||
segment_duration_s DESC,
|
||||
segment_from DESC
|
||||
) AS review_rank,
|
||||
activity_source_key
|
||||
|| ':'
|
||||
|| to_char(
|
||||
segment_from AT TIME ZONE 'Europe/Madrid',
|
||||
'YYYYMMDDHH24MISS'
|
||||
) AS context_review_key,
|
||||
machine_id,
|
||||
segment_from AS started_at,
|
||||
segment_to AS ended_at,
|
||||
round((segment_duration_s / 60.0)::numeric, 2) AS duration_min,
|
||||
context_status,
|
||||
context_reason,
|
||||
ARRAY[
|
||||
'LEGACY_AUTHORIZED',
|
||||
'TEST_SETUP',
|
||||
'MAINTENANCE',
|
||||
'ODOO_PROCESS_GAP',
|
||||
'UNKNOWN_CONTEXT'
|
||||
]::text[] AS allowed_decisions,
|
||||
false AS official_loss_eligible
|
||||
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
WHERE context_status IN ('UNCONFIRMED_CONTEXT', 'CONTEXT_CONFLICT')
|
||||
AND segment_duration_s >= 60
|
||||
ORDER BY review_rank;
|
||||
|
||||
COMMENT ON VIEW mv_reports_ucepsa_prod.li_context_review_queue_v1 IS
|
||||
'Una sola fila por bloque físico sin contexto suficiente. Se revisa en producción sin clasificar ciclos o paros individualmente.';
|
||||
|
||||
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_production_context_coverage_v1 AS
|
||||
WITH recent AS (
|
||||
SELECT *
|
||||
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
WHERE segment_to >= now() - interval '30 days'
|
||||
)
|
||||
SELECT
|
||||
machine_id,
|
||||
context_status,
|
||||
count(*) AS segment_count,
|
||||
round(sum(segment_duration_s / 60.0)::numeric, 2) AS minutes,
|
||||
round(sum(segment_duration_s / 3600.0)::numeric, 2) AS hours,
|
||||
round((
|
||||
100.0
|
||||
* sum(segment_duration_s)
|
||||
/ NULLIF(
|
||||
sum(sum(segment_duration_s)) OVER (
|
||||
PARTITION BY machine_id
|
||||
),
|
||||
0
|
||||
)
|
||||
)::numeric, 2) AS machine_activity_pct
|
||||
FROM recent
|
||||
GROUP BY machine_id, context_status
|
||||
ORDER BY machine_id, hours DESC;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.machine_migration_context_state_v1 AS
|
||||
WITH current_manual AS (
|
||||
SELECT DISTINCT ON (s.machine_id)
|
||||
s.machine_id,
|
||||
s.context_session_id,
|
||||
s.source_type,
|
||||
s.external_order_ref,
|
||||
s.product_name,
|
||||
s.operator_employee_id,
|
||||
s.operator_name,
|
||||
s.started_at,
|
||||
s.confidence,
|
||||
s.official_eligible
|
||||
FROM mv_loss_intelligence.production_context_sessions s
|
||||
WHERE s.tenant = 'ucepsa'
|
||||
AND s.site = 'ucepsa_onpremise'
|
||||
AND s.status = 'OPEN'
|
||||
AND s.authorization_status IN ('AUTHORIZED', 'AUTO_CONFIRMED')
|
||||
AND now() >= s.started_at
|
||||
ORDER BY
|
||||
s.machine_id,
|
||||
CASE s.source_type
|
||||
WHEN 'LEGACY_ERP' THEN 1
|
||||
WHEN 'MANUAL_AUTHORIZED' THEN 2
|
||||
WHEN 'MAINTENANCE' THEN 3
|
||||
WHEN 'TEST_SETUP' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
s.started_at DESC
|
||||
)
|
||||
SELECT
|
||||
m.*,
|
||||
c.context_session_id,
|
||||
c.source_type AS explicit_context_source,
|
||||
c.external_order_ref AS explicit_order_ref,
|
||||
c.product_name AS explicit_product_name,
|
||||
c.operator_employee_id AS explicit_operator_employee_id,
|
||||
c.operator_name AS explicit_operator_name,
|
||||
c.started_at AS explicit_context_started_at,
|
||||
CASE
|
||||
WHEN m.operational_state = 'RUNNING_WITH_ORDER'
|
||||
THEN 'RUNNING_ODOO_CONTEXT'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'LEGACY_ERP'
|
||||
THEN 'RUNNING_LEGACY_AUTHORIZED'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'MANUAL_AUTHORIZED'
|
||||
THEN 'RUNNING_MANUAL_AUTHORIZED'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'MAINTENANCE'
|
||||
THEN 'RUNNING_MAINTENANCE'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'TEST_SETUP'
|
||||
THEN 'RUNNING_TEST_SETUP'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
THEN 'RUNNING_UNCONFIRMED_CONTEXT'
|
||||
ELSE m.operational_state
|
||||
END AS migration_context_state,
|
||||
CASE
|
||||
WHEN m.operational_state = 'RUNNING_WITH_ORDER'
|
||||
THEN 'ODOO'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'LEGACY_ERP'
|
||||
THEN 'LEGACY AUTORIZADA'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'MANUAL_AUTHORIZED'
|
||||
THEN 'PRODUCCIÓN AUTORIZADA'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'MAINTENANCE'
|
||||
THEN 'MANTENIMIENTO'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
AND c.source_type = 'TEST_SETUP'
|
||||
THEN 'PRUEBA / AJUSTE'
|
||||
WHEN m.operational_state = 'RUNNING_WITHOUT_VALID_ORDER'
|
||||
THEN 'CONTEXTO PENDIENTE'
|
||||
ELSE m.machine_state_label
|
||||
END AS migration_context_label
|
||||
FROM mv_reports_ucepsa_prod.machine_operational_state_v1 m
|
||||
LEFT JOIN current_manual c ON c.machine_id = m.machine_id;
|
||||
|
||||
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_data_quality_v3 AS
|
||||
WITH base AS (
|
||||
SELECT * FROM mv_reports_ucepsa_prod.li_data_quality_v2
|
||||
), unconfirmed AS (
|
||||
SELECT count(*) AS n
|
||||
FROM mv_reports_ucepsa_prod.li_context_review_queue_v1
|
||||
WHERE ended_at >= now() - interval '24 hours'
|
||||
), conflicts AS (
|
||||
SELECT count(*) AS n
|
||||
FROM mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
WHERE context_status = 'CONTEXT_CONFLICT'
|
||||
AND segment_to >= now() - interval '24 hours'
|
||||
), stale_manual AS (
|
||||
SELECT count(*) AS n
|
||||
FROM mv_loss_intelligence.production_context_sessions s
|
||||
JOIN mv_loss_intelligence.production_context_policies p
|
||||
ON p.tenant = s.tenant
|
||||
AND p.site = s.site
|
||||
AND p.policy_code = 'CUTTER_CONTEXT_V1'
|
||||
AND p.is_active = true
|
||||
WHERE s.tenant = 'ucepsa'
|
||||
AND s.site = 'ucepsa_onpremise'
|
||||
AND s.status = 'OPEN'
|
||||
AND now() - s.started_at
|
||||
> make_interval(secs => p.stale_open_context_s::double precision)
|
||||
)
|
||||
SELECT * FROM base
|
||||
UNION ALL
|
||||
SELECT
|
||||
'UNCONFIRMED_PRODUCTION_CONTEXT',
|
||||
CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END,
|
||||
n,
|
||||
'Bloques de actividad de las últimas 24 h sin Odoo ni contexto legacy/manual confirmado.'
|
||||
FROM unconfirmed
|
||||
UNION ALL
|
||||
SELECT
|
||||
'CONTEXT_CONFLICT',
|
||||
CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END,
|
||||
n,
|
||||
'Segmentos de las últimas 24 h cubiertos por más de un contexto simultáneo.'
|
||||
FROM conflicts
|
||||
UNION ALL
|
||||
SELECT
|
||||
'STALE_OPEN_PRODUCTION_CONTEXT',
|
||||
CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END,
|
||||
n,
|
||||
'Contextos manuales/legacy abiertos más tiempo que el umbral permitido.'
|
||||
FROM stale_manual;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro'
|
||||
) THEN
|
||||
GRANT SELECT ON mv_reports_ucepsa_prod.li_machine_running_activity_v1
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_reports_ucepsa_prod.li_production_context_intervals_v1
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_reports_ucepsa_prod.li_running_activity_context_segments_v1
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_reports_ucepsa_prod.li_context_review_queue_v1
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_reports_ucepsa_prod.li_production_context_coverage_v1
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_reports_ucepsa_prod.machine_migration_context_state_v1
|
||||
TO grafana_ucepsa_ro;
|
||||
GRANT SELECT ON mv_reports_ucepsa_prod.li_data_quality_v3
|
||||
TO grafana_ucepsa_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
439
ucepsa/edge-oee-demo/tools/li_context_admin.py
Normal file
439
ucepsa/edge-oee-demo/tools/li_context_admin.py
Normal file
@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MESAVAULT LossIntelligence — Production Context Admin v0.3
|
||||
|
||||
Herramienta controlada para abrir, cerrar, crear retrospectivamente y cancelar
|
||||
contextos explícitos de producción.
|
||||
|
||||
Seguridad:
|
||||
- en modo SHADOW fuerza official_eligible=false;
|
||||
- no modifica Odoo, WISE, RevPi ni balizas;
|
||||
- no almacena PINs;
|
||||
- usa la conexión PostgreSQL ya disponible en el entorno UCEPSA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
|
||||
ALLOWED_SOURCES = (
|
||||
"LEGACY_ERP",
|
||||
"MANUAL_AUTHORIZED",
|
||||
"MAINTENANCE",
|
||||
"TEST_SETUP",
|
||||
)
|
||||
|
||||
MACHINES = ("CORT-00", "CORT-01", "CORT-02")
|
||||
|
||||
|
||||
def env(name: str, default: Optional[str] = None) -> str:
|
||||
value = os.getenv(name, default)
|
||||
if value is None or value == "":
|
||||
raise RuntimeError(f"Falta variable de entorno {name}")
|
||||
return value
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg2.connect(
|
||||
host=env("PGHOST", "mv_ucepsa_postgres_hot"),
|
||||
port=int(env("PGPORT", "5432")),
|
||||
dbname=env("PGDATABASE"),
|
||||
user=env("PGUSER"),
|
||||
password=env("PGPASSWORD"),
|
||||
)
|
||||
|
||||
|
||||
def parse_ts(value: Optional[str]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.lower() == "now":
|
||||
return datetime.now(timezone.utc)
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError(
|
||||
"La fecha debe incluir zona horaria, por ejemplo 2026-07-17T10:30:00+02:00"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def deployment_mode(cur) -> str:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT mode
|
||||
FROM mv_loss_intelligence.deployment_config
|
||||
WHERE tenant = 'ucepsa'
|
||||
AND site = 'ucepsa_onpremise'
|
||||
AND scope_code = 'CUTTERS'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError("No existe deployment_config CUTTERS")
|
||||
return str(row[0])
|
||||
|
||||
|
||||
def print_json(payload: Any) -> None:
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def open_context(args) -> None:
|
||||
started_at = parse_ts(args.started_at) or datetime.now(timezone.utc)
|
||||
with connect() as conn:
|
||||
with conn.cursor(
|
||||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
) as cur:
|
||||
mode = deployment_mode(cur)
|
||||
official = bool(args.official_eligible and mode == "ACTIVE")
|
||||
if args.official_eligible and mode != "ACTIVE":
|
||||
print(
|
||||
"[WARN] El despliegue está en SHADOW: official_eligible se fuerza a false.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO mv_loss_intelligence.production_context_sessions (
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
source_type,
|
||||
source_ref,
|
||||
external_order_ref,
|
||||
product_id,
|
||||
product_default_code,
|
||||
product_name,
|
||||
operator_employee_id,
|
||||
operator_name,
|
||||
started_at,
|
||||
status,
|
||||
authorization_status,
|
||||
confidence,
|
||||
official_eligible,
|
||||
authorized_by,
|
||||
authorized_at,
|
||||
notes,
|
||||
evidence_json
|
||||
)
|
||||
VALUES (
|
||||
'ucepsa',
|
||||
'ucepsa_onpremise',
|
||||
%(machine_id)s,
|
||||
%(source_type)s,
|
||||
%(source_ref)s,
|
||||
%(external_order_ref)s,
|
||||
%(product_id)s,
|
||||
%(product_default_code)s,
|
||||
%(product_name)s,
|
||||
%(operator_employee_id)s,
|
||||
%(operator_name)s,
|
||||
%(started_at)s,
|
||||
'OPEN',
|
||||
'AUTHORIZED',
|
||||
%(confidence)s,
|
||||
%(official_eligible)s,
|
||||
%(authorized_by)s,
|
||||
now(),
|
||||
%(notes)s,
|
||||
%(evidence_json)s
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
{
|
||||
"machine_id": args.machine,
|
||||
"source_type": args.source,
|
||||
"source_ref": args.source_ref,
|
||||
"external_order_ref": args.external_order_ref,
|
||||
"product_id": args.product_id,
|
||||
"product_default_code": args.product_code,
|
||||
"product_name": args.product_name,
|
||||
"operator_employee_id": args.operator_employee_id,
|
||||
"operator_name": args.operator_name,
|
||||
"started_at": started_at,
|
||||
"confidence": args.confidence,
|
||||
"official_eligible": official,
|
||||
"authorized_by": args.authorized_by,
|
||||
"notes": args.notes,
|
||||
"evidence_json": psycopg2.extras.Json({
|
||||
"created_with": "li_context_admin.py",
|
||||
"deployment_mode": mode,
|
||||
}),
|
||||
},
|
||||
)
|
||||
print_json(cur.fetchone())
|
||||
|
||||
|
||||
def close_context(args) -> None:
|
||||
ended_at = parse_ts(args.ended_at) or datetime.now(timezone.utc)
|
||||
with connect() as conn:
|
||||
with conn.cursor(
|
||||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE mv_loss_intelligence.production_context_sessions
|
||||
SET
|
||||
ended_at = %(ended_at)s,
|
||||
status = 'CLOSED',
|
||||
closed_by = %(closed_by)s,
|
||||
notes = concat_ws(
|
||||
E'\n',
|
||||
notes,
|
||||
%(notes)s
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE context_session_id = %(context_session_id)s
|
||||
AND status = 'OPEN'
|
||||
AND ended_at IS NULL
|
||||
AND %(ended_at)s > started_at
|
||||
RETURNING *
|
||||
""",
|
||||
{
|
||||
"context_session_id": args.session_id,
|
||||
"ended_at": ended_at,
|
||||
"closed_by": args.closed_by,
|
||||
"notes": args.notes,
|
||||
},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError(
|
||||
"No se ha cerrado ninguna sesión. Comprueba ID, estado y fechas."
|
||||
)
|
||||
print_json(row)
|
||||
|
||||
|
||||
def create_retrospective(args) -> None:
|
||||
started_at = parse_ts(args.started_at)
|
||||
ended_at = parse_ts(args.ended_at)
|
||||
if not started_at or not ended_at or ended_at <= started_at:
|
||||
raise ValueError("El intervalo retrospectivo debe tener inicio y fin válidos.")
|
||||
|
||||
with connect() as conn:
|
||||
with conn.cursor(
|
||||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
) as cur:
|
||||
mode = deployment_mode(cur)
|
||||
official = bool(args.official_eligible and mode == "ACTIVE")
|
||||
if args.official_eligible and mode != "ACTIVE":
|
||||
print(
|
||||
"[WARN] El despliegue está en SHADOW: official_eligible se fuerza a false.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO mv_loss_intelligence.production_context_sessions (
|
||||
tenant,
|
||||
site,
|
||||
machine_id,
|
||||
source_type,
|
||||
source_ref,
|
||||
external_order_ref,
|
||||
product_id,
|
||||
product_default_code,
|
||||
product_name,
|
||||
operator_employee_id,
|
||||
operator_name,
|
||||
started_at,
|
||||
ended_at,
|
||||
status,
|
||||
authorization_status,
|
||||
confidence,
|
||||
official_eligible,
|
||||
authorized_by,
|
||||
authorized_at,
|
||||
closed_by,
|
||||
notes,
|
||||
evidence_json
|
||||
)
|
||||
VALUES (
|
||||
'ucepsa',
|
||||
'ucepsa_onpremise',
|
||||
%(machine_id)s,
|
||||
%(source_type)s,
|
||||
%(source_ref)s,
|
||||
%(external_order_ref)s,
|
||||
%(product_id)s,
|
||||
%(product_default_code)s,
|
||||
%(product_name)s,
|
||||
%(operator_employee_id)s,
|
||||
%(operator_name)s,
|
||||
%(started_at)s,
|
||||
%(ended_at)s,
|
||||
'CLOSED',
|
||||
'AUTHORIZED',
|
||||
%(confidence)s,
|
||||
%(official_eligible)s,
|
||||
%(authorized_by)s,
|
||||
now(),
|
||||
%(closed_by)s,
|
||||
%(notes)s,
|
||||
%(evidence_json)s
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
{
|
||||
"machine_id": args.machine,
|
||||
"source_type": args.source,
|
||||
"source_ref": args.source_ref,
|
||||
"external_order_ref": args.external_order_ref,
|
||||
"product_id": args.product_id,
|
||||
"product_default_code": args.product_code,
|
||||
"product_name": args.product_name,
|
||||
"operator_employee_id": args.operator_employee_id,
|
||||
"operator_name": args.operator_name,
|
||||
"started_at": started_at,
|
||||
"ended_at": ended_at,
|
||||
"confidence": args.confidence,
|
||||
"official_eligible": official,
|
||||
"authorized_by": args.authorized_by,
|
||||
"closed_by": args.closed_by,
|
||||
"notes": args.notes,
|
||||
"evidence_json": psycopg2.extras.Json({
|
||||
"created_with": "li_context_admin.py",
|
||||
"deployment_mode": mode,
|
||||
"retrospective": True,
|
||||
}),
|
||||
},
|
||||
)
|
||||
print_json(cur.fetchone())
|
||||
|
||||
|
||||
def cancel_context(args) -> None:
|
||||
with connect() as conn:
|
||||
with conn.cursor(
|
||||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE mv_loss_intelligence.production_context_sessions
|
||||
SET
|
||||
status = 'CANCELLED',
|
||||
ended_at = COALESCE(ended_at, now()),
|
||||
closed_by = %(closed_by)s,
|
||||
notes = concat_ws(E'\n', notes, %(notes)s),
|
||||
updated_at = now()
|
||||
WHERE context_session_id = %(context_session_id)s
|
||||
AND status <> 'CANCELLED'
|
||||
RETURNING *
|
||||
""",
|
||||
{
|
||||
"context_session_id": args.session_id,
|
||||
"closed_by": args.closed_by,
|
||||
"notes": args.notes,
|
||||
},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError("No se ha cancelado ninguna sesión.")
|
||||
print_json(row)
|
||||
|
||||
|
||||
def list_contexts(args) -> None:
|
||||
with connect() as conn:
|
||||
with conn.cursor(
|
||||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
) as cur:
|
||||
where = [
|
||||
"tenant = 'ucepsa'",
|
||||
"site = 'ucepsa_onpremise'",
|
||||
]
|
||||
params: Dict[str, Any] = {"limit": args.limit}
|
||||
if args.machine:
|
||||
where.append("machine_id = %(machine_id)s")
|
||||
params["machine_id"] = args.machine
|
||||
if args.open_only:
|
||||
where.append("status = 'OPEN'")
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM mv_loss_intelligence.production_context_sessions
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY context_session_id DESC
|
||||
LIMIT %(limit)s
|
||||
""",
|
||||
params,
|
||||
)
|
||||
print_json(cur.fetchall())
|
||||
|
||||
|
||||
def add_common_context_args(parser) -> None:
|
||||
parser.add_argument("--machine", required=True, choices=MACHINES)
|
||||
parser.add_argument("--source", required=True, choices=ALLOWED_SOURCES)
|
||||
parser.add_argument("--source-ref")
|
||||
parser.add_argument("--external-order-ref")
|
||||
parser.add_argument("--product-id", type=int)
|
||||
parser.add_argument("--product-code")
|
||||
parser.add_argument("--product-name")
|
||||
parser.add_argument("--operator-employee-id", type=int)
|
||||
parser.add_argument("--operator-name")
|
||||
parser.add_argument(
|
||||
"--confidence",
|
||||
choices=("HIGH", "MEDIUM", "LOW"),
|
||||
default="MEDIUM",
|
||||
)
|
||||
parser.add_argument("--authorized-by", required=True)
|
||||
parser.add_argument("--notes")
|
||||
parser.add_argument("--official-eligible", action="store_true")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Administración controlada de contextos productivos"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_open = sub.add_parser("open")
|
||||
add_common_context_args(p_open)
|
||||
p_open.add_argument("--started-at", default="now")
|
||||
p_open.set_defaults(func=open_context)
|
||||
|
||||
p_close = sub.add_parser("close")
|
||||
p_close.add_argument("--session-id", type=int, required=True)
|
||||
p_close.add_argument("--ended-at", default="now")
|
||||
p_close.add_argument("--closed-by", required=True)
|
||||
p_close.add_argument("--notes")
|
||||
p_close.set_defaults(func=close_context)
|
||||
|
||||
p_create = sub.add_parser("create-retrospective")
|
||||
add_common_context_args(p_create)
|
||||
p_create.add_argument("--started-at", required=True)
|
||||
p_create.add_argument("--ended-at", required=True)
|
||||
p_create.add_argument("--closed-by", required=True)
|
||||
p_create.set_defaults(func=create_retrospective)
|
||||
|
||||
p_cancel = sub.add_parser("cancel")
|
||||
p_cancel.add_argument("--session-id", type=int, required=True)
|
||||
p_cancel.add_argument("--closed-by", required=True)
|
||||
p_cancel.add_argument("--notes")
|
||||
p_cancel.set_defaults(func=cancel_context)
|
||||
|
||||
p_list = sub.add_parser("list")
|
||||
p_list.add_argument("--machine", choices=MACHINES)
|
||||
p_list.add_argument("--open-only", action="store_true")
|
||||
p_list.add_argument("--limit", type=int, default=30)
|
||||
p_list.set_defaults(func=list_contexts)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Loading…
x
Reference in New Issue
Block a user