feat(review): 重构项目级单方案验收协作
This commit is contained in:
30
api/services/feedbackService.ts
Normal file
30
api/services/feedbackService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { database } from '../database.js';
|
||||
import type { FeedbackReply, FeedbackType } from '../../shared/types.js';
|
||||
|
||||
export type FeedbackTarget = {
|
||||
note_id: number;
|
||||
version_number: number;
|
||||
author_name: string;
|
||||
author_role: 'client' | 'operator';
|
||||
withdrawn_at: string | null;
|
||||
};
|
||||
|
||||
export async function findFeedbackTarget(workId: number, type: FeedbackType, feedbackId: number): Promise<FeedbackTarget | undefined> {
|
||||
if (type === 'image_annotation') {
|
||||
return database.one<FeedbackTarget>(`SELECT i.note_id,i.version_number,a.author_name,a.author_role,a.withdrawn_at
|
||||
FROM annotations a JOIN images i ON i.id=a.image_id WHERE a.id=? AND i.note_id=?`, [feedbackId, workId]);
|
||||
}
|
||||
const table = type === 'text_annotation' ? 'text_annotations' : 'work_comments';
|
||||
return database.one<FeedbackTarget>(`SELECT note_id,version_number,author_name,author_role,withdrawn_at FROM ${table} WHERE id=? AND note_id=?`, [feedbackId, workId]);
|
||||
}
|
||||
|
||||
export async function addFeedbackReply(target: FeedbackTarget, type: FeedbackType, feedbackId: number, content: string, authorName: string, authorRole: 'client' | 'operator'): Promise<FeedbackReply> {
|
||||
const id = await database.insertId(`INSERT INTO feedback_replies (note_id,version_number,feedback_type,feedback_id,content,author_name,author_role)
|
||||
VALUES (?,?,?,?,?,?,?)`, [target.note_id, target.version_number, type, feedbackId, content, authorName, authorRole]);
|
||||
return (await database.one<FeedbackReply>('SELECT * FROM feedback_replies WHERE id=?', [id]))!;
|
||||
}
|
||||
|
||||
export async function withdrawFeedback(type: FeedbackType, feedbackId: number): Promise<void> {
|
||||
const table = type === 'image_annotation' ? 'annotations' : type === 'text_annotation' ? 'text_annotations' : 'work_comments';
|
||||
await database.execute(`UPDATE ${table} SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL`, [feedbackId]);
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import sharp from 'sharp';
|
||||
import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus } from '../../shared/types.js';
|
||||
import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus, WorkFeedbackBundle, WorkRound } from '../../shared/types.js';
|
||||
import { notesRepository } from '../repositories/notesRepository.js';
|
||||
import { imagesRepository } from '../repositories/imagesRepository.js';
|
||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||
import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js';
|
||||
import { storeUploadedFile } from '../storage.js';
|
||||
import { recalculateCollectionStatus } from './collectionsService.js';
|
||||
import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.js';
|
||||
|
||||
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
|
||||
export interface UploadCandidate { candidate_name: string; title: string; description: string; tags: string[]; files: UploadedFile[] }
|
||||
export interface UrlCandidate { candidate_name: string; title: string; description: string; tags: string[]; images: string[] }
|
||||
export interface UploadRound { title: string; description: string; tags: string[]; files: UploadedFile[] }
|
||||
export interface UrlRound { title: string; description: string; tags: string[]; images: string[] }
|
||||
|
||||
type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string };
|
||||
type PreparedCandidate = { candidate_name: string; title: string; description: string; tags: string[]; images: StoredImage[] };
|
||||
type PreparedRound = { title: string; description: string; tags: string[]; images: StoredImage[] };
|
||||
|
||||
async function readImageSize(filePath: string): Promise<{ width: number; height: number }> {
|
||||
try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; }
|
||||
@@ -30,59 +31,62 @@ function externalImages(images: string[]): StoredImage[] {
|
||||
async function createRoundInTransaction(
|
||||
tx: QueryContext,
|
||||
noteId: number,
|
||||
projectId: number,
|
||||
collectionId: number,
|
||||
candidates: PreparedCandidate[],
|
||||
round: PreparedRound,
|
||||
createdBy: number | undefined,
|
||||
fromStatus: ReviewStatus,
|
||||
): Promise<{ roundId: number; roundNumber: number; firstVersion: number }> {
|
||||
const note = await tx.one<{ active_round_id: number | null }>('SELECT active_round_id FROM notes WHERE id = ?' + (databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''), [noteId]);
|
||||
): Promise<{ roundId: number; roundNumber: number; versionNumber: number }> {
|
||||
const note = await tx.one<{ active_round_id: number | null }>(
|
||||
`SELECT active_round_id FROM notes WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
|
||||
[noteId],
|
||||
);
|
||||
if (note?.active_round_id) {
|
||||
await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND candidate_status = 'pending'", [note.active_round_id]);
|
||||
await tx.execute("UPDATE review_rounds SET status = 'completed', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status = 'reviewing'", [new Date().toISOString(), note.active_round_id]);
|
||||
await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND review_status != 'approved'", [note.active_round_id]);
|
||||
await tx.execute("UPDATE review_rounds SET status = 'completed', completion_reason = 'superseded', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status IN ('draft', 'reviewing')", [new Date().toISOString(), note.active_round_id]);
|
||||
}
|
||||
|
||||
const maxima = await tx.one<{ max_version: number | string | null; max_round: number | string | null }>(
|
||||
`SELECT (SELECT MAX(version_number) FROM work_versions WHERE note_id = ?) AS max_version,
|
||||
(SELECT MAX(round_number) FROM review_rounds WHERE note_id = ?) AS max_round`,
|
||||
[noteId, noteId],
|
||||
);
|
||||
const firstVersion = Number(maxima?.max_version ?? 0) + 1;
|
||||
const versionNumber = Number(maxima?.max_version ?? 0) + 1;
|
||||
const roundNumber = Number(maxima?.max_round ?? 0) + 1;
|
||||
const roundId = await tx.insertId(
|
||||
"INSERT INTO review_rounds (note_id, round_number, status, created_by) VALUES (?, ?, 'reviewing', ?)",
|
||||
[noteId, roundNumber, createdBy ?? null],
|
||||
);
|
||||
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const candidate = candidates[index];
|
||||
const versionNumber = firstVersion + index;
|
||||
await tx.execute(
|
||||
"INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, review_round_id, candidate_name, candidate_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 'pending', ?)",
|
||||
[noteId, versionNumber, candidate.title, candidate.description, JSON.stringify(candidate.tags), roundId, candidate.candidate_name, createdBy ?? null],
|
||||
);
|
||||
await imagesRepository.createMany(noteId, candidate.images, versionNumber, tx);
|
||||
await tx.execute(
|
||||
"INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')",
|
||||
[noteId, versionNumber, fromStatus, '工作台'],
|
||||
);
|
||||
}
|
||||
|
||||
const first = candidates[0];
|
||||
await tx.execute(
|
||||
"INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, review_round_id, candidate_name, candidate_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?, '', 'pending', ?)",
|
||||
[noteId, versionNumber, round.title, round.description, JSON.stringify(round.tags), roundId, createdBy ?? null],
|
||||
);
|
||||
await imagesRepository.createMany(noteId, round.images, versionNumber, tx);
|
||||
await tx.execute(
|
||||
"INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')",
|
||||
[noteId, versionNumber, fromStatus, '工作台'],
|
||||
);
|
||||
await tx.execute(
|
||||
"UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?",
|
||||
[first.title, first.description, JSON.stringify(first.tags), firstVersion, roundId, noteId],
|
||||
[round.title, round.description, JSON.stringify(round.tags), versionNumber, roundId, noteId],
|
||||
);
|
||||
await recalculateCollectionStatus(collectionId, tx);
|
||||
return { roundId, roundNumber, firstVersion };
|
||||
await recalculateProjectReviewStatus(projectId, tx);
|
||||
return { roundId, roundNumber, versionNumber };
|
||||
}
|
||||
|
||||
async function prepareUploadCandidates(candidates: UploadCandidate[]): Promise<PreparedCandidate[]> {
|
||||
return Promise.all(candidates.map(async (candidate) => ({
|
||||
candidate_name: candidate.candidate_name,
|
||||
title: candidate.title,
|
||||
description: candidate.description,
|
||||
tags: candidate.tags,
|
||||
images: await prepareFiles(candidate.files),
|
||||
})));
|
||||
async function prepareUploadRound(round: UploadRound): Promise<PreparedRound> {
|
||||
return { title: round.title, description: round.description, tags: round.tags, images: await prepareFiles(round.files) };
|
||||
}
|
||||
|
||||
function mapRound(row: Omit<WorkRound, 'tags'> & { tags: string }): WorkRound {
|
||||
return {
|
||||
...row,
|
||||
version_number: Number(row.version_number),
|
||||
review_round_id: Number(row.review_round_id),
|
||||
round_number: Number(row.round_number),
|
||||
tags: JSON.parse(row.tags || '[]') as string[],
|
||||
};
|
||||
}
|
||||
|
||||
export const notesService = {
|
||||
@@ -90,103 +94,154 @@ export const notesService = {
|
||||
return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId, externalId: query.externalId });
|
||||
},
|
||||
|
||||
async getDetail(id: number, requestedVersion?: number): Promise<NoteDetail | null> {
|
||||
async getDetail(id: number, requestedVersion?: number, requestedRound?: number): Promise<NoteDetail | null> {
|
||||
const current = await notesRepository.findById(id);
|
||||
if (!current) return null;
|
||||
const selectedVersion = requestedVersion && requestedVersion !== current.version_number
|
||||
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, requestedVersion])
|
||||
const requested = requestedRound
|
||||
? await database.one<{ version_number: number }>('SELECT v.version_number FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id WHERE v.note_id = ? AND r.round_number = ?', [id, requestedRound])
|
||||
: undefined;
|
||||
if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null;
|
||||
const targetVersion = requested ? Number(requested.version_number) : requestedVersion;
|
||||
const selectedVersion = targetVersion && targetVersion !== current.version_number
|
||||
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, targetVersion])
|
||||
: undefined;
|
||||
if (targetVersion && targetVersion !== current.version_number && !selectedVersion) return null;
|
||||
const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current;
|
||||
const images = await imagesRepository.listByNote(id, note.version_number);
|
||||
const workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string; collection_status: NoteDetail['collection']['status'] }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name, c.status AS collection_status FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ?`, [id]);
|
||||
if (!workContext) return null;
|
||||
const versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
|
||||
v.candidate_name, v.candidate_status, v.created_at, r.round_number, r.status AS round_status, r.selected_version_number
|
||||
const project = await database.one<{ id: number; name: string; slug: string; status: NoteDetail['project']['status']; review_status: NoteDetail['project']['review_status'] }>('SELECT p.id, p.name, p.slug, p.status, p.review_status FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.id = ?', [id]);
|
||||
if (!project) return null;
|
||||
const roundRows = await database.all<Array<Omit<WorkRound, 'tags'> & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
|
||||
v.created_at, r.round_number, r.status AS round_status, r.completion_reason
|
||||
FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id
|
||||
WHERE v.note_id = ? ORDER BY r.round_number DESC, v.version_number ASC`, [id]);
|
||||
WHERE v.note_id = ? ORDER BY r.round_number DESC`, [id]);
|
||||
const result: NoteDetail = {
|
||||
...note,
|
||||
images: [] as ImageWithAnnotations[],
|
||||
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT id, note_id, version_number, target, content, author_name, status, created_at FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
|
||||
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id ASC', [id]),
|
||||
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), review_round_id: Number(item.review_round_id), round_number: Number(item.round_number), selected_version_number: item.selected_version_number == null ? null : Number(item.selected_version_number), tags: JSON.parse(item.tags || '[]') as string[] })),
|
||||
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC', [id]),
|
||||
project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug },
|
||||
collection: { id: Number(workContext.collection_id), name: workContext.collection_name, status: workContext.collection_status },
|
||||
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT * FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
|
||||
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
|
||||
feedback_replies: await database.all<NoteDetail['feedback_replies'][number]>('SELECT * FROM feedback_replies WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
|
||||
rounds: roundRows.map(mapRound),
|
||||
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? AND version_number = ? ORDER BY id DESC', [id, note.version_number]),
|
||||
project: { id: Number(project.id), name: project.name, slug: project.slug, status: project.status, review_status: project.review_status },
|
||||
};
|
||||
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
|
||||
return result;
|
||||
},
|
||||
|
||||
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
|
||||
async createInProject(projectId: number, title: string, description: string, files: UploadedFile[], tags: string[], externalId: string | null = null): Promise<Note> {
|
||||
const prepared = await prepareFiles(files);
|
||||
const noteId = await withTransaction(async (tx) => {
|
||||
const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]);
|
||||
await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: prepared }], undefined, 'draft');
|
||||
return id;
|
||||
return withTransaction(async (tx) => {
|
||||
const collectionId = await ensureProjectCompatibilityCollection(projectId, tx);
|
||||
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
|
||||
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
|
||||
return (await notesRepository.findById(id))!;
|
||||
});
|
||||
},
|
||||
|
||||
async createInProjectFromUrls(projectId: number, title: string, description: string, imageUrls: string[], tags: string[], externalId: string | null = null): Promise<Note> {
|
||||
return withTransaction(async (tx) => {
|
||||
const collectionId = await ensureProjectCompatibilityCollection(projectId, tx);
|
||||
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
|
||||
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft');
|
||||
return (await notesRepository.findById(id))!;
|
||||
});
|
||||
},
|
||||
|
||||
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
|
||||
const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
|
||||
if (!collection) throw new Error('作品交付集不存在');
|
||||
const projectId = Number(collection.project_id);
|
||||
const prepared = await prepareFiles(files);
|
||||
return withTransaction(async (tx) => {
|
||||
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
|
||||
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
|
||||
return (await notesRepository.findById(id))!;
|
||||
});
|
||||
return (await notesRepository.findById(noteId))!;
|
||||
},
|
||||
|
||||
async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
|
||||
const noteId = await withTransaction(async (tx) => {
|
||||
const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]);
|
||||
await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: externalImages(imageUrls) }], undefined, 'draft');
|
||||
return id;
|
||||
const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
|
||||
if (!collection) throw new Error('作品交付集不存在');
|
||||
const projectId = Number(collection.project_id);
|
||||
return withTransaction(async (tx) => {
|
||||
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
|
||||
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft');
|
||||
return (await notesRepository.findById(id))!;
|
||||
});
|
||||
return (await notesRepository.findById(noteId))!;
|
||||
},
|
||||
|
||||
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> {
|
||||
return notesRepository.findByExternalId(collectionId, externalId);
|
||||
},
|
||||
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> { return notesRepository.findByExternalId(collectionId, externalId); },
|
||||
async findByProjectExternalId(projectId: number, externalId: string): Promise<Note | null> { return notesRepository.findByProjectExternalId(projectId, externalId); },
|
||||
|
||||
async createReviewRound(id: number, candidates: UploadCandidate[], createdBy?: number): Promise<Note> {
|
||||
async createRound(id: number, round: UploadRound, createdBy?: number): Promise<Note> {
|
||||
const current = await notesRepository.findById(id);
|
||||
if (!current) throw new Error('作品不存在');
|
||||
const prepared = await prepareUploadCandidates(candidates);
|
||||
await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status));
|
||||
const prepared = await prepareUploadRound(round);
|
||||
await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status));
|
||||
return (await notesRepository.findById(id))!;
|
||||
},
|
||||
|
||||
async createReviewRoundFromUrls(id: number, candidates: UrlCandidate[], createdBy?: number): Promise<Note> {
|
||||
async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise<Note> {
|
||||
const current = await notesRepository.findById(id);
|
||||
if (!current) throw new Error('作品不存在');
|
||||
const prepared = candidates.map((candidate) => ({ ...candidate, images: externalImages(candidate.images) }));
|
||||
await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status));
|
||||
const prepared = { ...round, images: externalImages(round.images) };
|
||||
await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status));
|
||||
return (await notesRepository.findById(id))!;
|
||||
},
|
||||
|
||||
async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> {
|
||||
return this.createReviewRound(id, [{ candidate_name: '方案 A', title, description, tags, files }], createdBy);
|
||||
return this.createRound(id, { title, description, tags, files }, createdBy);
|
||||
},
|
||||
|
||||
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
|
||||
return this.createReviewRoundFromUrls(id, [{ candidate_name: '方案 A', title, description, tags, images: imageUrls }], createdBy);
|
||||
return this.createRoundFromUrls(id, { title, description, tags, images: imageUrls }, createdBy);
|
||||
},
|
||||
|
||||
async getFeedback(id: number): Promise<WorkFeedbackBundle | null> {
|
||||
const rounds = await database.all<{ round_number: number; version_number: number }>('SELECT r.round_number, v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id = r.id WHERE r.note_id = ? ORDER BY r.round_number DESC', [id]);
|
||||
if (!rounds.length && !await notesRepository.findById(id)) return null;
|
||||
const imageAnnotations = await database.all<Array<WorkFeedbackBundle['rounds'][number]['image_annotations'][number] & { version_number: number }>[number]>(`SELECT a.*, i.id AS image_id, i.url AS image_url, i.version_number FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = ? ORDER BY a.id`, [id]);
|
||||
const textAnnotations = await database.all<Array<NoteDetail['text_annotations'][number] & { version_number: number }>[number]>('SELECT * FROM text_annotations WHERE note_id = ? ORDER BY id', [id]);
|
||||
const comments = await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id', [id]);
|
||||
const replies = await database.all<NoteDetail['feedback_replies'][number]>('SELECT * FROM feedback_replies WHERE note_id = ? ORDER BY id', [id]);
|
||||
const events = await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id', [id]);
|
||||
return {
|
||||
work_id: id,
|
||||
rounds: rounds.map((round) => ({
|
||||
round_number: Number(round.round_number),
|
||||
version_number: Number(round.version_number),
|
||||
image_annotations: imageAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)),
|
||||
text_annotations: textAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)),
|
||||
comments: comments.filter((item) => Number(item.version_number) === Number(round.version_number)),
|
||||
feedback_replies: replies.filter((item) => Number(item.version_number) === Number(round.version_number)),
|
||||
review_events: events.filter((item) => Number(item.version_number) === Number(round.version_number)),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async remove(id: number) {
|
||||
return withTransaction(async (tx) => {
|
||||
const note = await tx.one<{ collection_id: number }>('SELECT collection_id FROM notes WHERE id = ?', [id]);
|
||||
const note = await tx.one<{ collection_id: number; project_id: number }>('SELECT collection_id, project_id FROM notes WHERE id = ?', [id]);
|
||||
if (!note) return false;
|
||||
const removed = (await tx.execute('DELETE FROM notes WHERE id = ?', [id])).changes > 0;
|
||||
if (removed) await recalculateCollectionStatus(Number(note.collection_id), tx);
|
||||
if (removed) {
|
||||
await recalculateCollectionStatus(Number(note.collection_id), tx);
|
||||
await recalculateProjectReviewStatus(Number(note.project_id), tx);
|
||||
}
|
||||
return removed;
|
||||
});
|
||||
},
|
||||
|
||||
async setStatus(id: number, status: ReviewStatus) {
|
||||
return withTransaction(async (tx) => {
|
||||
const note = await tx.one<{ collection_id: number; active_round_id: number | null }>('SELECT collection_id, active_round_id FROM notes WHERE id = ?', [id]);
|
||||
const note = await tx.one<{ collection_id: number; project_id: number; active_round_id: number | null }>('SELECT collection_id, project_id, active_round_id FROM notes WHERE id = ?', [id]);
|
||||
if (!note) return false;
|
||||
await tx.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id]);
|
||||
if (note.active_round_id) {
|
||||
const candidateStatus = status === 'draft' ? 'draft' : 'pending';
|
||||
await tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ? AND candidate_status NOT IN ('selected', 'not_selected')", [status, candidateStatus, note.active_round_id]);
|
||||
await tx.execute('UPDATE review_rounds SET status = ?, selected_version_number = NULL, completed_at = NULL WHERE id = ?', [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]);
|
||||
await tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ?", [status, status === 'draft' ? 'draft' : 'pending', note.active_round_id]);
|
||||
await tx.execute("UPDATE review_rounds SET status = ?, completion_reason = '', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]);
|
||||
}
|
||||
await recalculateCollectionStatus(Number(note.collection_id), tx);
|
||||
await recalculateProjectReviewStatus(Number(note.project_id), tx);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
55
api/services/projectsService.ts
Normal file
55
api/services/projectsService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ProjectReviewStatus } from '../../shared/types.js';
|
||||
import { database, databaseDialect, type QueryContext } from '../database.js';
|
||||
|
||||
export interface ProjectReviewStatusResult {
|
||||
reviewStatus: ProjectReviewStatus;
|
||||
workCount: number;
|
||||
approvedCount: number;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export function deriveProjectReviewStatus(workCount: number, approvedCount: number): Exclude<ProjectReviewStatus, 'archived'> {
|
||||
if (workCount === 0) return 'draft';
|
||||
if (approvedCount === workCount) return 'completed';
|
||||
return 'reviewing';
|
||||
}
|
||||
|
||||
export async function recalculateProjectReviewStatus(
|
||||
projectId: number,
|
||||
tx: QueryContext = database,
|
||||
): Promise<ProjectReviewStatusResult | null> {
|
||||
const project = await tx.one<{ status: string; review_status: ProjectReviewStatus; review_completed_at: string | null }>(
|
||||
`SELECT status, review_status, review_completed_at FROM projects WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
|
||||
[projectId],
|
||||
);
|
||||
if (!project) return null;
|
||||
|
||||
const counts = await tx.one<{ work_count: number | string; approved_count: number | string }>(
|
||||
`SELECT COUNT(*) AS work_count,
|
||||
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
|
||||
FROM notes WHERE project_id = ? AND review_status != 'draft'`,
|
||||
[projectId],
|
||||
);
|
||||
const workCount = Number(counts?.work_count ?? 0);
|
||||
const approvedCount = Number(counts?.approved_count ?? 0);
|
||||
|
||||
if (project.status === 'archived') {
|
||||
return { reviewStatus: 'archived', workCount, approvedCount, completedAt: project.review_completed_at };
|
||||
}
|
||||
|
||||
const reviewStatus = deriveProjectReviewStatus(workCount, approvedCount);
|
||||
const completedAt = reviewStatus === 'completed'
|
||||
? project.review_completed_at ?? new Date().toISOString()
|
||||
: null;
|
||||
await tx.execute('UPDATE projects SET review_status = ?, review_completed_at = ? WHERE id = ?', [reviewStatus, completedAt, projectId]);
|
||||
return { reviewStatus, workCount, approvedCount, completedAt };
|
||||
}
|
||||
|
||||
export async function ensureProjectCompatibilityCollection(projectId: number, tx: QueryContext = database): Promise<number> {
|
||||
const existing = await tx.one<{ id: number }>('SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1', [projectId]);
|
||||
if (existing) return Number(existing.id);
|
||||
return tx.insertId(
|
||||
"INSERT INTO collections (project_id, name, client_description, status) VALUES (?, ?, '', 'draft')",
|
||||
[projectId, '__project_default__'],
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ReviewStatus } from '../../shared/types.js';
|
||||
import { databaseDialect, withTransaction, type QueryContext } from '../database.js';
|
||||
import { recalculateCollectionStatus } from './collectionsService.js';
|
||||
import { recalculateProjectReviewStatus } from './projectsService.js';
|
||||
|
||||
export class ReviewDecisionError extends Error {
|
||||
constructor(public statusCode: number, message: string) { super(message); }
|
||||
}
|
||||
|
||||
export interface CandidateDecisionInput {
|
||||
export interface RoundDecisionInput {
|
||||
noteId: number;
|
||||
versionNumber: number;
|
||||
projectId: number;
|
||||
@@ -16,43 +17,48 @@ export interface CandidateDecisionInput {
|
||||
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' : ''}`,
|
||||
export async function decideRoundInTransaction(tx: QueryContext, input: RoundDecisionInput) {
|
||||
const round = await tx.one<{
|
||||
review_round_id: number; review_status: ReviewStatus; collection_id: number; project_id: number;
|
||||
active_round_id: number | null; round_status: string; project_status: string; title: string; description: string; tags: string;
|
||||
}>(`SELECT v.review_round_id,v.review_status,v.title,v.description,v.tags,
|
||||
n.collection_id,n.project_id,n.active_round_id,r.status AS round_status,p.status AS project_status
|
||||
FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN review_rounds r ON r.id=v.review_round_id JOIN projects p ON p.id=n.project_id
|
||||
WHERE v.note_id=? AND v.version_number=? AND n.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') {
|
||||
if (!round) throw new ReviewDecisionError(404, '验收轮次不存在');
|
||||
if (round.project_status !== 'active') throw new ReviewDecisionError(409, '已关闭或归档项目为只读状态');
|
||||
if (Number(round.active_round_id) !== Number(round.review_round_id) || round.round_status !== 'reviewing') {
|
||||
throw new ReviewDecisionError(409, '历史验收轮次为只读状态');
|
||||
}
|
||||
if (!['pending', 'changes_requested'].includes(candidate.candidate_status)) {
|
||||
throw new ReviewDecisionError(409, '该候选稿当前不能重复验收');
|
||||
if (!['pending', 'changes_requested'].includes(round.review_status)) {
|
||||
throw new ReviewDecisionError(409, '该轮次当前不能重复验收');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
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]);
|
||||
await tx.execute("UPDATE review_rounds SET status='completed', completion_reason='approved', selected_version_number=?, completed_at=? WHERE id=?", [input.versionNumber, now, round.review_round_id]);
|
||||
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,approved_version_number=?,review_status='approved' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, input.versionNumber, input.noteId]);
|
||||
await tx.execute("UPDATE annotations SET status='confirmed', closure_reason='approved_with_round' WHERE image_id IN (SELECT id FROM images WHERE note_id=? AND version_number=?) AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
|
||||
await tx.execute("UPDATE text_annotations SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
|
||||
await tx.execute("UPDATE work_comments SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
|
||||
} 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("UPDATE review_rounds SET status='completed', completion_reason='changes_requested', completed_at=? WHERE id=?", [now, round.review_round_id]);
|
||||
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,review_status='changes_requested' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, 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);
|
||||
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, round.review_status, input.decision, input.reason, input.actorName, input.actorRole]);
|
||||
if (input.reason) await tx.execute("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [input.noteId, input.versionNumber, input.reason, input.actorName]);
|
||||
await recalculateCollectionStatus(Number(round.collection_id), tx);
|
||||
await recalculateProjectReviewStatus(Number(round.project_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));
|
||||
export async function decideRound(input: RoundDecisionInput) {
|
||||
return withTransaction((tx) => decideRoundInTransaction(tx, input));
|
||||
}
|
||||
|
||||
export const decideCandidate = decideRound;
|
||||
export const decideCandidateInTransaction = decideRoundInTransaction;
|
||||
|
||||
Reference in New Issue
Block a user