Compare commits
2 Commits
66c7dd1b65
...
29a69793db
| Author | SHA1 | Date | |
|---|---|---|---|
| 29a69793db | |||
| 3db84a7fb2 |
@@ -2,8 +2,8 @@ import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAuth } from '@/_core/hooks/useAuth';
|
||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
||||
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, statusClass, statusLabel, type Overlay3D } from './GenericResultView';
|
||||
import { aggregateDemoFlowResults, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
||||
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, statusClass, type Overlay3D } from './GenericResultView';
|
||||
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
@@ -937,7 +937,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
</td>}
|
||||
<td className="px-2 text-right whitespace-nowrap">
|
||||
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(row.measurement.status)}`}>
|
||||
{row.measurement.status === 'info' ? '参考' : statusLabel(row.measurement)}
|
||||
{demoFlowStatusLabel(row.measurement)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
176
client/src/components/InspectionObjectConfig.tsx
Normal file
176
client/src/components/InspectionObjectConfig.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, CircleAlert, Plus, Settings2, Trash2 } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from './ui/alert-dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Props = { modelId: number };
|
||||
|
||||
const stateLabels = {
|
||||
missing_pointcloud: '未配置点云',
|
||||
missing_cad: '未配置 CAD',
|
||||
ready_draft: '配置完整,未启用',
|
||||
active: '已启用',
|
||||
} as const;
|
||||
|
||||
type ReferenceCounts = {
|
||||
taskReferenceCount: number;
|
||||
pointcloudReferenceCount: number;
|
||||
cadReferenceCount: number;
|
||||
};
|
||||
|
||||
function referenceReason(object: ReferenceCounts) {
|
||||
return [
|
||||
object.taskReferenceCount > 0 ? `${object.taskReferenceCount} 个任务` : null,
|
||||
object.pointcloudReferenceCount > 0 ? `${object.pointcloudReferenceCount} 个点云` : null,
|
||||
object.cadReferenceCount > 0 ? `${object.cadReferenceCount} 个 CAD 关联` : null,
|
||||
].filter(Boolean).join('、');
|
||||
}
|
||||
|
||||
export default function InspectionObjectConfig({ modelId }: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [pointcloudId, setPointcloudId] = useState<number | null>(null);
|
||||
const [cadId, setCadId] = useState<number | null>(null);
|
||||
const { data: objects = [] } = trpc.inspectionObject.list.useQuery({ modelId }, { enabled: open });
|
||||
const { data: pointclouds = [] } = trpc.modelPointcloudFile.list.useQuery({ modelId }, { enabled: open });
|
||||
const { data: cadFiles = [] } = trpc.cadFile.list.useQuery({ modelId }, { enabled: open });
|
||||
const selected = useMemo(() => objects.find(object => object.id === selectedId), [objects, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && objects.length > 0) setSelectedId(objects[0].id);
|
||||
}, [objects, selected]);
|
||||
useEffect(() => {
|
||||
setPointcloudId(selected?.defaultModelPointcloudId ?? null);
|
||||
setCadId(selected?.defaultModelCadFileId ?? null);
|
||||
}, [selected]);
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
utils.inspectionObject.list.invalidate({ modelId }),
|
||||
utils.modelPointcloudFile.list.invalidate({ modelId }),
|
||||
utils.cadFile.list.invalidate({ modelId }),
|
||||
]);
|
||||
};
|
||||
const create = trpc.inspectionObject.create.useMutation({
|
||||
onSuccess: async object => { setName(''); setSelectedId(object.id); await invalidate(); toast.success('检测对象已创建'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const assignPointcloud = trpc.modelPointcloudFile.assignInspectionObject.useMutation({
|
||||
onSuccess: async (_file, variables) => {
|
||||
await invalidate();
|
||||
const objectName = objects.find(object => object.id === variables.inspectionObjectId)?.name;
|
||||
toast.success(objectName ? `点云已归属到“${objectName}”` : '点云归属已更新');
|
||||
},
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const setCadObjects = trpc.cadFile.setInspectionObjects.useMutation({
|
||||
onSuccess: async () => { await invalidate(); toast.success('CAD 适用对象已更新'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const configure = trpc.inspectionObject.configureDefaults.useMutation({
|
||||
onSuccess: async () => { await invalidate(); toast.success('默认输入方案已保存并启用'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const setStatus = trpc.inspectionObject.setStatus.useMutation({
|
||||
onSuccess: async () => { await invalidate(); toast.success('检测对象已停用'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const deleteObject = trpc.inspectionObject.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
setSelectedId(null);
|
||||
await invalidate();
|
||||
toast.success('检测对象已删除');
|
||||
},
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
|
||||
const compatiblePointclouds = pointclouds.filter(file => file.inspectionObjectId === selectedId && file.status === 'available');
|
||||
const compatibleCadFiles = cadFiles.filter(file => file.inspectionObjectIds.includes(selectedId ?? -1) && file.status === 'available');
|
||||
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button className="industrial-btn flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-0.5 text-[10px]"><Settings2 className="h-3 w-3" /> 检测对象配置</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[92vh] max-w-[calc(100vw-1rem)] flex-col overflow-hidden p-4 sm:max-w-4xl sm:p-6">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>检测对象配置</DialogTitle>
|
||||
<DialogDescription>资源归属是当前车型的统一设置;只有默认输入方案会随所选检测对象变化,并且仅影响以后创建的任务。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="objects" className="min-h-0 flex-1 overflow-hidden">
|
||||
<TabsList className="grid w-full shrink-0 grid-cols-2 sm:w-80">
|
||||
<TabsTrigger value="objects">对象配置</TabsTrigger>
|
||||
<TabsTrigger value="resources">资源归属</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="objects" className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-[15rem_minmax(0,1fr)]">
|
||||
<aside className="min-w-0 space-y-3 rounded-lg border bg-secondary/20 p-3">
|
||||
<div className="flex min-w-0 gap-1">
|
||||
<input aria-label="新检测对象名称" className="min-w-0 flex-1 rounded border bg-background px-2 py-1.5 text-xs" placeholder="如:头车" value={name} onChange={event => setName(event.target.value)} />
|
||||
<Button aria-label="创建检测对象" size="icon" className="h-7 w-7 shrink-0" disabled={!name.trim() || create.isPending} onClick={() => create.mutate({ modelId, name: name.trim() })}><Plus className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto md:max-h-[25rem]">
|
||||
{objects.map(object => <button key={object.id} onClick={() => setSelectedId(object.id)} className={`w-full rounded border px-2 py-2 text-left text-xs ${selectedId === object.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-secondary'}`}>
|
||||
<span className="flex items-center justify-between gap-2"><strong className="min-w-0 truncate" title={object.name}>{object.name}</strong>{object.configurationState === 'active' ? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0 text-amber-400" />}</span>
|
||||
<span className="mt-1 block text-[10px] text-muted-foreground">{stateLabels[object.configurationState]}</span>
|
||||
</button>)}
|
||||
{objects.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">尚未创建检测对象</p>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{selected ? <main className="min-w-0 space-y-4">
|
||||
<section className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0"><h3 className="truncate text-sm font-medium" title={selected.name}>{selected.name}</h3><p className="text-xs text-muted-foreground">{stateLabels[selected.configurationState]}</p></div>
|
||||
<span className={`rounded-full px-2 py-1 text-[10px] ${selected.status === 'active' ? 'bg-green-500/15 text-green-400' : 'bg-amber-500/15 text-amber-400'}`}>{selected.status === 'active' ? '已启用' : '草稿'}</span>
|
||||
</div>
|
||||
<div><h3 className="text-sm font-medium">默认输入方案</h3><p className="text-xs text-muted-foreground">新建“{selected.name}”任务时自动关联并勾选以下资源。</p></div>
|
||||
<label className="grid min-w-0 gap-1 text-xs"><span>预置点云</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={pointcloudId ?? ''} onChange={event => setPointcloudId(Number(event.target.value) || null)}><option value="">请选择</option>{compatiblePointclouds.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
|
||||
<label className="grid min-w-0 gap-1 text-xs"><span>默认 CAD</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={cadId ?? ''} onChange={event => setCadId(Number(event.target.value) || null)}><option value="">请选择</option>{compatibleCadFiles.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
|
||||
<div className="flex flex-wrap gap-2"><Button size="sm" disabled={!pointcloudId || !cadId || configure.isPending} onClick={() => configure.mutate({ id: selected.id, defaultModelPointcloudId: pointcloudId!, defaultModelCadFileId: cadId!, active: true })}>保存并启用</Button><Button size="sm" variant="outline" disabled={selected.status === 'draft' || setStatus.isPending} onClick={() => setStatus.mutate({ id: selected.id, status: 'draft' })}>停用</Button></div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 rounded-lg border border-destructive/30 p-4">
|
||||
<div><h3 className="text-sm font-medium text-destructive">删除检测对象</h3><p className="text-xs text-muted-foreground">仅可删除没有任务、点云和 CAD 关联的空对象,不会自动解绑或迁移历史数据。</p></div>
|
||||
{selected.canDelete ? <AlertDialog>
|
||||
<AlertDialogTrigger asChild><Button size="sm" variant="destructive"><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>确认删除“{selected.name}”?</AlertDialogTitle><AlertDialogDescription>删除后无法恢复。系统会在执行删除时再次检查任务、点云和 CAD 引用。</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90" disabled={deleteObject.isPending} onClick={() => deleteObject.mutate({ id: selected.id })}>确认删除</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog> : <div className="space-y-1"><Button size="sm" variant="destructive" disabled><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button><p className="text-xs text-amber-500">需先处理:{referenceReason(selected)}。</p></div>}
|
||||
</section>
|
||||
</main> : <div className="grid min-h-48 place-items-center rounded-lg border border-dashed text-sm text-muted-foreground">请先创建检测对象</div>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resources" className="min-h-0 overflow-y-auto pr-1">
|
||||
<section className="min-w-0 space-y-3">
|
||||
<div><h3 className="text-sm font-medium">车型级资源归属</h3><p className="text-xs text-muted-foreground">每个点云必须归属一个检测对象;每个 CAD 可适用于多个检测对象,但至少保留一个。</p></div>
|
||||
{objects.length === 0 && <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">请先在“对象配置”中创建检测对象</div>}
|
||||
{objects.length > 0 && <div className="grid min-w-0 gap-3 lg:grid-cols-2">
|
||||
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库点云归属</h4><div className="max-h-[50vh] space-y-1 overflow-y-auto overflow-x-hidden">
|
||||
{pointclouds.map(file => <div key={file.id} className="flex min-w-0 flex-col gap-1 rounded px-1.5 py-1.5 text-xs hover:bg-secondary sm:flex-row sm:items-center sm:gap-2"><span className="min-w-0 flex-1 truncate" title={file.name}>{file.name}</span><select className={`min-w-0 rounded border bg-background px-1.5 py-1 text-[10px] outline-none focus:border-primary sm:w-32 sm:shrink-0 ${file.inspectionObjectId == null ? 'border-amber-500/50 text-amber-400' : 'border-border'}`} aria-label={`${file.name} 的归属检测对象`} value={file.inspectionObjectId ?? ''} disabled={assignPointcloud.isPending} onChange={event => { const inspectionObjectId = Number(event.target.value); if (inspectionObjectId && inspectionObjectId !== file.inspectionObjectId) assignPointcloud.mutate({ id: file.id, inspectionObjectId }); }}><option value="" disabled>未分类</option>{objects.map(object => <option key={object.id} value={object.id}>{object.name}</option>)}</select></div>)}
|
||||
{pointclouds.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无点云</p>}
|
||||
</div></div>
|
||||
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库 CAD · 适用对象</h4><div className="max-h-[50vh] space-y-2 overflow-y-auto overflow-x-hidden">
|
||||
{cadFiles.map(file => <fieldset key={file.id} className="min-w-0 rounded border p-2"><legend className="max-w-full truncate px-1 text-xs font-medium" title={file.name}>{file.name}</legend><div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{objects.map(object => { const checked = file.inspectionObjectIds.includes(object.id); return <label key={object.id} className="flex min-w-0 items-center gap-1.5 text-xs"><input type="checkbox" checked={checked} disabled={setCadObjects.isPending} onChange={() => { const next = checked ? file.inspectionObjectIds.filter(id => id !== object.id) : [...file.inspectionObjectIds, object.id]; if (next.length === 0) return toast.error('CAD 至少需要一个适用检测对象'); setCadObjects.mutate({ id: file.id, inspectionObjectIds: next }); }} /><span className="max-w-32 truncate" title={object.name}>{object.name}</span></label>; })}</div></fieldset>)}
|
||||
{cadFiles.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无 CAD</p>}
|
||||
</div></div>
|
||||
</div>}
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aggregateDemoFlowResults, type DemoFlowSourceResult } from './demoFlowAggregation';
|
||||
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowSourceResult } from './demoFlowAggregation';
|
||||
|
||||
function result(id: number, index: number, status: string, resultData: Record<string, unknown>): DemoFlowSourceResult {
|
||||
return { id, type: 'plugin', pluginId: `plugin-${id}`, status, resultData: { demoFlowIndex: index, ...resultData } };
|
||||
@@ -65,6 +65,47 @@ describe('aggregateDemoFlowResults', () => {
|
||||
expect(model.tableRows).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('过滤全流程指定的中间指标,同时保持其他有效测量项的原顺序', () => {
|
||||
const model = aggregateDemoFlowResults([
|
||||
result(10, 1, 'completed', {
|
||||
measurements: [
|
||||
{ name: '车体长度', value: 100, status: 'pass' },
|
||||
{ name: '前后端平移差', value: 0.1, status: 'pass' },
|
||||
{ name: '车体宽度', value: 20, status: 'warning' },
|
||||
{ name: 'ICP fitness', value: 0.98, status: 'pass' },
|
||||
{ name: '配准 RMSE', value: 0.02, status: 'pass' },
|
||||
{ name: '车体高度', value: 30, status: 'fail' },
|
||||
],
|
||||
}),
|
||||
], nameOf);
|
||||
|
||||
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
|
||||
'车体长度', '车体宽度', '车体高度',
|
||||
]);
|
||||
});
|
||||
|
||||
it('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
|
||||
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
|
||||
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');
|
||||
expect(demoFlowStatusLabel({ status: 'fail' })).toBe('超差');
|
||||
expect(demoFlowStatusLabel({ status: 'info' })).toBe('参考');
|
||||
expect(demoFlowStatusLabel({})).toBe('参考');
|
||||
|
||||
const model = aggregateDemoFlowResults([
|
||||
result(10, 1, 'completed', {
|
||||
measurements: [
|
||||
{ name: '通过项', value: 1, status: 'pass', passLabel: '通过' },
|
||||
{ name: '警告项', value: 2, status: 'warning', warningLabel: '部分成功' },
|
||||
{ name: '失败项', value: 3, status: 'fail', failLabel: '失败' },
|
||||
],
|
||||
}),
|
||||
], nameOf);
|
||||
|
||||
expect(model.measurementRows.map((row) => demoFlowStatusLabel(row.measurement))).toEqual([
|
||||
'合格', '警告', '超差',
|
||||
]);
|
||||
});
|
||||
|
||||
it('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
|
||||
const model = aggregateDemoFlowResults([
|
||||
result(10, 1, 'completed', {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
|
||||
|
||||
const DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES = new Set([
|
||||
'前后端平移差',
|
||||
'ICP fitness',
|
||||
'配准 RMSE',
|
||||
]);
|
||||
|
||||
export type DemoFlowSourceResult = {
|
||||
id: number;
|
||||
type: string;
|
||||
@@ -96,6 +102,24 @@ function hasOverlay(value: PluginResultLike['overlay3d']) {
|
||||
return Boolean(value && value.kind !== 'none');
|
||||
}
|
||||
|
||||
export function filterDemoFlowMeasurements(measurements: readonly Measurement[]): Measurement[] {
|
||||
return filterVisibleMeasurements(measurements)
|
||||
.filter((measurement) => !DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES.has(measurement.name));
|
||||
}
|
||||
|
||||
export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): string {
|
||||
switch (measurement.status) {
|
||||
case 'pass':
|
||||
return '合格';
|
||||
case 'warning':
|
||||
return '警告';
|
||||
case 'fail':
|
||||
return '超差';
|
||||
default:
|
||||
return '参考';
|
||||
}
|
||||
}
|
||||
|
||||
export function aggregateDemoFlowResults(
|
||||
results: DemoFlowSourceResult[],
|
||||
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
||||
@@ -113,7 +137,7 @@ export function aggregateDemoFlowResults(
|
||||
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
|
||||
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
|
||||
const algorithmName = getAlgorithmName(result);
|
||||
const measurements = filterVisibleMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
|
||||
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
|
||||
|
||||
if (measurements.length > 0 && options.includeAlgorithmGroups) {
|
||||
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<VehicleTree selection={selection} onSelect={setSelection} />
|
||||
<div className="h-px bg-border my-1" />
|
||||
<TaskManager
|
||||
@@ -296,7 +296,7 @@ export default function Home() {
|
||||
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
||||
onSelectTaskCad={handleSelectCad}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -475,7 +475,7 @@ export default function Home() {
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 左侧面板 */}
|
||||
{showLeftPanel && (
|
||||
<aside className="w-72 border-r border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3 space-y-2">
|
||||
<aside className="flex w-72 flex-shrink-0 flex-col overflow-hidden border-r border-border bg-card/30 p-3">
|
||||
{renderLeftPanel()}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
33
drizzle/0017_inspection_objects.sql
Normal file
33
drizzle/0017_inspection_objects.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE IF NOT EXISTS `inspection_objects` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`modelId` INT NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`status` ENUM('draft','active') NOT NULL DEFAULT 'draft',
|
||||
`defaultModelPointcloudId` INT NULL,
|
||||
`defaultModelCadFileId` INT NULL,
|
||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_inspection_objects_model_name` (`modelId`,`name`),
|
||||
KEY `idx_inspection_objects_model` (`modelId`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `model_pointcloud_files`
|
||||
ADD COLUMN `inspectionObjectId` INT NULL AFTER `filePath`,
|
||||
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `inspectionObjectId`,
|
||||
ADD KEY `idx_model_pointcloud_object` (`inspectionObjectId`);
|
||||
|
||||
ALTER TABLE `model_cad_files`
|
||||
ADD COLUMN `contentScope` ENUM('whole_vehicle') NOT NULL DEFAULT 'whole_vehicle' AFTER `filePath`,
|
||||
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `contentScope`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `model_cad_inspection_objects` (
|
||||
`cadFileId` INT NOT NULL,
|
||||
`inspectionObjectId` INT NOT NULL,
|
||||
UNIQUE KEY `uk_model_cad_inspection_object` (`cadFileId`,`inspectionObjectId`),
|
||||
KEY `idx_model_cad_object` (`inspectionObjectId`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `inspection_tasks`
|
||||
ADD COLUMN `inspectionObjectId` INT NULL AFTER `modelId`,
|
||||
ADD KEY `idx_inspection_tasks_object` (`inspectionObjectId`);
|
||||
@@ -50,6 +50,25 @@ export const vehicleModels = mysqlTable("vehicle_models", {
|
||||
export type VehicleModel = typeof vehicleModels.$inferSelect;
|
||||
export type InsertVehicleModel = typeof vehicleModels.$inferInsert;
|
||||
|
||||
// ─── 检测对象 ────────────────────────────────────────────────────────────────
|
||||
// 车型下真正执行检测的对象,例如头车、中间车。默认资源只影响新建任务。
|
||||
export const inspectionObjects = mysqlTable("inspection_objects", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
modelId: int("modelId").notNull(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
description: text("description"),
|
||||
status: mysqlEnum("status", ["draft", "active"]).default("draft").notNull(),
|
||||
defaultModelPointcloudId: int("defaultModelPointcloudId"),
|
||||
defaultModelCadFileId: int("defaultModelCadFileId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
modelNameUnique: uniqueIndex("uk_inspection_objects_model_name").on(table.modelId, table.name),
|
||||
}));
|
||||
|
||||
export type InspectionObject = typeof inspectionObjects.$inferSelect;
|
||||
export type InsertInspectionObject = typeof inspectionObjects.$inferInsert;
|
||||
|
||||
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||
// 一个车型可关联多个 CAD 文件(车头、车身、转向架等),低频变更
|
||||
|
||||
@@ -60,12 +79,21 @@ export const modelCadFiles = mysqlTable("model_cad_files", {
|
||||
format: varchar("format", { length: 32 }).notNull(),
|
||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||
contentScope: mysqlEnum("contentScope", ["whole_vehicle"]).default("whole_vehicle").notNull(),
|
||||
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
|
||||
export type InsertModelCadFile = typeof modelCadFiles.$inferInsert;
|
||||
|
||||
export const modelCadInspectionObjects = mysqlTable("model_cad_inspection_objects", {
|
||||
cadFileId: int("cadFileId").notNull(),
|
||||
inspectionObjectId: int("inspectionObjectId").notNull(),
|
||||
}, (table) => ({
|
||||
cadObjectUnique: uniqueIndex("uk_model_cad_inspection_object").on(table.cadFileId, table.inspectionObjectId),
|
||||
}));
|
||||
|
||||
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
||||
// 一个车型可维护多个通用点云文件,供任务按需关联复用
|
||||
|
||||
@@ -75,6 +103,8 @@ export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||
inspectionObjectId: int("inspectionObjectId"),
|
||||
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
|
||||
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
|
||||
pointCount: bigint("pointCount", { mode: "number" }),
|
||||
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
||||
@@ -113,6 +143,7 @@ export type InsertTaskCadFile = typeof taskCadFiles.$inferInsert;
|
||||
export const inspectionTasks = mysqlTable("inspection_tasks", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
modelId: int("modelId").notNull(),
|
||||
inspectionObjectId: int("inspectionObjectId"),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
inspector: varchar("inspector", { length: 100 }),
|
||||
notes: text("notes"),
|
||||
|
||||
60
scripts/migrate-inspection-objects.ts
Normal file
60
scripts/migrate-inspection-objects.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
type Mapping = { modelId: number; objectName: string; pointclouds: string[]; defaultPointcloud: string; cadFiles: string[]; defaultCad: string };
|
||||
const mappings: Mapping[] = [
|
||||
{ modelId: 1, objectName: '中间车', pointclouds: ['1_10.ply', '1_11.ply', '1_12.ply'], defaultPointcloud: '1_10.ply', cadFiles: ['train_1_mesh.ply'], defaultCad: 'train_1_mesh.ply' },
|
||||
{ modelId: 2, objectName: '头车', pointclouds: ['2_10.ply', '2_11.ply', '2_12.ply', 'train_2_10M.ply', 'train_2_1T.ply'], defaultPointcloud: '2_10.ply', cadFiles: ['m000004802418tc01_asm.stp', 'train_2_mesh.ply'], defaultCad: 'm000004802418tc01_asm.stp' },
|
||||
];
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error('DATABASE_URL is required');
|
||||
const connection = await mysql.createConnection(connectionString);
|
||||
let createdObjects = 0;
|
||||
let assignedPointclouds = 0;
|
||||
let assignedCads = 0;
|
||||
let classifiedTasks = 0;
|
||||
const warnings: Array<{ taskId: number; reason: string }> = [];
|
||||
const conflicts: string[] = [];
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
for (const mapping of mappings) {
|
||||
const [existing] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
|
||||
if (existing.length === 0) {
|
||||
await connection.execute('INSERT INTO inspection_objects (modelId,name,status) VALUES (?,?,\'draft\')', [mapping.modelId, mapping.objectName]);
|
||||
createdObjects += 1;
|
||||
}
|
||||
const [objectRows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
|
||||
const objectId = Number(objectRows[0].id);
|
||||
for (const name of mapping.pointclouds) {
|
||||
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id,inspectionObjectId FROM model_pointcloud_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
|
||||
if (rows.length !== 1) { conflicts.push(`点云 model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
|
||||
if (rows[0].inspectionObjectId && Number(rows[0].inspectionObjectId) !== objectId) { conflicts.push(`点云 ${name} 已属于其他检测对象`); continue; }
|
||||
if (!rows[0].inspectionObjectId) { await connection.execute('UPDATE model_pointcloud_files SET inspectionObjectId=?,status=\'available\' WHERE id=?', [objectId, rows[0].id]); assignedPointclouds += 1; }
|
||||
}
|
||||
for (const name of mapping.cadFiles) {
|
||||
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_cad_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
|
||||
if (rows.length !== 1) { conflicts.push(`CAD model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
|
||||
const [result] = await connection.execute<mysql.ResultSetHeader>('INSERT IGNORE INTO model_cad_inspection_objects (cadFileId,inspectionObjectId) VALUES (?,?)', [rows[0].id, objectId]);
|
||||
assignedCads += result.affectedRows;
|
||||
}
|
||||
const [[defaultPc]] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_pointcloud_files WHERE modelId=? AND name=? AND inspectionObjectId=?', [mapping.modelId, mapping.defaultPointcloud, objectId]);
|
||||
const [[defaultCad]] = await connection.query<mysql.RowDataPacket[]>('SELECT c.id FROM model_cad_files c JOIN model_cad_inspection_objects r ON r.cadFileId=c.id WHERE c.modelId=? AND c.name=? AND r.inspectionObjectId=?', [mapping.modelId, mapping.defaultCad, objectId]);
|
||||
if (defaultPc && defaultCad) await connection.execute('UPDATE inspection_objects SET defaultModelPointcloudId=?,defaultModelCadFileId=?,status=\'active\' WHERE id=?', [defaultPc.id, defaultCad.id, objectId]);
|
||||
else conflicts.push(`${mapping.objectName} 默认输入方案资源不完整`);
|
||||
}
|
||||
|
||||
const [tasks] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_tasks WHERE inspectionObjectId IS NULL ORDER BY id');
|
||||
for (const task of tasks) {
|
||||
const [sources] = await connection.query<mysql.RowDataPacket[]>('SELECT DISTINCT mp.inspectionObjectId FROM task_pointcloud_files tp JOIN model_pointcloud_files mp ON mp.id=tp.sourceModelPointcloudId WHERE tp.taskId=? AND mp.inspectionObjectId IS NOT NULL', [task.id]);
|
||||
if (sources.length === 1) { await connection.execute('UPDATE inspection_tasks SET inspectionObjectId=? WHERE id=? AND inspectionObjectId IS NULL', [sources[0].inspectionObjectId, task.id]); classifiedTasks += 1; }
|
||||
else warnings.push({ taskId: Number(task.id), reason: sources.length > 1 ? '包含不同检测对象的车型库点云' : '无可推断的车型库来源点云' });
|
||||
}
|
||||
await connection.commit();
|
||||
console.log(JSON.stringify({ createdObjects, assignedPointclouds, assignedCads, classifiedTasks, unclassifiedTasks: warnings, conflicts }, null, 2));
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
310
server/db.ts
310
server/db.ts
@@ -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 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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
75
server/inspectionObject.test.ts
Normal file
75
server/inspectionObject.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user