- 支持每轮提交 1–5 个候选稿并按指定稿验收 - 保留历史轮次只读并兼容单候选稿版本接口 - 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
59 lines
4.2 KiB
TypeScript
59 lines
4.2 KiB
TypeScript
import type { ReviewStatus } from '../../shared/types.js';
|
|
import { databaseDialect, withTransaction, type QueryContext } from '../database.js';
|
|
import { recalculateCollectionStatus } from './collectionsService.js';
|
|
|
|
export class ReviewDecisionError extends Error {
|
|
constructor(public statusCode: number, message: string) { super(message); }
|
|
}
|
|
|
|
export interface CandidateDecisionInput {
|
|
noteId: number;
|
|
versionNumber: number;
|
|
projectId: number;
|
|
decision: 'approved' | 'changes_requested';
|
|
reason: string;
|
|
actorName: string;
|
|
actorRole: 'client';
|
|
}
|
|
|
|
export async function decideCandidateInTransaction(tx: QueryContext, input: CandidateDecisionInput) {
|
|
const candidate = await tx.one<{
|
|
review_round_id: number; candidate_status: string; review_status: ReviewStatus;
|
|
collection_id: number; active_round_id: number | null; round_status: string;
|
|
title: string; description: string; tags: string;
|
|
}>(`SELECT v.review_round_id,v.candidate_status,v.review_status,v.title,v.description,v.tags,
|
|
n.collection_id,n.active_round_id,r.status AS round_status
|
|
FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id
|
|
JOIN review_rounds r ON r.id=v.review_round_id
|
|
WHERE v.note_id=? AND v.version_number=? AND c.project_id=?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
|
|
[input.noteId, input.versionNumber, input.projectId]);
|
|
if (!candidate) throw new ReviewDecisionError(404, '候选稿不存在');
|
|
if (Number(candidate.active_round_id) !== Number(candidate.review_round_id) || candidate.round_status !== 'reviewing') {
|
|
throw new ReviewDecisionError(409, '历史验收轮次为只读状态');
|
|
}
|
|
if (!['pending', 'changes_requested'].includes(candidate.candidate_status)) {
|
|
throw new ReviewDecisionError(409, '该候选稿当前不能重复验收');
|
|
}
|
|
|
|
if (input.decision === 'approved') {
|
|
await tx.execute("UPDATE work_versions SET candidate_status='not_selected', review_status='draft' WHERE review_round_id=? AND version_number!=?", [candidate.review_round_id, input.versionNumber]);
|
|
await tx.execute("UPDATE work_versions SET candidate_status='selected', review_status='approved' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]);
|
|
await tx.execute("UPDATE review_rounds SET status='completed', selected_version_number=?, completed_at=? WHERE id=?", [input.versionNumber, new Date().toISOString(), candidate.review_round_id]);
|
|
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,approved_version_number=?,review_status='approved' WHERE id=?", [candidate.title, candidate.description, candidate.tags, input.versionNumber, input.versionNumber, input.noteId]);
|
|
} else {
|
|
await tx.execute("UPDATE work_versions SET candidate_status='changes_requested', review_status='changes_requested' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]);
|
|
const remaining = await tx.one<{ count: number | string }>("SELECT COUNT(*) AS count FROM work_versions WHERE review_round_id=? AND candidate_status='pending'", [candidate.review_round_id]);
|
|
const workStatus: ReviewStatus = Number(remaining?.count ?? 0) > 0 ? 'pending' : 'changes_requested';
|
|
await tx.execute('UPDATE notes SET title=?,description=?,tags=?,version_number=?,review_status=? WHERE id=?', [candidate.title, candidate.description, candidate.tags, input.versionNumber, workStatus, input.noteId]);
|
|
}
|
|
|
|
await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)', [input.noteId, input.versionNumber, input.decision, candidate.review_status, input.decision, input.reason, input.actorName, input.actorRole]);
|
|
if (input.reason) await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')", [input.noteId, input.reason, input.actorName]);
|
|
await recalculateCollectionStatus(Number(candidate.collection_id), tx);
|
|
return { success: true as const, status: input.decision, version_number: input.versionNumber };
|
|
}
|
|
|
|
export async function decideCandidate(input: CandidateDecisionInput) {
|
|
return withTransaction((tx) => decideCandidateInTransaction(tx, input));
|
|
}
|