feat(collections): 自动同步作品交付集验收状态
This commit is contained in:
46
api/services/collectionsService.ts
Normal file
46
api/services/collectionsService.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { CollectionStatus } from '../../shared/types.js';
|
||||
import { database, databaseDialect, type QueryContext } from '../database.js';
|
||||
|
||||
export interface CollectionStatusResult {
|
||||
status: CollectionStatus;
|
||||
workCount: number;
|
||||
approvedCount: number;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export function deriveCollectionStatus(workCount: number, approvedCount: number): Exclude<CollectionStatus, 'archived'> {
|
||||
if (workCount === 0) return 'draft';
|
||||
if (approvedCount === workCount) return 'completed';
|
||||
return 'reviewing';
|
||||
}
|
||||
|
||||
export async function recalculateCollectionStatus(
|
||||
collectionId: number,
|
||||
tx: QueryContext = database,
|
||||
): Promise<CollectionStatusResult | null> {
|
||||
const collection = await tx.one<{ status: CollectionStatus; completed_at: string | null }>(
|
||||
`SELECT status, completed_at FROM collections WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
|
||||
[collectionId],
|
||||
);
|
||||
if (!collection) 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 collection_id = ? AND review_status != 'draft'`,
|
||||
[collectionId],
|
||||
);
|
||||
const workCount = Number(counts?.work_count ?? 0);
|
||||
const approvedCount = Number(counts?.approved_count ?? 0);
|
||||
|
||||
if (collection.status === 'archived') {
|
||||
return { status: 'archived', workCount, approvedCount, completedAt: collection.completed_at };
|
||||
}
|
||||
|
||||
const status = deriveCollectionStatus(workCount, approvedCount);
|
||||
const completedAt = status === 'completed'
|
||||
? collection.completed_at ?? new Date().toISOString()
|
||||
: null;
|
||||
await tx.execute('UPDATE collections SET status = ?, completed_at = ? WHERE id = ?', [status, completedAt, collectionId]);
|
||||
return { status, workCount, approvedCount, completedAt };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { imagesRepository } from '../repositories/imagesRepository.js';
|
||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||
import { database, withTransaction } from '../database.js';
|
||||
import { storeUploadedFile } from '../storage.js';
|
||||
import { recalculateCollectionStatus } from './collectionsService.js';
|
||||
|
||||
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
|
||||
|
||||
@@ -31,7 +32,7 @@ export const notesService = {
|
||||
if (requestedVersion && requestedVersion !== 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 }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name 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]);
|
||||
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 version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC', [id]);
|
||||
const result: NoteDetail = {
|
||||
@@ -42,7 +43,7 @@ export const notesService = {
|
||||
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.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 },
|
||||
collection: { id: Number(workContext.collection_id), name: workContext.collection_name, status: workContext.collection_status },
|
||||
};
|
||||
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
|
||||
return result;
|
||||
@@ -54,6 +55,7 @@ export const notesService = {
|
||||
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
||||
await imagesRepository.createMany(id, prepared, 1, tx);
|
||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
||||
await recalculateCollectionStatus(collectionId, tx);
|
||||
return id;
|
||||
});
|
||||
return (await notesRepository.findById(noteId))!;
|
||||
@@ -64,6 +66,7 @@ export const notesService = {
|
||||
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
||||
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx);
|
||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
||||
await recalculateCollectionStatus(collectionId, tx);
|
||||
return id;
|
||||
});
|
||||
return (await notesRepository.findById(noteId))!;
|
||||
@@ -83,6 +86,7 @@ export const notesService = {
|
||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
||||
await imagesRepository.createMany(id, prepared, nextVersion, 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')", [id, nextVersion, current.review_status, '工作台']);
|
||||
await recalculateCollectionStatus(current.collection_id, tx);
|
||||
});
|
||||
return (await notesRepository.findById(id))!;
|
||||
},
|
||||
@@ -96,10 +100,28 @@ export const notesService = {
|
||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
||||
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), nextVersion, 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')", [id, nextVersion, current.review_status, '工作台']);
|
||||
await recalculateCollectionStatus(current.collection_id, tx);
|
||||
});
|
||||
return (await notesRepository.findById(id))!;
|
||||
},
|
||||
|
||||
async remove(id: number) { return notesRepository.remove(id); },
|
||||
async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); },
|
||||
async remove(id: number) {
|
||||
return withTransaction(async (tx) => {
|
||||
const note = await tx.one<{ collection_id: number }>('SELECT collection_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);
|
||||
return removed;
|
||||
});
|
||||
},
|
||||
async setStatus(id: number, status: ReviewStatus) {
|
||||
return withTransaction(async (tx) => {
|
||||
const note = await tx.one<{ collection_id: number; version_number: number }>('SELECT collection_id, version_number FROM notes WHERE id = ?', [id]);
|
||||
if (!note) return false;
|
||||
await tx.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id]);
|
||||
await tx.execute('UPDATE work_versions SET review_status = ? WHERE note_id = ? AND version_number = ?', [status, id, note.version_number]);
|
||||
await recalculateCollectionStatus(Number(note.collection_id), tx);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user