feat(ucepsa): add multi-source production context shadow v0.3

This commit is contained in:
Victor Fraile Garcia 2026-07-17 11:20:39 +02:00
parent 36d67d99b0
commit d83bacefc4
6 changed files with 1574 additions and 0 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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)