feat(review): 重构项目级单方案验收协作

This commit is contained in:
yuzhe
2026-07-22 16:41:03 +08:00
parent 6091d61612
commit e7e268d4eb
45 changed files with 1830 additions and 812 deletions

View File

@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS projects (
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')),
review_status TEXT NOT NULL DEFAULT 'draft' CHECK (review_status IN ('draft', 'reviewing', 'completed', 'archived')),
review_completed_at TIMESTAMPTZ,
access_password_hash TEXT NOT NULL DEFAULT '',
customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE,
access_expires_at TIMESTAMPTZ,
@@ -51,6 +53,7 @@ ALTER TABLE collections ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS notes (
id BIGSERIAL PRIMARY KEY,
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
external_id TEXT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@@ -63,6 +66,9 @@ CREATE TABLE IF NOT EXISTS notes (
);
ALTER TABLE notes ADD COLUMN IF NOT EXISTS external_id TEXT;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'draft';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_completed_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != '';
CREATE TABLE IF NOT EXISTS images (
@@ -84,17 +90,23 @@ CREATE TABLE IF NOT EXISTS annotations (
y DOUBLE PRECISION NOT NULL CHECK (y BETWEEN 0 AND 1),
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS work_comments (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -102,15 +114,38 @@ CREATE TABLE IF NOT EXISTS text_annotations (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
target TEXT NOT NULL CHECK (target IN ('title', 'description')),
target TEXT NOT NULL CHECK (target IN ('title', 'description', 'tags')),
start_offset INTEGER NOT NULL DEFAULT 0,
end_offset INTEGER NOT NULL DEFAULT 0,
selected_text TEXT NOT NULL DEFAULT '',
prefix_text TEXT NOT NULL DEFAULT '',
suffix_text TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE TABLE IF NOT EXISTS feedback_replies (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
feedback_type TEXT NOT NULL CHECK (feedback_type IN ('image_annotation', 'text_annotation', 'comment')),
feedback_id BIGINT NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL,
author_role TEXT NOT NULL CHECK (author_role IN ('client', 'operator')),
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id);
CREATE TABLE IF NOT EXISTS work_versions (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -135,6 +170,7 @@ CREATE TABLE IF NOT EXISTS review_rounds (
selected_version_number INTEGER,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
completion_reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(note_id, round_number)
);
@@ -144,6 +180,27 @@ ALTER TABLE notes ADD COLUMN IF NOT EXISTS approved_version_number INTEGER;
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS review_round_id BIGINT;
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_name TEXT NOT NULL DEFAULT '方案 A';
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE review_rounds ADD COLUMN IF NOT EXISTS completion_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client';
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS version_number INTEGER NOT NULL DEFAULT 1;
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS start_offset INTEGER NOT NULL DEFAULT 0;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS end_offset INTEGER NOT NULL DEFAULT 0;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS selected_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS prefix_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS suffix_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
-- TEXT_ANNOTATION_TARGET_REPAIR_START
ALTER TABLE text_annotations DROP CONSTRAINT IF EXISTS text_annotations_target_check;
ALTER TABLE text_annotations ADD CONSTRAINT text_annotations_target_check
CHECK (target IN ('title', 'description', 'tags'));
-- TEXT_ANNOTATION_TARGET_REPAIR_END
CREATE TABLE IF NOT EXISTS review_events (
id BIGSERIAL PRIMARY KEY,
@@ -223,6 +280,8 @@ CREATE TABLE IF NOT EXISTS storage_configs (
CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config ON storage_configs ((status)) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS collections_project_id_idx ON collections(project_id);
CREATE INDEX IF NOT EXISTS notes_collection_id_idx ON notes(collection_id);
CREATE INDEX IF NOT EXISTS notes_project_id_idx ON notes(project_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != '';
CREATE INDEX IF NOT EXISTS images_note_version_idx ON images(note_id, version_number, order_index);
CREATE INDEX IF NOT EXISTS annotations_image_id_idx ON annotations(image_id);
CREATE INDEX IF NOT EXISTS comments_note_id_idx ON work_comments(note_id);
@@ -231,6 +290,56 @@ CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_
CREATE INDEX IF NOT EXISTS review_rounds_note_id_idx ON review_rounds(note_id, round_number);
CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id);
-- SINGLE_SCHEME_REPAIR_START
DO $$
DECLARE
duplicate RECORD;
version_row RECORD;
source_round RECORD;
note_row RECORD;
keeper_version INTEGER;
next_round INTEGER;
new_round_id BIGINT;
remains_active BOOLEAN;
BEGIN
FOR duplicate IN
SELECT review_round_id, note_id FROM work_versions
WHERE review_round_id IS NOT NULL
GROUP BY review_round_id, note_id HAVING COUNT(*) > 1
LOOP
SELECT * INTO source_round FROM review_rounds WHERE id = duplicate.review_round_id;
SELECT active_round_id, version_number INTO note_row FROM notes WHERE id = duplicate.note_id;
SELECT COALESCE(
(SELECT v.version_number FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id AND v.version_number=note_row.version_number LIMIT 1),
(SELECT MIN(v.version_number) FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id)
) INTO keeper_version;
FOR version_row IN SELECT version_number FROM work_versions WHERE review_round_id=duplicate.review_round_id AND version_number<>keeper_version ORDER BY version_number
LOOP
SELECT COALESCE(MAX(round_number),0)+1 INTO next_round FROM review_rounds WHERE note_id=duplicate.note_id;
remains_active := note_row.active_round_id=duplicate.review_round_id AND note_row.version_number=version_row.version_number;
INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason)
VALUES (
duplicate.note_id,
next_round,
CASE WHEN remains_active THEN source_round.status ELSE 'completed' END,
CASE WHEN source_round.selected_version_number=version_row.version_number THEN version_row.version_number ELSE NULL END,
CASE WHEN remains_active THEN source_round.completed_at ELSE COALESCE(source_round.completed_at,NOW()) END,
source_round.created_by,
source_round.created_at,
CASE WHEN remains_active THEN source_round.completion_reason ELSE COALESCE(NULLIF(source_round.completion_reason,''),'migrated_single_scheme') END
) RETURNING id INTO new_round_id;
UPDATE work_versions SET review_round_id=new_round_id WHERE note_id=duplicate.note_id AND version_number=version_row.version_number;
IF remains_active THEN UPDATE notes SET active_round_id=new_round_id WHERE id=duplicate.note_id; END IF;
END LOOP;
IF source_round.selected_version_number IS DISTINCT FROM keeper_version THEN
UPDATE review_rounds SET selected_version_number=NULL WHERE id=duplicate.review_round_id;
END IF;
END LOOP;
END $$;
-- SINGLE_SCHEME_REPAIR_END
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL;
-- COLLECTION_STATUS_REPAIR_START
UPDATE collections
SET status = 'draft', completed_at = NULL
@@ -281,4 +390,41 @@ FROM review_rounds r
WHERE r.note_id = n.id AND r.round_number = n.version_number AND n.active_round_id IS NULL;
-- REVIEW_ROUND_REPAIR_END
-- PROJECT_REVIEW_STATUS_REPAIR_START
UPDATE notes n
SET project_id = c.project_id
FROM collections c
WHERE c.id = n.collection_id AND n.project_id IS NULL;
UPDATE work_comments wc
SET version_number = n.version_number
FROM notes n
WHERE n.id = wc.note_id AND wc.version_number < 1;
UPDATE projects p
SET review_status = CASE
WHEN p.status = 'archived' THEN 'archived'
WHEN s.work_count IS NULL OR s.work_count = 0 THEN 'draft'
WHEN s.approved_count = s.work_count THEN 'completed'
ELSE 'reviewing'
END,
review_completed_at = CASE
WHEN p.status != 'archived' AND s.work_count > 0 AND s.approved_count = s.work_count
THEN COALESCE(p.review_completed_at, NOW())
ELSE NULL
END
FROM (
SELECT project_id,
COUNT(*) FILTER (WHERE review_status != 'draft') AS work_count,
COUNT(*) FILTER (WHERE review_status = 'approved') AS approved_count
FROM notes GROUP BY project_id
) s
WHERE p.id = s.project_id;
UPDATE projects p
SET review_status = CASE WHEN p.status = 'archived' THEN 'archived' ELSE 'draft' END,
review_completed_at = NULL
WHERE NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = p.id AND n.review_status != 'draft');
-- PROJECT_REVIEW_STATUS_REPAIR_END
COMMIT;