feat: add inspection object resource workflow

This commit is contained in:
2026-08-13 16:51:48 +08:00
parent 3db84a7fb2
commit 29a69793db
11 changed files with 2312 additions and 935 deletions

View File

@@ -6,7 +6,9 @@ import {
users, InsertUser,
vehicleSeries, InsertVehicleSeries, VehicleSeries,
vehicleModels, InsertVehicleModel, VehicleModel,
inspectionObjects, InsertInspectionObject, InspectionObject,
modelCadFiles, InsertModelCadFile, ModelCadFile,
modelCadInspectionObjects,
modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile,
taskCadFiles, InsertTaskCadFile, TaskCadFile,
inspectionTasks, InsertInspectionTask, InspectionTask,
@@ -206,6 +208,192 @@ export async function deleteVehicleModel(id: number): Promise<void> {
await db.delete(vehicleModels).where(eq(vehicleModels.id, id));
}
// ─── 检测对象与默认输入方案 ──────────────────────────────────────────────────
export type InspectionObjectWithConfiguration = InspectionObject & {
defaultPointcloudName: string | null;
defaultCadName: string | null;
configurationState: "missing_pointcloud" | "missing_cad" | "ready_draft" | "active";
taskReferenceCount: number;
pointcloudReferenceCount: number;
cadReferenceCount: number;
canDelete: boolean;
};
export type InspectionObjectReferenceCounts = {
taskReferenceCount: number;
pointcloudReferenceCount: number;
cadReferenceCount: number;
};
export class InspectionObjectReferencedError extends Error {
constructor(public readonly references: InspectionObjectReferenceCounts) {
const parts = [
references.taskReferenceCount > 0 ? `任务 ${references.taskReferenceCount} 个` : null,
references.pointcloudReferenceCount > 0 ? `点云 ${references.pointcloudReferenceCount} 个` : null,
references.cadReferenceCount > 0 ? `CAD ${references.cadReferenceCount} 个` : null,
].filter(Boolean);
super(`检测对象仍被以下数据引用:${parts.join("、")}。请先处理关联数据后再删除。`);
this.name = "InspectionObjectReferencedError";
}
}
export function assertInspectionObjectDeletable(references: InspectionObjectReferenceCounts): void {
if (references.taskReferenceCount + references.pointcloudReferenceCount + references.cadReferenceCount > 0) {
throw new InspectionObjectReferencedError(references);
}
}
export async function listInspectionObjects(modelId: number, activeOnly = false): Promise<InspectionObjectWithConfiguration[]> {
const db = await getDb();
if (!db) return [];
const conditions = [eq(inspectionObjects.modelId, modelId)];
if (activeOnly) conditions.push(eq(inspectionObjects.status, "active"));
const rows = await db.select().from(inspectionObjects).where(and(...conditions)).orderBy(inspectionObjects.createdAt);
if (rows.length === 0) return [];
const objectIds = rows.map(row => row.id);
const pointclouds = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.modelId, modelId));
const cadFiles = await db.select().from(modelCadFiles).where(eq(modelCadFiles.modelId, modelId));
const taskReferences = await db.select({
inspectionObjectId: inspectionTasks.inspectionObjectId,
count: sql<number>`count(*)`,
}).from(inspectionTasks).where(inArray(inspectionTasks.inspectionObjectId, objectIds)).groupBy(inspectionTasks.inspectionObjectId);
const cadReferences = await db.select({
inspectionObjectId: modelCadInspectionObjects.inspectionObjectId,
count: sql<number>`count(*)`,
}).from(modelCadInspectionObjects).where(inArray(modelCadInspectionObjects.inspectionObjectId, objectIds)).groupBy(modelCadInspectionObjects.inspectionObjectId);
const pcById = new Map(pointclouds.map(row => [row.id, row]));
const cadById = new Map(cadFiles.map(row => [row.id, row]));
const taskCountByObjectId = new Map(taskReferences.map(row => [row.inspectionObjectId, Number(row.count)]));
const cadCountByObjectId = new Map(cadReferences.map(row => [row.inspectionObjectId, Number(row.count)]));
const pointcloudCountByObjectId = new Map<number, number>();
for (const pointcloud of pointclouds) {
if (pointcloud.inspectionObjectId != null) {
pointcloudCountByObjectId.set(pointcloud.inspectionObjectId, (pointcloudCountByObjectId.get(pointcloud.inspectionObjectId) ?? 0) + 1);
}
}
return rows.map(row => {
const pc = row.defaultModelPointcloudId ? pcById.get(row.defaultModelPointcloudId) : undefined;
const cad = row.defaultModelCadFileId ? cadById.get(row.defaultModelCadFileId) : undefined;
const hasPc = pc?.inspectionObjectId === row.id && pc.status === "available";
const hasCad = cad?.status === "available";
const taskReferenceCount = taskCountByObjectId.get(row.id) ?? 0;
const pointcloudReferenceCount = pointcloudCountByObjectId.get(row.id) ?? 0;
const cadReferenceCount = cadCountByObjectId.get(row.id) ?? 0;
return {
...row,
defaultPointcloudName: pc?.name ?? null,
defaultCadName: cad?.name ?? null,
configurationState: !hasPc ? "missing_pointcloud" : !hasCad ? "missing_cad" : row.status === "active" ? "active" : "ready_draft",
taskReferenceCount,
pointcloudReferenceCount,
cadReferenceCount,
canDelete: taskReferenceCount + pointcloudReferenceCount + cadReferenceCount === 0,
};
});
}
export async function getInspectionObjectById(id: number): Promise<InspectionObject | undefined> {
const db = await getDb();
if (!db) return undefined;
const [row] = await db.select().from(inspectionObjects).where(eq(inspectionObjects.id, id));
return row;
}
export async function createInspectionObject(data: InsertInspectionObject): Promise<InspectionObject> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(inspectionObjects).values(data);
return (await getInspectionObjectById(getMysqlInsertId(result)))!;
}
export async function updateInspectionObject(id: number, data: Partial<InsertInspectionObject>): Promise<InspectionObject> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(inspectionObjects).set(data).where(eq(inspectionObjects.id, id));
return (await getInspectionObjectById(id))!;
}
export async function deleteInspectionObject(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async tx => {
const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.id, id)).limit(1).for("update");
if (!object) throw new Error("检测对象不存在");
const taskRows = await tx.select({ id: inspectionTasks.id }).from(inspectionTasks)
.where(eq(inspectionTasks.inspectionObjectId, id)).for("update");
const pointcloudRows = await tx.select({ id: modelPointcloudFiles.id }).from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.inspectionObjectId, id)).for("update");
const cadRows = await tx.select({ cadFileId: modelCadInspectionObjects.cadFileId }).from(modelCadInspectionObjects)
.where(eq(modelCadInspectionObjects.inspectionObjectId, id)).for("update");
assertInspectionObjectDeletable({
taskReferenceCount: taskRows.length,
pointcloudReferenceCount: pointcloudRows.length,
cadReferenceCount: cadRows.length,
});
await tx.delete(inspectionObjects).where(eq(inspectionObjects.id, id));
});
}
export async function cadAppliesToInspectionObject(cadFileId: number, inspectionObjectId: number): Promise<boolean> {
const db = await getDb();
if (!db) return false;
const [row] = await db.select().from(modelCadInspectionObjects).where(and(
eq(modelCadInspectionObjects.cadFileId, cadFileId),
eq(modelCadInspectionObjects.inspectionObjectId, inspectionObjectId),
)).limit(1);
return Boolean(row);
}
export async function setCadInspectionObjects(cadFileId: number, inspectionObjectIds: number[]): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async tx => {
const lockedObjects = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(inArray(inspectionObjects.id, inspectionObjectIds)).for("share");
if (lockedObjects.length !== new Set(inspectionObjectIds).size) throw new Error("Inspection object not found");
await tx.delete(modelCadInspectionObjects).where(eq(modelCadInspectionObjects.cadFileId, cadFileId));
if (inspectionObjectIds.length > 0) {
await tx.insert(modelCadInspectionObjects).values(inspectionObjectIds.map(inspectionObjectId => ({ cadFileId, inspectionObjectId })));
}
});
}
export async function listCadInspectionObjectIds(cadFileIds: number[]): Promise<Record<number, number[]>> {
const db = await getDb();
if (!db || cadFileIds.length === 0) return {};
const rows = await db.select().from(modelCadInspectionObjects).where(inArray(modelCadInspectionObjects.cadFileId, cadFileIds));
return rows.reduce<Record<number, number[]>>((result, row) => {
(result[row.cadFileId] ??= []).push(row.inspectionObjectId);
return result;
}, {});
}
export async function configureInspectionObject(input: {
id: number;
defaultModelPointcloudId: number;
defaultModelCadFileId: number;
active: boolean;
}): Promise<InspectionObject> {
const object = await getInspectionObjectById(input.id);
const pc = await getModelPointcloudFileById(input.defaultModelPointcloudId);
const cad = await getModelCadFileById(input.defaultModelCadFileId);
if (!object || !pc || !cad) throw new Error("Configuration resource not found");
if (pc.modelId !== object.modelId || pc.inspectionObjectId !== object.id || pc.status !== "available") {
throw new Error("Pointcloud is incompatible with inspection object");
}
if (cad.modelId !== object.modelId || cad.status !== "available" || !await cadAppliesToInspectionObject(cad.id, object.id)) {
throw new Error("CAD is incompatible with inspection object");
}
return updateInspectionObject(object.id, {
defaultModelPointcloudId: pc.id,
defaultModelCadFileId: cad.id,
status: input.active ? "active" : "draft",
});
}
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
export async function createModelCadFile(data: InsertModelCadFile): Promise<ModelCadFile> {
@@ -224,6 +412,18 @@ export async function listModelCadFiles(modelId: number): Promise<ModelCadFile[]
.orderBy(desc(modelCadFiles.createdAt));
}
export async function listModelCadFilesForInspectionObject(modelId: number, inspectionObjectId: number): Promise<ModelCadFile[]> {
const db = await getDb();
if (!db) return [];
return db.select({
id: modelCadFiles.id, modelId: modelCadFiles.modelId, name: modelCadFiles.name,
format: modelCadFiles.format, fileSize: modelCadFiles.fileSize, filePath: modelCadFiles.filePath,
contentScope: modelCadFiles.contentScope, status: modelCadFiles.status, createdAt: modelCadFiles.createdAt,
}).from(modelCadFiles).innerJoin(modelCadInspectionObjects, eq(modelCadInspectionObjects.cadFileId, modelCadFiles.id))
.where(and(eq(modelCadFiles.modelId, modelId), eq(modelCadFiles.status, "available"), eq(modelCadInspectionObjects.inspectionObjectId, inspectionObjectId)))
.orderBy(desc(modelCadFiles.createdAt));
}
export async function getModelCadFileById(id: number): Promise<ModelCadFile | undefined> {
const db = await getDb();
if (!db) return undefined;
@@ -234,6 +434,16 @@ export async function getModelCadFileById(id: number): Promise<ModelCadFile | un
export async function deleteModelCadFile(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
const [defaultUse] = await db.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.defaultModelCadFileId, id)).limit(1);
if (defaultUse) throw new Error("Resource is used as default input");
const [historicalUse] = await db.select({ id: taskCadFiles.id }).from(taskCadFiles)
.where(eq(taskCadFiles.sourceModelCadFileId, id)).limit(1);
if (historicalUse) {
await db.update(modelCadFiles).set({ status: "disabled" }).where(eq(modelCadFiles.id, id));
return;
}
await db.delete(modelCadInspectionObjects).where(eq(modelCadInspectionObjects.cadFileId, id));
await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id));
}
@@ -242,9 +452,16 @@ export async function deleteModelCadFile(id: number): Promise<void> {
export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise<ModelPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(modelPointcloudFiles).values(data);
const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result)));
return row;
return db.transaction(async tx => {
if (data.inspectionObjectId != null) {
const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.id, data.inspectionObjectId)).limit(1).for("share");
if (!object) throw new Error("Inspection object not found");
}
const result = await tx.insert(modelPointcloudFiles).values(data);
const [row] = await tx.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result)));
return row;
});
}
const modelMetadataBackfills = new Map<number, Promise<ModelPointcloudFile>>();
@@ -283,6 +500,26 @@ export async function listModelPointcloudFiles(modelId: number): Promise<ModelPo
return Promise.all(rows.map(backfillModelPointcloudMetadata));
}
export async function listModelPointcloudFilesForInspectionObject(modelId: number, inspectionObjectId: number): Promise<ModelPointcloudFile[]> {
const rows = await listModelPointcloudFiles(modelId);
return rows.filter(row => row.inspectionObjectId === inspectionObjectId && row.status === "available");
}
export async function assignModelPointcloudInspectionObject(id: number, inspectionObjectId: number): Promise<ModelPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async tx => {
const [object] = await tx.select().from(inspectionObjects)
.where(eq(inspectionObjects.id, inspectionObjectId)).limit(1).for("share");
const [file] = await tx.select().from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.id, id)).limit(1).for("update");
if (!file || !object || file.modelId !== object.modelId) throw new Error("Inspection object does not belong to file model");
await tx.update(modelPointcloudFiles).set({ inspectionObjectId }).where(eq(modelPointcloudFiles.id, id));
const [updated] = await tx.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
return updated;
});
}
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
@@ -323,6 +560,15 @@ export async function updateModelPointcloudFileName(
export async function deleteModelPointcloudFile(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
const [defaultUse] = await db.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.defaultModelPointcloudId, id)).limit(1);
if (defaultUse) throw new Error("Resource is used as default input");
const [historicalUse] = await db.select({ id: taskPointcloudFiles.id }).from(taskPointcloudFiles)
.where(eq(taskPointcloudFiles.sourceModelPointcloudId, id)).limit(1);
if (historicalUse) {
await db.update(modelPointcloudFiles).set({ status: "disabled" }).where(eq(modelPointcloudFiles.id, id));
return;
}
await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
}
@@ -380,6 +626,9 @@ export async function linkTaskCadFilesFromModel(taskId: number, modelCadFileIds:
if (source.modelId !== task.modelId) {
throw new Error("Cannot link CAD file from another model");
}
if (!task.inspectionObjectId || source.status !== "available" || !await cadAppliesToInspectionObject(source.id, task.inspectionObjectId)) {
throw new Error("Cannot link CAD file from another inspection object");
}
const record = await createTaskCadFile({
taskId,
@@ -407,6 +656,49 @@ export async function createInspectionTask(data: InsertInspectionTask): Promise<
return row;
}
export async function createInspectionTaskWithDefaults(data: InsertInspectionTask & { inspectionObjectId: number }): Promise<{
task: InspectionTask;
pointcloudFile: TaskPointcloudFile;
cadFile: TaskCadFile;
}> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async tx => {
const [object] = await tx.select().from(inspectionObjects)
.where(eq(inspectionObjects.id, data.inspectionObjectId)).limit(1).for("share");
if (!object || object.modelId !== data.modelId || object.status !== "active") throw new Error("Inspection object is not active for model");
if (!object.defaultModelPointcloudId || !object.defaultModelCadFileId) throw new Error("Default input scheme is incomplete");
const [pointcloud] = await tx.select().from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.id, object.defaultModelPointcloudId)).limit(1);
const [cad] = await tx.select().from(modelCadFiles)
.where(eq(modelCadFiles.id, object.defaultModelCadFileId)).limit(1);
const [cadApplication] = await tx.select({ cadFileId: modelCadInspectionObjects.cadFileId }).from(modelCadInspectionObjects).where(and(
eq(modelCadInspectionObjects.cadFileId, object.defaultModelCadFileId),
eq(modelCadInspectionObjects.inspectionObjectId, object.id),
)).limit(1);
if (!pointcloud || pointcloud.inspectionObjectId !== object.id || pointcloud.status !== "available") throw new Error("Default pointcloud is invalid");
if (!cad || cad.modelId !== object.modelId || cad.status !== "available" || !cadApplication) throw new Error("Default CAD is invalid");
const taskInsert = await tx.insert(inspectionTasks).values(data);
const taskId = getMysqlInsertId(taskInsert);
const pointcloudInsert = await tx.insert(taskPointcloudFiles).values({
taskId, name: pointcloud.name, fileSize: pointcloud.fileSize, filePath: pointcloud.filePath,
source: "model_library", sourceModelPointcloudId: pointcloud.id,
metadataStatus: pointcloud.metadataStatus, pointCount: pointcloud.pointCount,
pointAttributes: pointcloud.pointAttributes, pointDataFormat: pointcloud.pointDataFormat,
bytesPerPoint: pointcloud.bytesPerPoint, metadataError: pointcloud.metadataError,
});
const cadInsert = await tx.insert(taskCadFiles).values({
taskId, name: cad.name, format: cad.format, fileSize: cad.fileSize, filePath: cad.filePath,
source: "model_library", sourceModelCadFileId: cad.id,
});
const [task] = await tx.select().from(inspectionTasks).where(eq(inspectionTasks.id, taskId));
const [pointcloudFile] = await tx.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, getMysqlInsertId(pointcloudInsert)));
const [cadFile] = await tx.select().from(taskCadFiles).where(eq(taskCadFiles.id, getMysqlInsertId(cadInsert)));
return { task, pointcloudFile, cadFile };
});
}
export async function findInspectionTaskByModelAndName(modelId: number, name: string): Promise<InspectionTask | undefined> {
const db = await getDb();
if (!db) return undefined;
@@ -482,6 +774,17 @@ export async function updateInspectionTask(id: number, data: Partial<InsertInspe
await db.update(inspectionTasks).set(data).where(eq(inspectionTasks.id, id));
}
export async function classifyInspectionTask(id: number, inspectionObjectId: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async tx => {
const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.id, inspectionObjectId)).limit(1).for("share");
if (!object) throw new Error("Inspection object not found");
await tx.update(inspectionTasks).set({ inspectionObjectId }).where(eq(inspectionTasks.id, id));
});
}
export async function deleteInspectionTask(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
@@ -631,6 +934,9 @@ export async function linkTaskPointcloudFilesFromModel(
if (source.modelId !== task.modelId) {
throw new Error("Cannot link pointcloud file from another model");
}
if (!task.inspectionObjectId || source.inspectionObjectId !== task.inspectionObjectId || source.status !== "available") {
throw new Error("Cannot link pointcloud file from another inspection object");
}
const record = await createTaskPointcloudFile({
taskId,

View File

@@ -97,8 +97,14 @@ function safeResolve(base: string, filename: string): string | null {
router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
const inspectionObjectIds = String(req.body.inspectionObjectIds ?? '').split(',').map(Number).filter(Number.isFinite);
if (!req.file || isNaN(modelId) || inspectionObjectIds.length === 0) {
return res.status(400).json({ success: false, error: '上传车型 CAD 时必须选择适用检测对象' });
}
const objects = await Promise.all(inspectionObjectIds.map(id => db.getInspectionObjectById(id)));
if (objects.some(object => !object || object.modelId !== modelId)) {
await fs.promises.rm(req.file.path, { force: true });
return res.status(400).json({ success: false, error: '适用检测对象必须属于当前车型' });
}
const destPath = req.file.path;
@@ -110,6 +116,7 @@ router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'),
fileSize: req.file.size,
filePath: destPath,
});
await db.setCadInspectionObjects(record.id, inspectionObjectIds);
res.json({
success: true,
@@ -129,8 +136,14 @@ router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'),
router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
const inspectionObjectId = Number(req.body.inspectionObjectId);
if (!req.file || isNaN(modelId) || !Number.isFinite(inspectionObjectId)) {
return res.status(400).json({ success: false, error: '上传车型点云时必须选择检测对象' });
}
const object = await db.getInspectionObjectById(inspectionObjectId);
if (!object || object.modelId !== modelId) {
await fs.promises.rm(req.file.path, { force: true });
return res.status(400).json({ success: false, error: '检测对象必须属于当前车型' });
}
const destPath = req.file.path;
@@ -140,6 +153,7 @@ router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.si
name: req.file.originalname,
fileSize: req.file.size,
filePath: destPath,
inspectionObjectId,
...metadata,
});

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { appRouter } from "./routers";
import * as db from "./db";
import type { TrpcContext } from "./_core/context";
vi.mock("./db", () => {
class InspectionObjectReferencedError extends Error {
constructor(public readonly references: { taskReferenceCount: number; pointcloudReferenceCount: number; cadReferenceCount: number }) {
const parts = [
references.taskReferenceCount > 0 ? `任务 ${references.taskReferenceCount} 个` : null,
references.pointcloudReferenceCount > 0 ? `点云 ${references.pointcloudReferenceCount} 个` : null,
references.cadReferenceCount > 0 ? `CAD ${references.cadReferenceCount} 个` : null,
].filter(Boolean);
super(`检测对象仍被以下数据引用:${parts.join("、")}。请先处理关联数据后再删除。`);
}
}
return {
deleteInspectionObject: vi.fn(async () => undefined),
InspectionObjectReferencedError,
};
});
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createContext(role: AuthenticatedUser["role"]): TrpcContext {
return {
user: {
id: 1, username: "test-user", passwordHash: null, email: "test@example.com", name: "Test User",
role, isActive: 1, mustChangePassword: 0, sessionVersion: 0,
createdAt: new Date(), updatedAt: new Date(), lastLoginAt: new Date(),
},
req: { protocol: "https", headers: {} } as TrpcContext["req"],
res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
};
}
describe("inspection object delete router", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.deleteInspectionObject).mockResolvedValue(undefined);
});
it("allows an administrator to delete an empty object", async () => {
await expect(appRouter.createCaller(createContext("admin")).inspectionObject.delete({ id: 7 }))
.resolves.toEqual({ success: true });
expect(db.deleteInspectionObject).toHaveBeenCalledWith(7);
});
it.each([
[{ taskReferenceCount: 2, pointcloudReferenceCount: 0, cadReferenceCount: 0 }, "任务 2 个"],
[{ taskReferenceCount: 0, pointcloudReferenceCount: 3, cadReferenceCount: 0 }, "点云 3 个"],
[{ taskReferenceCount: 0, pointcloudReferenceCount: 0, cadReferenceCount: 4 }, "CAD 4 个"],
])("rejects a single reference type with its count", async (references, message) => {
vi.mocked(db.deleteInspectionObject).mockRejectedValueOnce(new db.InspectionObjectReferencedError(references));
await expect(appRouter.createCaller(createContext("admin")).inspectionObject.delete({ id: 7 }))
.rejects.toMatchObject({ code: "PRECONDITION_FAILED", message: expect.stringContaining(message) });
});
it("reports all reference types when a concurrent reference is observed by the transaction", async () => {
vi.mocked(db.deleteInspectionObject).mockRejectedValueOnce(new db.InspectionObjectReferencedError({
taskReferenceCount: 1, pointcloudReferenceCount: 2, cadReferenceCount: 3,
}));
await expect(appRouter.createCaller(createContext("admin")).inspectionObject.delete({ id: 7 }))
.rejects.toMatchObject({
code: "PRECONDITION_FAILED",
message: "检测对象仍被以下数据引用:任务 1 个、点云 2 个、CAD 3 个。请先处理关联数据后再删除。",
});
});
it("forbids a non-administrator from deleting", async () => {
await expect(appRouter.createCaller(createContext("user")).inspectionObject.delete({ id: 7 }))
.rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.deleteInspectionObject).not.toHaveBeenCalled();
});
});

View File

@@ -5,16 +5,10 @@ import type { TrpcContext } from "./_core/context";
vi.mock("./db", () => ({
findInspectionTaskByModelAndName: vi.fn(async () => undefined),
createInspectionTask: vi.fn(async (data) => ({
id: 1,
modelId: data.modelId,
name: data.name,
inspector: data.inspector ?? null,
notes: data.notes ?? null,
status: data.status,
createdAt: new Date(),
updatedAt: new Date(),
completedAt: null,
createInspectionTaskWithDefaults: vi.fn(async (data) => ({
task: { id: 1, modelId: data.modelId, inspectionObjectId: data.inspectionObjectId, name: data.name, inspector: data.inspector ?? null, notes: data.notes ?? null, status: data.status, createdAt: new Date(), updatedAt: new Date(), completedAt: null },
pointcloudFile: { id: 11, taskId: 1 },
cadFile: { id: 12, taskId: 1 },
})),
}));
@@ -60,13 +54,15 @@ describe("inspection task router", () => {
const result = await caller.inspectionTask.create({
modelId: 1,
inspectionObjectId: 9,
name: " 首检任务 ",
});
expect(result.status).toBe("created");
expect(result.task.status).toBe("created");
expect(db.findInspectionTaskByModelAndName).toHaveBeenCalledWith(1, "首检任务");
expect(db.createInspectionTask).toHaveBeenCalledWith({
expect(db.createInspectionTaskWithDefaults).toHaveBeenCalledWith({
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
inspector: null,
notes: null,
@@ -78,6 +74,7 @@ describe("inspection task router", () => {
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValueOnce({
id: 1,
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
inspector: null,
notes: null,
@@ -92,17 +89,18 @@ describe("inspection task router", () => {
await expect(caller.inspectionTask.create({
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
})).rejects.toMatchObject({
code: "CONFLICT",
message: "该车型下已存在同名检测任务。",
});
expect(db.createInspectionTask).not.toHaveBeenCalled();
expect(db.createInspectionTaskWithDefaults).not.toHaveBeenCalled();
});
it("returns CONFLICT when the database unique key rejects a concurrent duplicate", async () => {
vi.mocked(db.createInspectionTask).mockRejectedValueOnce({
vi.mocked(db.createInspectionTaskWithDefaults).mockRejectedValueOnce({
code: "ER_DUP_ENTRY",
errno: 1062,
message: "Duplicate entry '1-首检任务' for key 'uk_inspection_tasks_model_name'",
@@ -113,6 +111,7 @@ describe("inspection task router", () => {
await expect(caller.inspectionTask.create({
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
})).rejects.toMatchObject({
code: "CONFLICT",

View File

@@ -91,6 +91,7 @@ async function validateRegistrationRunInput(input: {
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
await validateTaskInputCompatibility(input.taskId, pointcloud, cad);
const registeredChildren = pointcloudFiles
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id)
@@ -104,6 +105,29 @@ async function validateRegistrationRunInput(input: {
}
}
async function validateTaskInputCompatibility(
taskId: number,
pointcloud: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>[number],
cad: Awaited<ReturnType<typeof db.listTaskCadFiles>>[number],
) {
const task = await db.getInspectionTaskById(taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
if (!task.inspectionObjectId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "历史任务尚未确认检测对象,请联系管理员完成归类后再运行算法。" });
}
if (pointcloud.source === "model_library" && pointcloud.sourceModelPointcloudId) {
const source = await db.getModelPointcloudFileById(pointcloud.sourceModelPointcloudId);
if (!source || source.inspectionObjectId !== task.inspectionObjectId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云与当前检测对象不兼容。" });
}
}
if (cad.source === "model_library" && cad.sourceModelCadFileId) {
if (!await db.cadAppliesToInspectionObject(cad.sourceModelCadFileId, task.inspectionObjectId)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "CAD 与当前检测对象不兼容。" });
}
}
}
function latestRegisteredPointcloud(
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
sourcePointcloudFileId: number,
@@ -581,16 +605,88 @@ export const appRouter = router({
}),
}),
// ─── 检测对象配置 ─────────────────────────────────────────────────────────
inspectionObject: router({
list: protectedProcedure
.input(z.object({ modelId: z.number(), activeOnly: z.boolean().optional() }))
.query(({ input }) => db.listInspectionObjects(input.modelId, input.activeOnly ?? false)),
create: adminProcedure
.input(z.object({ modelId: z.number(), name: z.string().trim().min(1).max(100), description: z.string().optional() }))
.mutation(({ input }) => db.createInspectionObject({ ...input, description: input.description ?? null, status: "draft" })),
update: adminProcedure
.input(z.object({ id: z.number(), name: z.string().trim().min(1).max(100).optional(), description: z.string().optional() }))
.mutation(({ input }) => {
const { id, ...data } = input;
return db.updateInspectionObject(id, data);
}),
configureDefaults: adminProcedure
.input(z.object({ id: z.number(), defaultModelPointcloudId: z.number(), defaultModelCadFileId: z.number(), active: z.boolean() }))
.mutation(async ({ input }) => {
try { return await db.configureInspectionObject(input); }
catch (error) { throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "默认输入方案无效" }); }
}),
setStatus: adminProcedure
.input(z.object({ id: z.number(), status: z.enum(["draft", "active"]) }))
.mutation(async ({ input }) => {
if (input.status === "active") {
const object = await db.getInspectionObjectById(input.id);
if (!object?.defaultModelPointcloudId || !object.defaultModelCadFileId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "缺少预置点云或默认 CAD,不能启用检测对象" });
}
return db.configureInspectionObject({
id: object.id,
defaultModelPointcloudId: object.defaultModelPointcloudId,
defaultModelCadFileId: object.defaultModelCadFileId,
active: true,
});
}
return db.updateInspectionObject(input.id, { status: "draft" });
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
try {
await db.deleteInspectionObject(input.id);
return { success: true };
} catch (error) {
if (error instanceof db.InspectionObjectReferencedError) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: error.message });
}
if (error instanceof Error && error.message === "检测对象不存在") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
throw error;
}
}),
}),
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────
cadFile: router({
list: protectedProcedure
.input(z.object({ modelId: z.number() }))
.query(async ({ input }) => {
return db.listModelCadFiles(input.modelId);
const files = await db.listModelCadFiles(input.modelId);
const objectIds = await db.listCadInspectionObjectIds(files.map(file => file.id));
return files.map(file => ({ ...file, inspectionObjectIds: objectIds[file.id] ?? [] }));
}),
delete: protectedProcedure
listForInspectionObject: protectedProcedure
.input(z.object({ modelId: z.number(), inspectionObjectId: z.number() }))
.query(({ input }) => db.listModelCadFilesForInspectionObject(input.modelId, input.inspectionObjectId)),
setInspectionObjects: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectIds: z.array(z.number()).min(1) }))
.mutation(async ({ input }) => {
const cad = await db.getModelCadFileById(input.id);
if (!cad) throw new TRPCError({ code: "NOT_FOUND", message: "CAD 文件不存在" });
const objects = await Promise.all(input.inspectionObjectIds.map(id => db.getInspectionObjectById(id)));
if (objects.some(object => !object || object.modelId !== cad.modelId)) throw new TRPCError({ code: "BAD_REQUEST", message: "适用检测对象必须属于 CAD 当前车型" });
await db.setCadInspectionObjects(input.id, input.inspectionObjectIds);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteModelCadFile(input.id);
@@ -623,6 +719,14 @@ export const appRouter = router({
return db.listModelPointcloudFiles(input.modelId);
}),
listForInspectionObject: protectedProcedure
.input(z.object({ modelId: z.number(), inspectionObjectId: z.number() }))
.query(({ input }) => db.listModelPointcloudFilesForInspectionObject(input.modelId, input.inspectionObjectId)),
assignInspectionObject: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectId: z.number() }))
.mutation(({ input }) => db.assignModelPointcloudInspectionObject(input.id, input.inspectionObjectId)),
rename: adminProcedure
.input(z.object({ id: z.number(), baseName: z.string() }))
.mutation(async ({ input }) => {
@@ -640,7 +744,7 @@ export const appRouter = router({
return db.updateModelPointcloudFileName(row.id, name);
}),
delete: protectedProcedure
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteModelPointcloudFile(input.id);
@@ -720,9 +824,23 @@ export const appRouter = router({
return db.getInspectionTaskById(input.id);
}),
classify: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectId: z.number() }))
.mutation(async ({ input }) => {
const task = await db.getInspectionTaskById(input.id);
const object = await db.getInspectionObjectById(input.inspectionObjectId);
if (!task || !object) throw new TRPCError({ code: "NOT_FOUND", message: "任务或检测对象不存在" });
if (task.modelId !== object.modelId || object.status !== "active") {
throw new TRPCError({ code: "BAD_REQUEST", message: "只能确认当前车型下已启用的检测对象" });
}
await db.classifyInspectionTask(task.id, object.id);
return { success: true };
}),
create: protectedProcedure
.input(z.object({
modelId: z.number(),
inspectionObjectId: z.number(),
name: z.string().trim().min(1).max(255),
inspector: z.string().max(100).optional(),
notes: z.string().optional(),
@@ -734,8 +852,9 @@ export const appRouter = router({
}
try {
return await db.createInspectionTask({
return await db.createInspectionTaskWithDefaults({
modelId: input.modelId,
inspectionObjectId: input.inspectionObjectId,
name: input.name,
inspector: input.inspector ?? null,
notes: input.notes ?? null,
@@ -1212,6 +1331,7 @@ export const appRouter = router({
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad);
const registration = await createAndEnqueuePluginJob({
taskId: input.taskId,