409 lines
14 KiB
Vue
409 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import type { Factory, Order } from '@/shared/types'
|
||
import { api } from '@/utils/api'
|
||
import type { ImportPreviewRow } from '@/utils/orderImport'
|
||
import { buildImportPreview, readSpreadsheetRows } from '@/utils/orderImport'
|
||
|
||
const props = defineProps<{ open: boolean; factories: Factory[] }>()
|
||
const emit = defineEmits<{ (e: 'update:open', v: boolean): void; (e: 'imported'): void }>()
|
||
|
||
const importing = ref(false)
|
||
const parsing = ref(false)
|
||
const importMode = ref<'file' | 'json'>('file')
|
||
const fileName = ref('')
|
||
const jsonText = ref('')
|
||
const previewRows = ref<ImportPreviewRow[]>([])
|
||
const selectedKeys = ref<Set<number>>(new Set())
|
||
const tableRef = ref<
|
||
| {
|
||
clearSelection: () => void
|
||
toggleRowSelection: (row: ImportPreviewRow, selected?: boolean) => void
|
||
}
|
||
| null
|
||
>(null)
|
||
const skipDuplicates = ref(true)
|
||
|
||
const factoryNameById = computed(() => new Map(props.factories.map((f) => [f.id, f.name])))
|
||
|
||
const displayFactory = (row: ImportPreviewRow): string => {
|
||
return (
|
||
row.cleaned.factoryName ??
|
||
(row.cleaned.factoryId ? factoryNameById.value.get(row.cleaned.factoryId) : '') ??
|
||
row.cleaned.factoryId ??
|
||
''
|
||
)
|
||
}
|
||
|
||
const hasPreview = computed(() => previewRows.value.length > 0)
|
||
const errorCount = computed(
|
||
() => previewRows.value.filter((r) => r.issues.some((x) => x.severity === 'error')).length,
|
||
)
|
||
const warnCount = computed(() => previewRows.value.reduce((acc, r) => acc + r.issues.filter((x) => x.severity === 'warn').length, 0))
|
||
const selectedCount = computed(() => selectedKeys.value.size)
|
||
const duplicateCount = computed(() => previewRows.value.filter((r) => r.issues.some((x) => x.message.includes('订单号已存在'))).length)
|
||
|
||
const close = () => emit('update:open', false)
|
||
|
||
const reset = () => {
|
||
parsing.value = false
|
||
importing.value = false
|
||
importMode.value = 'file'
|
||
fileName.value = ''
|
||
jsonText.value = ''
|
||
previewRows.value = []
|
||
selectedKeys.value = new Set()
|
||
skipDuplicates.value = true
|
||
}
|
||
|
||
const syncTableSelection = async () => {
|
||
await nextTick()
|
||
if (!tableRef.value) return
|
||
tableRef.value.clearSelection()
|
||
for (const r of previewRows.value) {
|
||
if (selectedKeys.value.has(r.index)) {
|
||
tableRef.value.toggleRowSelection(r, true)
|
||
}
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.open,
|
||
async (v) => {
|
||
if (!v) {
|
||
reset()
|
||
return
|
||
}
|
||
},
|
||
)
|
||
|
||
const onPickFile = async (file: File) => {
|
||
parsing.value = true
|
||
try {
|
||
fileName.value = file.name
|
||
const rawRows = await readSpreadsheetRows(file)
|
||
const preview0 = buildImportPreview({ rawRows, factories: props.factories })
|
||
const orderNos = preview0.rows.map((r) => r.cleaned.orderNo).filter((x): x is string => Boolean(x))
|
||
let duplicates: string[] = []
|
||
try {
|
||
duplicates = orderNos.length > 0 ? await api.checkOrderDuplicates(orderNos) : []
|
||
} catch {
|
||
duplicates = []
|
||
}
|
||
const preview = buildImportPreview({
|
||
rawRows,
|
||
factories: props.factories,
|
||
existingOrderNos: new Set(duplicates),
|
||
})
|
||
previewRows.value = preview.rows
|
||
|
||
const selected = new Set<number>()
|
||
for (const r of preview.rows) {
|
||
const hasErrors = r.issues.some((x) => x.severity === 'error')
|
||
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
|
||
if (!hasErrors && (!skipDuplicates.value || !isDup)) {
|
||
selected.add(r.index)
|
||
}
|
||
}
|
||
selectedKeys.value = selected
|
||
await syncTableSelection()
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '解析失败')
|
||
} finally {
|
||
parsing.value = false
|
||
}
|
||
}
|
||
|
||
const onUploadChange = async (uploadFile: { raw?: File }) => {
|
||
const f = uploadFile.raw
|
||
if (!f) return
|
||
await onPickFile(f)
|
||
}
|
||
|
||
const onSkipDuplicatesChange = (v: boolean) => {
|
||
skipDuplicates.value = v
|
||
if (!hasPreview.value) return
|
||
const selected = new Set(selectedKeys.value)
|
||
for (const r of previewRows.value) {
|
||
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
|
||
const hasErrors = r.issues.some((x) => x.severity === 'error')
|
||
if (hasErrors) {
|
||
selected.delete(r.index)
|
||
continue
|
||
}
|
||
if (skipDuplicates.value && isDup) selected.delete(r.index)
|
||
if (!skipDuplicates.value && !selected.has(r.index)) selected.add(r.index)
|
||
}
|
||
selectedKeys.value = selected
|
||
void syncTableSelection()
|
||
}
|
||
|
||
const rowClassName = (args: { row: ImportPreviewRow }) => {
|
||
const hasErrors = args.row.issues.some((x) => x.severity === 'error')
|
||
return hasErrors ? 'bg-red-50' : ''
|
||
}
|
||
|
||
const isRowSelectable = (row: ImportPreviewRow) => !row.issues.some((x) => x.severity === 'error')
|
||
|
||
const onSelectionChange = (rows: ImportPreviewRow[]) => {
|
||
selectedKeys.value = new Set(rows.map((r) => r.index))
|
||
}
|
||
|
||
const onDownloadTemplate = () => {
|
||
const headers = [
|
||
'订单号',
|
||
'PO号',
|
||
'产品名称',
|
||
'国别',
|
||
'订单金额',
|
||
'下单日期',
|
||
'客人货期',
|
||
'工厂名称',
|
||
'工厂货期',
|
||
'合同状态',
|
||
'订单进度',
|
||
'备注',
|
||
]
|
||
const csv = `${headers.join(',')}\n`
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = '订单导入模板.csv'
|
||
a.click()
|
||
URL.revokeObjectURL(a.href)
|
||
}
|
||
|
||
const onParseJson = async () => {
|
||
parsing.value = true
|
||
try {
|
||
const t = jsonText.value.trim()
|
||
if (!t) {
|
||
ElMessage.warning('请粘贴JSON数据')
|
||
return
|
||
}
|
||
const parsed = JSON.parse(t)
|
||
if (!Array.isArray(parsed)) {
|
||
ElMessage.error('JSON必须是数组')
|
||
return
|
||
}
|
||
fileName.value = '粘贴JSON'
|
||
const rawRows = parsed as Record<string, unknown>[]
|
||
const preview0 = buildImportPreview({ rawRows, factories: props.factories })
|
||
const orderNos = preview0.rows.map((r) => r.cleaned.orderNo).filter((x): x is string => Boolean(x))
|
||
let duplicates: string[] = []
|
||
try {
|
||
duplicates = orderNos.length > 0 ? await api.checkOrderDuplicates(orderNos) : []
|
||
} catch {
|
||
duplicates = []
|
||
}
|
||
const preview = buildImportPreview({
|
||
rawRows,
|
||
factories: props.factories,
|
||
existingOrderNos: new Set(duplicates),
|
||
})
|
||
previewRows.value = preview.rows
|
||
|
||
const selected = new Set<number>()
|
||
for (const r of preview.rows) {
|
||
const hasErrors = r.issues.some((x) => x.severity === 'error')
|
||
const isDup = r.issues.some((x) => x.message.includes('订单号已存在'))
|
||
if (!hasErrors && (!skipDuplicates.value || !isDup)) selected.add(r.index)
|
||
}
|
||
selectedKeys.value = selected
|
||
await syncTableSelection()
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : 'JSON解析失败')
|
||
} finally {
|
||
parsing.value = false
|
||
}
|
||
}
|
||
|
||
const toCreateInput = (row: ImportPreviewRow): Partial<Order> & { orderNo: string; productName: string } | null => {
|
||
const c = row.cleaned
|
||
if (!c.orderNo || !c.productName) return null
|
||
const isDup = row.issues.some((x) => x.message.includes('订单号已存在'))
|
||
if (skipDuplicates.value && isDup) return null
|
||
return {
|
||
orderNo: c.orderNo,
|
||
poNo: c.poNo,
|
||
productName: c.productName,
|
||
country: c.country,
|
||
orderAmount: c.orderAmount,
|
||
orderDate: c.orderDate,
|
||
customerDeliveryDate: c.customerDeliveryDate,
|
||
factoryId: c.factoryId,
|
||
factoryDeliveryDate: c.factoryDeliveryDate,
|
||
factoryContract: c.factoryContract,
|
||
packagingStatus: c.packagingStatus,
|
||
stickerStatus: c.stickerStatus,
|
||
shippingStatus: c.shippingStatus,
|
||
inspectionStatus: c.inspectionStatus,
|
||
purchaseAmount: c.purchaseAmount,
|
||
orderProgress: c.orderProgress ?? '下单',
|
||
remarks: c.remarks,
|
||
ciAmount: c.ciAmount,
|
||
etd: c.etd,
|
||
eta: c.eta,
|
||
paymentDate: c.paymentDate,
|
||
paymentAmount: c.paymentAmount,
|
||
balance: c.balance,
|
||
}
|
||
}
|
||
|
||
const onImport = async () => {
|
||
if (!hasPreview.value) {
|
||
ElMessage.warning('请先选择文件')
|
||
return
|
||
}
|
||
if (selectedKeys.value.size === 0) {
|
||
ElMessage.warning('没有可导入的行')
|
||
return
|
||
}
|
||
importing.value = true
|
||
try {
|
||
const items = previewRows.value
|
||
.filter((r) => selectedKeys.value.has(r.index))
|
||
.filter((r) => !r.issues.some((x) => x.severity === 'error'))
|
||
.map((r) => toCreateInput(r))
|
||
.filter(
|
||
(x): x is Partial<Order> & { orderNo: string; productName: string } => x !== null,
|
||
)
|
||
|
||
const res = await api.bulkCreateOrders({ items, skipDuplicates: skipDuplicates.value })
|
||
const skipped = res.skippedOrderNos.length
|
||
ElMessage.success(`导入完成:成功 ${res.createdCount},跳过重复 ${skipped}`)
|
||
emit('imported')
|
||
emit('update:open', false)
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '导入失败')
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
const title = computed(() => (fileName.value ? `导入订单 - ${fileName.value}` : '导入订单'))
|
||
</script>
|
||
|
||
<template>
|
||
<el-dialog :model-value="open" :title="title" width="1100px" @close="close">
|
||
<div class="space-y-3">
|
||
<el-radio-group v-model="importMode" size="small">
|
||
<el-radio-button label="file">文件导入</el-radio-button>
|
||
<el-radio-button label="json">粘贴JSON</el-radio-button>
|
||
</el-radio-group>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<el-upload
|
||
v-if="importMode==='file'"
|
||
:show-file-list="false"
|
||
accept=".xlsx,.xls,.csv"
|
||
:auto-upload="false"
|
||
:on-change="onUploadChange"
|
||
>
|
||
<el-button type="primary" :loading="parsing">选择Excel/CSV</el-button>
|
||
</el-upload>
|
||
|
||
<el-button v-else type="primary" :loading="parsing" @click="onParseJson">解析JSON</el-button>
|
||
<el-button @click="onDownloadTemplate">下载模板</el-button>
|
||
<div class="flex-1" />
|
||
<el-switch :model-value="skipDuplicates" @change="onSkipDuplicatesChange" />
|
||
<span class="text-xs text-zinc-600">跳过重复订单号</span>
|
||
</div>
|
||
|
||
<el-input
|
||
v-if="importMode==='json' && !hasPreview"
|
||
v-model="jsonText"
|
||
type="textarea"
|
||
:rows="10"
|
||
placeholder="粘贴 JSON 数组(例如 [{...},{...}]),字段支持 orderNo/poNo/product/country/factory/contractStatus/orderProgress/remarks 等"
|
||
/>
|
||
|
||
<el-alert
|
||
v-if="hasPreview"
|
||
type="info"
|
||
show-icon
|
||
:closable="false"
|
||
:title="`共 ${previewRows.length} 行;错误 ${errorCount} 行;警告 ${warnCount} 条;重复 ${duplicateCount} 行;已选择 ${selectedCount} 行导入`"
|
||
/>
|
||
|
||
<el-table
|
||
v-if="hasPreview"
|
||
ref="tableRef"
|
||
:data="previewRows"
|
||
stripe
|
||
class="w-full"
|
||
height="520"
|
||
:row-key="(r) => r.index"
|
||
reserve-selection
|
||
:row-class-name="rowClassName"
|
||
@selection-change="onSelectionChange"
|
||
>
|
||
<el-table-column type="selection" width="48" :selectable="isRowSelectable" />
|
||
<el-table-column prop="index" label="#" width="60" />
|
||
<el-table-column label="订单号" min-width="140">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.orderNo }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="产品" min-width="160">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.productName }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="国别" width="100">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.country }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="工厂" width="160">
|
||
<template #default="scope">
|
||
<span>{{ displayFactory(scope.row) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="金额" width="120">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.orderAmount ?? '' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="下单" width="110">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.orderDate ?? '' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="客人货期" width="110">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.customerDeliveryDate ?? '' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="进度" width="110">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.orderProgress ?? '下单' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="合同" width="140">
|
||
<template #default="scope">
|
||
<span>{{ scope.row.cleaned.factoryContract ?? '' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="问题" min-width="260">
|
||
<template #default="scope">
|
||
<div class="space-y-1">
|
||
<div v-for="(it, idx) in scope.row.issues" :key="idx" class="text-xs" :class="it.severity==='error' ? 'text-red-600' : 'text-amber-600'">
|
||
{{ it.severity === 'error' ? '错误' : '警告' }}:{{ it.message }}
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<el-button @click="close">取消</el-button>
|
||
<el-button v-if="hasPreview" @click="reset" :disabled="parsing || importing">清空</el-button>
|
||
<el-button type="primary" :loading="importing" :disabled="parsing" @click="onImport">开始导入</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|