40-clients/ucepsa/edge-oee-demo/sql/versions/095_lossintelligence_ucepsa_shadow_views.sql
2026-07-16 12:27:29 +02:00

524 lines
18 KiB
PL/PgSQL

BEGIN;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_candidates_v1 AS
WITH stop_base AS (
SELECT
s.*,
COALESCE(
s.duration_s,
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))::numeric
) AS effective_duration_s,
COALESCE(
s.cost_eur,
ROUND((
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
/ 3600.0 * s.value_hour_eur
)::numeric, 2)
) AS effective_capacity_cost_eur
FROM mv_hot.edge_oee_machine_stops s
WHERE s.tenant = 'ucepsa'
AND s.site = 'ucepsa_onpremise'
), enriched AS (
SELECT
s.*,
d.id AS deployment_id,
d.scope_code,
d.mode AS deployment_mode,
d.planned_go_live_date,
d.actual_go_live_at,
p.noise_below_s,
p.aggregate_below_s,
p.operator_individual_from_s,
p.aggregation_window_min,
p.require_valid_order,
p.require_closed_event,
o.id AS order_state_event_id,
o.effective_from AS order_context_from,
o.effective_to AS order_context_to,
o.odoo_state,
o.order_present,
o.order_coherent,
o.workorder_id AS odoo_workorder_id,
o.workorder_name,
o.workorder_state,
o.production_id AS odoo_production_id,
o.production_order AS order_ref,
o.product_id,
o.product AS product_name,
o.qty_production,
o.date_start AS odoo_date_start,
o.date_finished AS odoo_date_finished,
pc.units_per_cycle,
pc.target_cycle_rate_ppm,
ep.parameter_value AS configured_capacity_eur_h,
ep.confidence AS capacity_cost_confidence
FROM stop_base s
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.deployment_config x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.scope_code = 'CUTTERS'
ORDER BY x.id DESC
LIMIT 1
) d ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.loss_policies x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.policy_code = 'CUTTER_STOPS_V1'
AND x.is_active = true
ORDER BY x.id DESC
LIMIT 1
) p ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_reports_ucepsa_prod.li_order_state_intervals_v1 x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.machine_id = s.machine_id
AND s.started_at >= x.effective_from
AND (x.effective_to IS NULL OR s.started_at < x.effective_to)
ORDER BY x.effective_from DESC, x.id DESC
LIMIT 1
) o ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.product_cycle_parameters x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.is_active = true
AND x.product_id = o.product_id
AND (x.machine_id IS NULL OR x.machine_id = s.machine_id)
AND s.started_at >= x.valid_from
AND (x.valid_to IS NULL OR s.started_at < x.valid_to)
ORDER BY (x.machine_id IS NOT NULL) DESC, x.valid_from DESC, x.id DESC
LIMIT 1
) pc ON true
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.economic_parameters x
WHERE x.tenant = s.tenant
AND x.site = s.site
AND x.parameter_code = 'CAPACITY_HOUR_EUR'
AND x.is_active = true
AND (x.machine_id IS NULL OR x.machine_id = s.machine_id)
AND s.started_at >= x.valid_from
AND (x.valid_to IS NULL OR s.started_at < x.valid_to)
ORDER BY (x.machine_id IS NOT NULL) DESC, x.valid_from DESC, x.id DESC
LIMIT 1
) ep ON true
)
SELECT
e.id AS source_stop_id,
e.tenant,
e.site,
e.deployment_id,
e.scope_code,
e.deployment_mode,
e.planned_go_live_date,
e.actual_go_live_at,
CASE
WHEN e.deployment_mode = 'ACTIVE'
AND e.actual_go_live_at IS NOT NULL
AND e.started_at >= e.actual_go_live_at
THEN 'ACTIVE'
ELSE 'SHADOW'
END AS ledger_phase,
e.machine_id,
e.asset,
e.started_at,
e.ended_at,
e.status AS source_status,
e.classification_status,
e.cause_code,
c.cause_name,
c.is_planned,
e.operator_note,
e.start_event_name,
e.end_event_name,
e.effective_duration_s,
ROUND((e.effective_duration_s / 60.0)::numeric, 2) AS effective_duration_min,
e.value_hour_eur AS source_value_hour_eur,
e.configured_capacity_eur_h,
COALESCE(e.configured_capacity_eur_h, e.value_hour_eur) AS applied_capacity_eur_h,
ROUND((
e.effective_duration_s / 3600.0
* COALESCE(e.configured_capacity_eur_h, e.value_hour_eur)
)::numeric, 2) AS effective_capacity_cost_eur,
e.capacity_cost_confidence,
e.order_state_event_id,
e.order_context_from,
e.order_context_to,
e.odoo_state,
e.order_present,
e.order_coherent,
e.odoo_workorder_id,
e.workorder_name,
e.workorder_state,
e.odoo_production_id,
e.order_ref,
e.product_id,
e.product_name,
e.qty_production,
e.odoo_date_start,
e.odoo_date_finished,
e.units_per_cycle,
e.target_cycle_rate_ppm,
CASE
WHEN e.deployment_id IS NULL THEN 'CONFIG_MISSING'
WHEN e.noise_below_s IS NULL THEN 'POLICY_MISSING'
WHEN e.status = 'OPEN' THEN 'OPEN_MONITORING'
WHEN e.require_closed_event AND e.status <> 'CLOSED' THEN 'EXCLUDED_NOT_CLOSED'
WHEN e.order_state_event_id IS NULL THEN 'EXCLUDED_NO_ORDER_CONTEXT'
WHEN e.require_valid_order AND COALESCE(e.order_present, false) = false THEN 'EXCLUDED_NO_VALID_ORDER'
WHEN e.require_valid_order AND COALESCE(e.order_coherent, false) = false THEN 'EXCLUDED_ORDER_INCOHERENT'
WHEN e.effective_duration_s < e.noise_below_s THEN 'EXCLUDED_NOISE'
WHEN e.effective_duration_s < e.operator_individual_from_s THEN 'CANDIDATE_GROUP'
ELSE 'CANDIDATE_INDIVIDUAL'
END AS eligibility_status,
CASE
WHEN e.status = 'CLOSED'
AND e.order_state_event_id IS NOT NULL
AND (NOT e.require_valid_order OR (e.order_present = true AND e.order_coherent = true))
AND e.effective_duration_s >= e.noise_below_s
THEN true
ELSE false
END AS technically_eligible,
CASE
WHEN e.deployment_mode = 'ACTIVE'
AND e.actual_go_live_at IS NOT NULL
AND e.started_at >= e.actual_go_live_at
AND e.status = 'CLOSED'
AND e.order_state_event_id IS NOT NULL
AND (NOT e.require_valid_order OR (e.order_present = true AND e.order_coherent = true))
AND e.effective_duration_s >= e.noise_below_s
THEN true
ELSE false
END AS official_ledger_eligible,
jsonb_build_object(
'source_stop_id', e.id,
'source_start_payload', e.start_payload_json,
'source_end_payload', e.end_payload_json,
'order_state_event_id', e.order_state_event_id,
'deployment_mode', e.deployment_mode,
'policy', jsonb_build_object(
'noise_below_s', e.noise_below_s,
'aggregate_below_s', e.aggregate_below_s,
'operator_individual_from_s', e.operator_individual_from_s,
'aggregation_window_min', e.aggregation_window_min
)
) AS evidence_json
FROM enriched e
LEFT JOIN mv_hot.edge_oee_stop_causes c
ON c.cause_code = e.cause_code;
COMMENT ON VIEW mv_reports_ucepsa_prod.li_stop_candidates_v1 IS
'Clasifica cada paro raw como excluido, agrupable, individual u abierto. Mantiene separados el modo SHADOW y la elegibilidad oficial.';
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_stop_groups_v1 AS
WITH candidates AS (
SELECT
c.*,
COALESCE((c.evidence_json #>> '{policy,aggregation_window_min}')::int, 15) AS aggregation_window_min
FROM mv_reports_ucepsa_prod.li_stop_candidates_v1 c
WHERE c.eligibility_status IN ('CANDIDATE_GROUP', 'CANDIDATE_INDIVIDUAL')
), bucketed AS (
SELECT
c.*,
date_bin(
make_interval(mins => c.aggregation_window_min),
c.started_at,
TIMESTAMPTZ '2000-01-01 00:00:00+00'
) AS bucket_start
FROM candidates c
), grouped_micro AS (
SELECT
tenant,
site,
deployment_id,
ledger_phase,
machine_id,
asset,
order_ref,
odoo_production_id,
NULLIF(odoo_workorder_id, '')::bigint AS odoo_workorder_id,
product_id,
product_name,
units_per_cycle,
target_cycle_rate_ppm,
COALESCE(cause_code, 'PENDING') AS cause_code,
COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name,
classification_status,
bucket_start AS group_window_start,
COUNT(*)::int AS event_count,
MIN(started_at) AS started_at,
MAX(ended_at) AS ended_at,
SUM(effective_duration_s)::numeric AS duration_s,
ROUND(SUM(effective_capacity_cost_eur)::numeric, 2) AS capacity_loss_eur,
array_agg(source_stop_id ORDER BY started_at) AS source_stop_ids,
bool_and(official_ledger_eligible) AS official_ledger_eligible
FROM bucketed
WHERE eligibility_status = 'CANDIDATE_GROUP'
GROUP BY
tenant, site, deployment_id, ledger_phase, machine_id, asset,
order_ref, odoo_production_id, odoo_workorder_id, product_id,
product_name, units_per_cycle, target_cycle_rate_ppm,
COALESCE(cause_code, 'PENDING'),
COALESCE(cause_name, 'Pendiente de clasificar'),
classification_status, bucket_start
), individual AS (
SELECT
tenant,
site,
deployment_id,
ledger_phase,
machine_id,
asset,
order_ref,
odoo_production_id,
NULLIF(odoo_workorder_id, '')::bigint AS odoo_workorder_id,
product_id,
product_name,
units_per_cycle,
target_cycle_rate_ppm,
COALESCE(cause_code, 'PENDING') AS cause_code,
COALESCE(cause_name, 'Pendiente de clasificar') AS cause_name,
classification_status,
started_at AS group_window_start,
1::int AS event_count,
started_at,
ended_at,
effective_duration_s::numeric AS duration_s,
effective_capacity_cost_eur::numeric AS capacity_loss_eur,
ARRAY[source_stop_id]::bigint[] AS source_stop_ids,
official_ledger_eligible
FROM bucketed
WHERE eligibility_status = 'CANDIDATE_INDIVIDUAL'
), combined AS (
SELECT * FROM grouped_micro
UNION ALL
SELECT * FROM individual
)
SELECT
CASE
WHEN event_count = 1 THEN 'STOP:' || source_stop_ids[1]::text
ELSE 'STOPGROUP:' || machine_id || ':'
|| COALESCE(odoo_production_id::text, COALESCE(order_ref, 'NO_ORDER')) || ':'
|| to_char(group_window_start AT TIME ZONE 'Europe/Madrid', 'YYYYMMDDHH24MI') || ':'
|| cause_code
END AS source_key,
*,
ROUND((duration_s / 60.0)::numeric, 2) AS duration_min,
CASE
WHEN duration_s >= 900 THEN 'HIGH'
WHEN duration_s >= 300 THEN 'MEDIUM'
ELSE 'LOW'
END AS preliminary_priority
FROM combined;
COMMENT ON VIEW mv_reports_ucepsa_prod.li_stop_groups_v1 IS
'Agrupa microparos por máquina, orden, ventana y causa. Los paros largos permanecen individuales.';
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_shadow_review_queue_v1 AS
SELECT
row_number() OVER (
ORDER BY capacity_loss_eur DESC, duration_s DESC, started_at DESC
) AS shadow_rank,
source_key,
machine_id,
order_ref,
product_name,
cause_code,
cause_name,
event_count,
started_at,
ended_at,
duration_min,
capacity_loss_eur,
preliminary_priority,
'SHADOW_ONLY_NOT_OFFICIAL'::text AS queue_status,
official_ledger_eligible,
source_stop_ids
FROM mv_reports_ucepsa_prod.li_stop_groups_v1
WHERE ledger_phase = 'SHADOW'
ORDER BY shadow_rank;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_operator_stop_queue_v1 AS
SELECT
source_key,
machine_id,
order_ref,
product_name,
cause_code,
cause_name,
classification_status,
event_count,
started_at,
ended_at,
duration_min,
capacity_loss_eur,
source_stop_ids
FROM mv_reports_ucepsa_prod.li_stop_groups_v1
WHERE ledger_phase = 'ACTIVE'
AND official_ledger_eligible = true
AND classification_status = 'PENDING'
ORDER BY capacity_loss_eur DESC, duration_s DESC, started_at DESC;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_daily_review_queue_v1 AS
SELECT
row_number() OVER (
ORDER BY
COALESCE(l.priority_score, 0) DESC,
l.review_due_at NULLS LAST,
l.detected_at
) AS review_rank,
l.loss_id,
l.machine_id,
l.order_ref,
l.product_name,
l.loss_type,
l.cause_code,
ROUND((l.duration_s / 60.0)::numeric, 2) AS duration_min,
l.capacity_loss_eur,
l.energy_waste_eur,
l.priority_level,
l.action_candidate,
l.action_final,
l.review_owner,
l.review_due_at,
l.status,
t.trial_id,
t.trial_code,
t.result AS trial_result,
t.decision AS trial_decision
FROM mv_loss_intelligence.loss_ledger l
LEFT JOIN LATERAL (
SELECT x.*
FROM mv_loss_intelligence.action_trials x
WHERE x.loss_id = l.loss_id
ORDER BY x.created_at DESC, x.trial_id DESC
LIMIT 1
) t ON true
WHERE l.tenant = 'ucepsa'
AND l.site = 'ucepsa_onpremise'
AND l.ledger_phase = 'ACTIVE'
AND l.status NOT IN ('CLOSED', 'DISCARDED')
ORDER BY review_rank
LIMIT 5;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_shadow_summary_v1 AS
SELECT
ledger_phase,
eligibility_status,
machine_id,
COUNT(*) AS stop_count,
ROUND(SUM(effective_duration_s / 60.0)::numeric, 2) AS total_minutes,
ROUND(SUM(effective_capacity_cost_eur)::numeric, 2) AS capacity_cost_eur
FROM mv_reports_ucepsa_prod.li_stop_candidates_v1
GROUP BY ledger_phase, eligibility_status, machine_id;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_product_cycle_coverage_v1 AS
SELECT
c.machine_id,
c.odoo_production_id,
c.order_ref,
c.product_id,
c.product_name,
c.qty_production,
c.units_per_cycle,
c.target_cycle_rate_ppm,
CASE
WHEN c.product_id IS NULL THEN 'MISSING_PRODUCT_ID'
WHEN c.units_per_cycle IS NULL THEN 'MISSING_UNITS_PER_CYCLE'
WHEN c.target_cycle_rate_ppm IS NULL THEN 'MISSING_TARGET_CYCLE_RATE'
ELSE 'READY'
END AS cycle_parameter_status,
MAX(c.started_at) AS latest_stop_started_at,
COUNT(*) AS related_stop_count
FROM mv_reports_ucepsa_prod.li_stop_candidates_v1 c
WHERE c.order_present = true
AND c.order_coherent = true
GROUP BY
c.machine_id, c.odoo_production_id, c.order_ref, c.product_id,
c.product_name, c.qty_production, c.units_per_cycle, c.target_cycle_rate_ppm;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.li_data_quality_v1 AS
WITH deployment AS (
SELECT COUNT(*) AS n
FROM mv_loss_intelligence.deployment_config
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise' AND scope_code = 'CUTTERS'
), capacity_cost AS (
SELECT COUNT(*) AS n
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND parameter_code = 'CAPACITY_HOUR_EUR' AND is_active = true
), energy_cost AS (
SELECT COUNT(*) AS n
FROM mv_loss_intelligence.economic_parameters
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND parameter_code = 'ENERGY_VARIABLE_EUR_KWH' AND is_active = true
), current_loaded AS (
SELECT COUNT(*) AS n
FROM mv_hot.odoo_order_state_current
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND order_present = true AND order_coherent = true
), cycle_missing AS (
SELECT COUNT(*) AS n
FROM mv_hot.odoo_order_state_current o
WHERE o.tenant = 'ucepsa' AND o.site = 'ucepsa_onpremise'
AND o.order_present = true AND o.order_coherent = true
AND NOT EXISTS (
SELECT 1
FROM mv_loss_intelligence.product_cycle_parameters p
WHERE p.tenant = o.tenant
AND p.site = o.site
AND p.product_id = o.product_id
AND p.is_active = true
AND (p.machine_id IS NULL OR p.machine_id = o.machine_id)
)
), stale_open AS (
SELECT COUNT(*) AS n
FROM mv_hot.edge_oee_machine_stops
WHERE tenant = 'ucepsa' AND site = 'ucepsa_onpremise'
AND status = 'OPEN'
AND started_at < now() - interval '12 hours'
)
SELECT 'DEPLOYMENT_CONFIG'::text AS check_code,
CASE WHEN n = 1 THEN 'OK' ELSE 'BLOCKER' END AS status,
n AS affected_count,
'Debe existir una única configuración CUTTERS.'::text AS description
FROM deployment
UNION ALL
SELECT 'CAPACITY_COST', CASE WHEN n >= 1 THEN 'OK' ELSE 'BLOCKER' END, n,
'Debe existir coste de capacidad €/h activo.' FROM capacity_cost
UNION ALL
SELECT 'ENERGY_COST', CASE WHEN n >= 1 THEN 'OK' ELSE 'BLOCKER' END, n,
'Debe existir coste eléctrico variable €/kWh activo.' FROM energy_cost
UNION ALL
SELECT 'CURRENT_VALID_ORDERS', CASE WHEN n >= 1 THEN 'OK' ELSE 'INFO' END, n,
'Órdenes actualmente cargadas y coherentes.' FROM current_loaded
UNION ALL
SELECT 'MISSING_CYCLE_PARAMETERS', CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, n,
'Productos activos sin bolsas por golpe o cadencia configurada.' FROM cycle_missing
UNION ALL
SELECT 'STALE_OPEN_STOPS', CASE WHEN n = 0 THEN 'OK' ELSE 'WARNING' END, n,
'Paros abiertos desde hace más de 12 horas; no equivalen automáticamente a pérdida.' FROM stale_open;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN
GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_candidates_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_stop_groups_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_shadow_review_queue_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_operator_stop_queue_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_daily_review_queue_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_shadow_summary_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_product_cycle_coverage_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.li_data_quality_v1 TO grafana_ucepsa_ro;
END IF;
END $$;
COMMIT;