feat(workbench): add dynamic task approval lifecycle
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
-- Read-only work-order projection synchronized by an external work-order source.
|
||||
-- Historical work-order projection. Do not reapply after
|
||||
-- 021_retire_workbench_work_orders.sql has retired this table.
|
||||
|
||||
BEGIN;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Task-scoped, reviewable data access requests for dynamic investigations.
|
||||
-- This migration is deliberately restricted to the verified tjwater_v2 BizDB.
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_database() <> 'tjwater_v2' THEN
|
||||
RAISE EXCEPTION 'Operational access-request migration is restricted to tjwater_v2';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workbench.operational_task_access_requests (
|
||||
project_id uuid NOT NULL,
|
||||
task_id uuid NOT NULL,
|
||||
access_request_id uuid NOT NULL,
|
||||
node_id text NOT NULL CHECK (length(node_id) > 0),
|
||||
status text NOT NULL CHECK (status IN ('pending','approved','rejected')),
|
||||
version bigint NOT NULL CHECK (version >= 1),
|
||||
document jsonb NOT NULL,
|
||||
requested_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (project_id,task_id,access_request_id),
|
||||
FOREIGN KEY (project_id,task_id)
|
||||
REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE,
|
||||
CHECK ((document->>'access_request_id'=access_request_id::text
|
||||
AND document->>'status'=status
|
||||
AND (document->>'version')::bigint=version) IS TRUE)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS operational_task_access_pending_idx
|
||||
ON workbench.operational_task_access_requests(project_id,status,requested_at)
|
||||
WHERE status='pending';
|
||||
|
||||
COMMENT ON TABLE workbench.operational_task_access_requests IS
|
||||
'动态调查中的任务级数据访问申请;与动作审批分离,保存申请范围及人工审核结果。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.project_id IS
|
||||
'所属项目标识;当前仍要求一个项目独占一个 BizDB。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.task_id IS
|
||||
'所属工况任务标识。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.access_request_id IS
|
||||
'访问申请的稳定标识,同时用于计划审核节点引用。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.node_id IS
|
||||
'发起申请的执行计划节点标识。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.status IS
|
||||
'人工审核状态:pending、approved 或 rejected。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.version IS
|
||||
'用于乐观并发控制的申请修订号。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.document IS
|
||||
'经过接口模型校验的完整访问申请文档。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.requested_at IS
|
||||
'Agent 发起访问申请的时间。';
|
||||
COMMENT ON COLUMN workbench.operational_task_access_requests.updated_at IS
|
||||
'申请最近一次状态变更时间。';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Retire the legacy demo-order projection only after its three rows have
|
||||
-- moved to active peripheral orders and active operational task projections.
|
||||
-- This migration is deliberately restricted to the verified tjwater_v2 BizDB.
|
||||
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
legacy_count integer;
|
||||
BEGIN
|
||||
IF current_database() <> 'tjwater_v2' THEN
|
||||
RAISE EXCEPTION 'Legacy work-order retirement is restricted to tjwater_v2';
|
||||
END IF;
|
||||
|
||||
IF to_regclass('workbench.work_orders') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
LOCK TABLE workbench.work_orders IN ACCESS EXCLUSIVE MODE;
|
||||
SELECT count(*) INTO legacy_count FROM workbench.work_orders;
|
||||
IF legacy_count <> 3 OR EXISTS (
|
||||
SELECT 1
|
||||
FROM workbench.work_orders AS legacy
|
||||
WHERE legacy.project_id <> '324ff7ee-c2d4-5184-a84b-48fb14a2933e'::uuid
|
||||
OR legacy.work_order_id NOT IN (
|
||||
'mock-work-order-incident-valve-dispatch',
|
||||
'mock-work-order-pump-boundary-reply',
|
||||
'mock-work-order-valve-operation-execution'
|
||||
)
|
||||
OR legacy.document->>'migrated_to_peripheral_sample' IS NULL
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM peripheral_sample.work_orders AS source_order
|
||||
WHERE source_order.project_id = legacy.project_id
|
||||
AND source_order.work_order_id::text =
|
||||
legacy.document->>'migrated_to_peripheral_sample'
|
||||
AND source_order.deleted_at IS NULL
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM workbench.operational_tasks AS task
|
||||
WHERE task.project_id = legacy.project_id
|
||||
AND task.task_type = 'external_work_order'
|
||||
AND task.document#>>'{source,external_id}' =
|
||||
'peripheral_sample:' ||
|
||||
(legacy.document->>'migrated_to_peripheral_sample')
|
||||
AND task.document#>>'{source,deleted_at}' IS NULL
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Legacy work-order retirement preconditions failed';
|
||||
END IF;
|
||||
|
||||
DROP TABLE workbench.work_orders;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,225 @@
|
||||
-- Backfill actionable notification lifecycle state and business-object links.
|
||||
-- This migration is deliberately restricted to the verified tjwater_v2 BizDB.
|
||||
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_database() <> 'tjwater_v2' THEN
|
||||
RAISE EXCEPTION 'Notification lifecycle migration is restricted to tjwater_v2';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
LOCK TABLE workbench.operational_task_notifications IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
CREATE TEMP TABLE notification_lifecycle_projects ON COMMIT DROP AS
|
||||
SELECT project_id, count(*)::bigint AS notification_count
|
||||
FROM workbench.operational_task_notifications
|
||||
GROUP BY project_id;
|
||||
|
||||
WITH mapped AS (
|
||||
SELECT
|
||||
notification.project_id,
|
||||
notification.notification_id,
|
||||
action.action_id,
|
||||
action.approval_status,
|
||||
action.execution_status
|
||||
FROM workbench.operational_task_notifications AS notification
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT candidate.action_id, candidate.approval_status, candidate.execution_status
|
||||
FROM workbench.operational_task_actions AS candidate
|
||||
WHERE candidate.project_id = notification.project_id
|
||||
AND candidate.task_id = notification.task_id
|
||||
AND 1 = (
|
||||
SELECT count(*)
|
||||
FROM workbench.operational_task_actions AS sibling
|
||||
WHERE sibling.project_id = notification.project_id
|
||||
AND sibling.task_id = notification.task_id
|
||||
)
|
||||
LIMIT 1
|
||||
) AS action ON true
|
||||
WHERE notification.document->>'kind' IN (
|
||||
'action_pending', 'action_approval_pending', 'external_coordination_failed'
|
||||
)
|
||||
AND NOT (notification.document ? 'lifecycle_status')
|
||||
)
|
||||
UPDATE workbench.operational_task_notifications AS notification
|
||||
SET document = notification.document || jsonb_strip_nulls(jsonb_build_object(
|
||||
'lifecycle_status', CASE
|
||||
WHEN mapped.action_id IS NULL THEN 'active'
|
||||
WHEN notification.document->>'kind' IN ('action_pending', 'action_approval_pending')
|
||||
AND mapped.approval_status = 'pending' THEN 'active'
|
||||
WHEN notification.document->>'kind' = 'external_coordination_failed'
|
||||
AND mapped.execution_status IN ('failed', 'unsupported', 'unknown') THEN 'active'
|
||||
ELSE 'resolved'
|
||||
END,
|
||||
'subject_type', CASE WHEN mapped.action_id IS NULL THEN 'task' ELSE 'action' END,
|
||||
'subject_id', COALESCE(mapped.action_id::text, notification.task_id::text),
|
||||
'resolved_at', CASE
|
||||
WHEN (notification.document->>'kind' IN ('action_pending', 'action_approval_pending')
|
||||
AND mapped.approval_status <> 'pending')
|
||||
OR (notification.document->>'kind' = 'external_coordination_failed'
|
||||
AND mapped.execution_status NOT IN ('failed', 'unsupported', 'unknown'))
|
||||
THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP))
|
||||
ELSE NULL
|
||||
END,
|
||||
'resolution_reason', CASE
|
||||
WHEN notification.document->>'resolution_reason' IS NOT NULL
|
||||
THEN notification.document->>'resolution_reason'
|
||||
WHEN notification.document->>'kind' IN ('action_pending', 'action_approval_pending')
|
||||
AND mapped.approval_status <> 'pending' THEN 'migration_action_decided'
|
||||
WHEN notification.document->>'kind' = 'external_coordination_failed'
|
||||
AND mapped.execution_status NOT IN ('failed', 'unsupported', 'unknown')
|
||||
THEN 'migration_external_coordination_recovered'
|
||||
ELSE NULL
|
||||
END
|
||||
))
|
||||
FROM mapped
|
||||
WHERE notification.project_id = mapped.project_id
|
||||
AND notification.notification_id = mapped.notification_id;
|
||||
|
||||
DO $block$
|
||||
BEGIN
|
||||
IF to_regclass('workbench.operational_task_access_requests') IS NOT NULL THEN
|
||||
EXECUTE $update$
|
||||
WITH mapped AS (
|
||||
SELECT
|
||||
notification.project_id,
|
||||
notification.notification_id,
|
||||
access_request.access_request_id,
|
||||
access_request.status
|
||||
FROM workbench.operational_task_notifications AS notification
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT candidate.access_request_id, candidate.status
|
||||
FROM workbench.operational_task_access_requests AS candidate
|
||||
WHERE candidate.project_id = notification.project_id
|
||||
AND candidate.task_id = notification.task_id
|
||||
AND 1 = (
|
||||
SELECT count(*)
|
||||
FROM workbench.operational_task_access_requests AS sibling
|
||||
WHERE sibling.project_id = notification.project_id
|
||||
AND sibling.task_id = notification.task_id
|
||||
)
|
||||
LIMIT 1
|
||||
) AS access_request ON true
|
||||
WHERE notification.document->>'kind' = 'access_approval_pending'
|
||||
AND NOT (notification.document ? 'lifecycle_status')
|
||||
)
|
||||
UPDATE workbench.operational_task_notifications AS notification
|
||||
SET document = notification.document || jsonb_strip_nulls(jsonb_build_object(
|
||||
'lifecycle_status', CASE
|
||||
WHEN mapped.access_request_id IS NULL OR mapped.status = 'pending'
|
||||
THEN 'active' ELSE 'resolved'
|
||||
END,
|
||||
'subject_type', CASE
|
||||
WHEN mapped.access_request_id IS NULL THEN 'task'
|
||||
ELSE 'access_request'
|
||||
END,
|
||||
'subject_id', COALESCE(
|
||||
mapped.access_request_id::text,
|
||||
notification.task_id::text
|
||||
),
|
||||
'resolved_at', CASE
|
||||
WHEN mapped.access_request_id IS NOT NULL AND mapped.status <> 'pending'
|
||||
THEN COALESCE(
|
||||
notification.document->'resolved_at',
|
||||
to_jsonb(CURRENT_TIMESTAMP)
|
||||
)
|
||||
ELSE NULL
|
||||
END,
|
||||
'resolution_reason', CASE
|
||||
WHEN notification.document->>'resolution_reason' IS NOT NULL
|
||||
THEN notification.document->>'resolution_reason'
|
||||
WHEN mapped.status <> 'pending' THEN 'migration_access_request_decided'
|
||||
ELSE NULL
|
||||
END
|
||||
))
|
||||
FROM mapped
|
||||
WHERE notification.project_id = mapped.project_id
|
||||
AND notification.notification_id = mapped.notification_id
|
||||
$update$;
|
||||
END IF;
|
||||
END $block$;
|
||||
|
||||
UPDATE workbench.operational_task_notifications AS notification
|
||||
SET document = notification.document || jsonb_strip_nulls(jsonb_build_object(
|
||||
'lifecycle_status', CASE
|
||||
WHEN notification.document->>'kind' = 'task_failed'
|
||||
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') = 'failed'
|
||||
THEN 'active'
|
||||
WHEN notification.document->>'kind' = 'human_input_required'
|
||||
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') = 'waiting_human'
|
||||
THEN 'active'
|
||||
WHEN notification.document->>'kind' IN ('task_failed', 'human_input_required')
|
||||
THEN 'resolved'
|
||||
ELSE COALESCE(notification.document->>'lifecycle_status', 'active')
|
||||
END,
|
||||
'subject_type', COALESCE(notification.document->>'subject_type', 'task'),
|
||||
'subject_id', COALESCE(notification.document->>'subject_id', notification.task_id::text),
|
||||
'resolved_at', CASE
|
||||
WHEN notification.document->>'kind' = 'task_failed'
|
||||
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'failed'
|
||||
THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP))
|
||||
WHEN notification.document->>'kind' = 'human_input_required'
|
||||
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'waiting_human'
|
||||
THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP))
|
||||
ELSE notification.document->'resolved_at'
|
||||
END,
|
||||
'resolution_reason', CASE
|
||||
WHEN notification.document->>'kind' = 'task_failed'
|
||||
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'failed'
|
||||
THEN 'migration_task_recovered'
|
||||
WHEN notification.document->>'kind' = 'human_input_required'
|
||||
AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'waiting_human'
|
||||
THEN 'migration_human_input_completed'
|
||||
ELSE notification.document->>'resolution_reason'
|
||||
END
|
||||
))
|
||||
FROM workbench.operational_tasks AS task
|
||||
WHERE notification.project_id = task.project_id
|
||||
AND notification.task_id = task.task_id
|
||||
AND notification.document->>'kind' NOT IN (
|
||||
'action_pending', 'action_approval_pending',
|
||||
'external_coordination_failed', 'access_approval_pending'
|
||||
)
|
||||
AND NOT (notification.document ? 'lifecycle_status');
|
||||
|
||||
WITH inserted_events AS (
|
||||
INSERT INTO workbench.domain_event_log (
|
||||
project_id,
|
||||
event_id,
|
||||
topic,
|
||||
aggregate_type,
|
||||
aggregate_id,
|
||||
aggregate_revision,
|
||||
metadata
|
||||
)
|
||||
SELECT
|
||||
project_id,
|
||||
(
|
||||
substr(md5(project_id::text || ':notification-lifecycle-v1'), 1, 8)
|
||||
|| '-' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 9, 4)
|
||||
|| '-5' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 14, 3)
|
||||
|| '-a' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 18, 3)
|
||||
|| '-' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 21, 12)
|
||||
)::uuid,
|
||||
'operational.notification.changed',
|
||||
'operational_notification_batch',
|
||||
project_id::text,
|
||||
1,
|
||||
jsonb_build_object(
|
||||
'change', 'lifecycle_backfilled',
|
||||
'notification_count', notification_count
|
||||
)
|
||||
FROM notification_lifecycle_projects
|
||||
ON CONFLICT (project_id, event_id) DO NOTHING
|
||||
RETURNING project_id, event_seq
|
||||
)
|
||||
SELECT pg_notify('workbench_events', project_id::text || ':' || event_seq::text)
|
||||
FROM inserted_events;
|
||||
|
||||
COMMENT ON COLUMN workbench.operational_task_notifications.document IS
|
||||
'通知内容及 active/resolved 生命周期;用户 unread/read 状态独立存于 notification_reads。';
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user