<script lang="ts" setup>
|
import type { FileType } from 'ant-design-vue/es/upload/interface';
|
|
import type { MdmAgentImportApi } from '#/api/mdm/item/agent';
|
|
import { computed, ref } from 'vue';
|
|
import { useVbenModal } from '@vben/common-ui';
|
|
import {
|
Alert,
|
Button,
|
Input,
|
message,
|
Steps,
|
Table,
|
Tag,
|
Upload,
|
} from 'ant-design-vue';
|
|
import {
|
agentImportErrorMessage,
|
bindImportAttachment,
|
createImportSession,
|
getMaterialImportReceipt,
|
reviewMaterialImport,
|
startMaterialImport,
|
} from '#/api/mdm/item/agent';
|
import { uploadFile } from '#/api/system/storage';
|
|
defineOptions({ name: 'MdmAgentImport' });
|
|
const emit = defineEmits<{ success: [] }>();
|
|
const MAX_BYTES = 8 * 1024 * 1024;
|
const FIELD_KEYS = [
|
'code',
|
'name',
|
'specification',
|
'categoryCode',
|
'unitMeasureCode',
|
] as const;
|
type FieldKey = (typeof FIELD_KEYS)[number];
|
|
interface EditableRow {
|
candidate_id: string;
|
row_no?: number;
|
code: string;
|
name: string;
|
specification: string;
|
categoryCode: string;
|
unitMeasureCode: string;
|
original: Record<string, string>;
|
preflight?: MdmAgentImportApi.PreflightRow;
|
issues: string[];
|
}
|
|
interface RowMessage {
|
type: 'error' | 'warning';
|
text: string;
|
}
|
|
const step = ref(1);
|
const file = ref<File>();
|
const fileName = ref('');
|
const processing = ref(false);
|
const taskId = ref('');
|
const state = ref<MdmAgentImportApi.ImportState>();
|
const receipt = ref<MdmAgentImportApi.Receipt>();
|
const rows = ref<EditableRow[]>([]);
|
const lastError = ref('');
|
let sessionId: number | undefined;
|
let attachmentId: number | undefined;
|
|
const preflight = computed(() => state.value?.preflight);
|
const issues = computed(() => state.value?.issues ?? []);
|
const canCommit = computed(
|
() => !!preflight.value?.can_commit && issues.value.length === 0,
|
);
|
const hasChanges = computed(() => corrections.value.length > 0);
|
|
const corrections = computed<MdmAgentImportApi.Correction[]>(() => {
|
const result: MdmAgentImportApi.Correction[] = [];
|
for (const row of rows.value) {
|
for (const field of FIELD_KEYS) {
|
const before = (row.original[field] ?? '').trim();
|
const after = (row[field] ?? '').trim();
|
if (before !== after) {
|
result.push({
|
candidate_id: row.candidate_id,
|
field,
|
value: after,
|
reason: '人工修订',
|
});
|
}
|
}
|
}
|
return result;
|
});
|
|
const ACTION_LABEL: Record<MdmAgentImportApi.RowAction, string> = {
|
BLOCKED: '阻断',
|
CREATE: '新建',
|
SKIP: '已存在',
|
};
|
const ACTION_COLOR: Record<MdmAgentImportApi.RowAction, string> = {
|
BLOCKED: 'red',
|
CREATE: 'green',
|
SKIP: 'orange',
|
};
|
const STATUS_LABEL: Record<MdmAgentImportApi.RowStatus, string> = {
|
FAILED: '失败',
|
SKIPPED: '已跳过',
|
SUCCESS: '成功',
|
UNKNOWN: '未知',
|
};
|
const STATUS_COLOR: Record<MdmAgentImportApi.RowStatus, string> = {
|
FAILED: 'red',
|
SKIPPED: 'orange',
|
SUCCESS: 'green',
|
UNKNOWN: 'default',
|
};
|
|
const reviewColumns = [
|
{ dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
|
{ dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
|
{ dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
|
{ dataIndex: 'specification', key: 'specification', title: '规格型号', width: 150 },
|
{ dataIndex: 'categoryCode', key: 'categoryCode', title: '分类编码', width: 140 },
|
{
|
dataIndex: 'unitMeasureCode',
|
key: 'unitMeasureCode',
|
title: '计量单位',
|
width: 130,
|
},
|
{ key: 'action', title: '预检动作', width: 100 },
|
{ key: 'message', title: '校验信息', width: 260 },
|
];
|
|
const receiptColumns = [
|
{ dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
|
{ dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
|
{ dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
|
{ key: 'action', title: '动作', width: 90 },
|
{ key: 'status', title: '结果', width: 90 },
|
{ dataIndex: 'target_item_id', key: 'target_item_id', title: '物料 ID', width: 100 },
|
{ key: 'error', title: '失败原因', width: 220 },
|
];
|
|
function isEditableColumn(key: unknown): key is FieldKey {
|
return typeof key === 'string' && (FIELD_KEYS as readonly string[]).includes(key);
|
}
|
|
function rowMessages(row: EditableRow): RowMessage[] {
|
return [
|
...(row.preflight?.blockers ?? []).map((item) => ({
|
type: 'error' as const,
|
text: item.message,
|
})),
|
...(row.preflight?.warnings ?? []).map((item) => ({
|
type: 'warning' as const,
|
text: item.message,
|
})),
|
...row.issues.map((text) => ({ type: 'warning' as const, text })),
|
];
|
}
|
|
function buildRows(data: MdmAgentImportApi.ImportState): EditableRow[] {
|
const plannedRows = data.preflight?.rows ?? [];
|
return (data.candidates ?? [])
|
.map((candidate) => {
|
const planned = plannedRows.find(
|
(item) => item.candidate_id === candidate.candidate_id,
|
);
|
const values = candidate.values ?? {};
|
return {
|
candidate_id: candidate.candidate_id,
|
row_no: planned?.row_no ?? candidate.evidence?.code?.row,
|
code: values.code ?? '',
|
name: values.name ?? '',
|
specification: values.specification ?? '',
|
categoryCode: values.categoryCode ?? '',
|
unitMeasureCode: values.unitMeasureCode ?? '',
|
original: { ...values },
|
preflight: planned,
|
issues: candidate.issues ?? [],
|
};
|
})
|
.sort((a, b) => (a.row_no ?? 0) - (b.row_no ?? 0));
|
}
|
|
function applyState(payload: MdmAgentImportApi.PublicState) {
|
state.value = payload.state;
|
taskId.value = payload.state.task_id;
|
lastError.value =
|
payload.state.last_error || payload.interrupts?.[0]?.last_error || '';
|
receipt.value = payload.state.receipt;
|
rows.value = buildRows(payload.state);
|
step.value = receipt.value ? 3 : 2;
|
syncFooter();
|
}
|
|
function syncFooter() {
|
void modalApi.setState({
|
cancelText: step.value === 2 ? '取消任务' : '取消',
|
confirmText:
|
step.value === 1 ? '开始解析' : step.value === 2 ? '确认提交' : '完成',
|
showCancelButton: step.value !== 3,
|
});
|
}
|
|
function reset() {
|
step.value = 1;
|
file.value = undefined;
|
fileName.value = '';
|
processing.value = false;
|
taskId.value = '';
|
state.value = undefined;
|
receipt.value = undefined;
|
rows.value = [];
|
lastError.value = '';
|
sessionId = undefined;
|
attachmentId = undefined;
|
}
|
|
function beforeUpload(fileInfo: FileType) {
|
const raw = fileInfo as unknown as File;
|
const name = raw.name.toLowerCase();
|
if (!name.endsWith('.csv') && !name.endsWith('.xlsx')) {
|
message.error('仅支持 CSV 或 XLSX 文件');
|
return false;
|
}
|
if (raw.size > MAX_BYTES) {
|
message.error('文件不能超过 8 MiB');
|
return false;
|
}
|
file.value = raw;
|
fileName.value = raw.name;
|
return false;
|
}
|
|
async function startImport() {
|
if (!file.value) {
|
message.warning('请先选择 CSV/XLSX 文件');
|
return;
|
}
|
processing.value = true;
|
modalApi.lock();
|
try {
|
sessionId = await createImportSession();
|
const blobs = await uploadFile([file.value]);
|
const blobId = blobs?.[0]?.id;
|
if (!blobId) {
|
throw new Error('文件上传失败,未取得 blobId');
|
}
|
attachmentId = await bindImportAttachment(sessionId, blobId);
|
const payload = await startMaterialImport(attachmentId);
|
applyState(payload);
|
} catch (error) {
|
message.error(agentImportErrorMessage(error));
|
} finally {
|
processing.value = false;
|
modalApi.unlock();
|
}
|
}
|
|
async function revise() {
|
if (!state.value) return;
|
if (!hasChanges.value) {
|
message.info('未检测到修改');
|
return;
|
}
|
processing.value = true;
|
modalApi.lock();
|
try {
|
const payload = await reviewMaterialImport(taskId.value, {
|
action: 'revise',
|
corrections: corrections.value,
|
expected_revision: state.value.revision,
|
});
|
applyState(payload);
|
message.success('已按修改重新预检,请核对结果');
|
} catch (error) {
|
message.error(agentImportErrorMessage(error));
|
} finally {
|
processing.value = false;
|
modalApi.unlock();
|
}
|
}
|
|
async function confirmSubmit() {
|
if (!state.value) return;
|
if (hasChanges.value) {
|
message.warning('存在未提交的修改,请先点「重新检查」并按新的预检结果确认');
|
return;
|
}
|
if (!canCommit.value) {
|
message.warning(lastError.value || '预检仍有阻断项,请修订后重新检查');
|
return;
|
}
|
processing.value = true;
|
modalApi.lock();
|
try {
|
const payload = await reviewMaterialImport(taskId.value, {
|
action: 'confirm',
|
expected_preflight_hash: state.value.preflight_hash,
|
expected_revision: state.value.revision,
|
});
|
applyState(payload);
|
if (receipt.value) {
|
message.success('提交完成,请查看回执');
|
} else if (lastError.value) {
|
message.error(lastError.value);
|
}
|
} catch (error) {
|
const text = agentImportErrorMessage(error);
|
message.error(text);
|
// 提交超时/网关中断时命令可能已执行,提示用户查回执,避免重复提交
|
lastError.value = text;
|
} finally {
|
processing.value = false;
|
modalApi.unlock();
|
}
|
}
|
|
async function fetchReceipt() {
|
if (!taskId.value) return;
|
processing.value = true;
|
modalApi.lock();
|
try {
|
receipt.value = await getMaterialImportReceipt(taskId.value);
|
step.value = 3;
|
syncFooter();
|
} catch (error) {
|
message.error(agentImportErrorMessage(error));
|
} finally {
|
processing.value = false;
|
modalApi.unlock();
|
}
|
}
|
|
async function handleCancel() {
|
if (step.value === 2 && taskId.value) {
|
try {
|
await reviewMaterialImport(taskId.value, {
|
action: 'cancel',
|
expected_revision: state.value?.revision ?? 0,
|
});
|
} catch {
|
// 取消失败不影响关闭窗口
|
}
|
}
|
modalApi.close();
|
}
|
|
function finish() {
|
if ((receipt.value?.summary.success ?? 0) > 0) {
|
emit('success');
|
}
|
modalApi.close();
|
}
|
|
async function handlePrimary() {
|
if (processing.value) return;
|
if (step.value === 1) {
|
await startImport();
|
return;
|
}
|
if (step.value === 2) {
|
await confirmSubmit();
|
return;
|
}
|
finish();
|
}
|
|
const [Modal, modalApi] = useVbenModal({
|
onCancel() {
|
void handleCancel();
|
},
|
onClosed() {
|
reset();
|
},
|
onConfirm() {
|
void handlePrimary();
|
},
|
onOpenChange(isOpen) {
|
if (isOpen) {
|
reset();
|
syncFooter();
|
}
|
},
|
});
|
</script>
|
|
<template>
|
<Modal title="智能体导入物料" class="w-[1080px]">
|
<Steps
|
:current="step - 1"
|
class="mx-4 mb-4"
|
size="small"
|
:items="[
|
{ title: '上传文件' },
|
{ title: '审核确认' },
|
{ title: '提交回执' },
|
]"
|
/>
|
|
<div v-if="step === 1" class="mx-4">
|
<Upload
|
accept=".csv,.xlsx"
|
:max-count="1"
|
:show-upload-list="false"
|
:before-upload="beforeUpload"
|
>
|
<Button type="primary">选择 CSV / XLSX 文件</Button>
|
</Upload>
|
<div v-if="fileName" class="mt-2 text-sm text-gray-600">
|
已选择:{{ fileName }}
|
</div>
|
<div class="mt-3 text-xs text-gray-500 leading-relaxed">
|
<div>文件上限 8 MiB,仅支持 CSV / XLSX。</div>
|
<div>
|
流程:解析清单 → Java 校验分类/单位/编码 → 人工核对 → 确认提交。
|
</div>
|
<div>
|
本阶段只新建物料:编码已存在会跳过,不更新原物料,也不同步 MES。
|
</div>
|
</div>
|
</div>
|
|
<div v-else-if="step === 2" class="mx-4">
|
<div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
|
<span>总计:{{ preflight?.summary.total ?? rows.length }}</span>
|
<span class="text-green-600">
|
新建:{{ preflight?.summary.create ?? 0 }}
|
</span>
|
<span class="text-orange-500">
|
跳过:{{ preflight?.summary.skip ?? 0 }}
|
</span>
|
<span class="text-red-500">
|
阻断:{{ preflight?.summary.blocked ?? 0 }}
|
</span>
|
<span class="text-gray-500">方案版本:{{ state?.revision ?? 0 }}</span>
|
</div>
|
|
<Alert
|
v-if="lastError"
|
type="error"
|
:message="lastError"
|
show-icon
|
class="mb-3"
|
/>
|
|
<Alert
|
v-if="issues.length > 0"
|
type="warning"
|
show-icon
|
class="mb-3"
|
message="存在需要处理的问题"
|
>
|
<template #description>
|
<div v-for="(issue, index) in issues" :key="index">
|
{{ issue.message }}
|
</div>
|
</template>
|
</Alert>
|
|
<Alert
|
v-else-if="preflight && !preflight.can_commit"
|
type="warning"
|
show-icon
|
class="mb-3"
|
message="预检仍有阻断项,修正后请点「重新检查」,通过后才能确认提交"
|
/>
|
|
<Table
|
bordered
|
size="small"
|
:columns="reviewColumns"
|
:data-source="rows"
|
:pagination="false"
|
:row-key="(record: EditableRow) => record.candidate_id"
|
:scroll="{ y: 360 }"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="isEditableColumn(column.key)">
|
<Input
|
v-model:value="record[column.key]"
|
size="small"
|
:status="
|
record.preflight?.blockers?.some(
|
(item: MdmAgentImportApi.Issue) => item.field === column.key,
|
)
|
? 'error'
|
: undefined
|
"
|
/>
|
<div
|
v-if="
|
column.key === 'categoryCode' &&
|
record.preflight?.resolved?.category_name
|
"
|
class="mt-1 text-xs text-gray-400"
|
>
|
解析为:{{ record.preflight.resolved.category_name }}
|
</div>
|
<div
|
v-if="
|
column.key === 'unitMeasureCode' &&
|
record.preflight?.resolved?.unit_measure_name
|
"
|
class="mt-1 text-xs text-gray-400"
|
>
|
解析为:{{ record.preflight.resolved.unit_measure_name }}
|
</div>
|
</template>
|
<template v-else-if="column.key === 'action'">
|
<Tag
|
v-if="record.preflight"
|
:color="
|
ACTION_COLOR[record.preflight.action as MdmAgentImportApi.RowAction]
|
"
|
>
|
{{ ACTION_LABEL[record.preflight.action as MdmAgentImportApi.RowAction] }}
|
</Tag>
|
</template>
|
<template v-else-if="column.key === 'message'">
|
<div
|
v-for="(item, index) in rowMessages(record as EditableRow)"
|
:key="index"
|
class="text-xs leading-relaxed"
|
:class="item.type === 'error' ? 'text-red-500' : 'text-orange-500'"
|
>
|
{{ item.text }}
|
</div>
|
</template>
|
</template>
|
</Table>
|
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
<Button :disabled="processing || !hasChanges" @click="revise">
|
重新检查
|
</Button>
|
<Button :disabled="processing || !taskId" @click="fetchReceipt">
|
查询回执
|
</Button>
|
<span class="text-xs text-gray-400">
|
{{ hasChanges ? '已修改,需重新检查后才能确认' : '确认后提交,已存在编码会跳过' }}
|
</span>
|
</div>
|
</div>
|
|
<div v-else class="mx-4">
|
<div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
|
<Tag :color="receipt?.status === 'SUCCEEDED' ? 'green' : 'orange'">
|
{{ receipt?.status ?? 'UNKNOWN' }}
|
</Tag>
|
<span>总计:{{ receipt?.summary.total ?? 0 }}</span>
|
<span class="text-green-600">
|
成功:{{ receipt?.summary.success ?? 0 }}
|
</span>
|
<span class="text-orange-500">
|
跳过:{{ receipt?.summary.skipped ?? 0 }}
|
</span>
|
<span class="text-red-500">
|
失败:{{ receipt?.summary.failed ?? 0 }}
|
</span>
|
<span class="text-gray-500">
|
未知:{{ receipt?.summary.unknown ?? 0 }}
|
</span>
|
</div>
|
|
<Alert
|
v-if="receipt?.error_message"
|
type="error"
|
:message="receipt.error_message"
|
show-icon
|
class="mb-3"
|
/>
|
|
<Table
|
bordered
|
size="small"
|
:columns="receiptColumns"
|
:data-source="receipt?.rows ?? []"
|
:pagination="false"
|
:row-key="(record: MdmAgentImportApi.ReceiptRow) => record.candidate_id"
|
:scroll="{ y: 360 }"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.key === 'action'">
|
<Tag :color="record.action === 'CREATE' ? 'green' : 'orange'">
|
{{ record.action === 'CREATE' ? '新建' : '跳过' }}
|
</Tag>
|
</template>
|
<template v-else-if="column.key === 'status'">
|
<Tag :color="STATUS_COLOR[record.status as MdmAgentImportApi.RowStatus]">
|
{{ STATUS_LABEL[record.status as MdmAgentImportApi.RowStatus] }}
|
</Tag>
|
</template>
|
<template v-else-if="column.key === 'error'">
|
<span class="text-xs text-red-500">
|
{{ record.error_message ?? '' }}
|
</span>
|
</template>
|
</template>
|
</Table>
|
</div>
|
</Modal>
|
</template>
|