Merge origin/release/test and preserve local frontend changes

This commit is contained in:
2026-10-20 11:48:38 +08:00
265 changed files with 14131 additions and 5723 deletions
@@ -3,6 +3,7 @@ import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -105,32 +106,33 @@ async function save() {
</script>
<template>
<AppDialog class="admin-density-dialog" v-model="open" title="派送单所需资料" width="min(1040px, calc(100vw - 40px))" confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy" :confirm-disabled="conflict || loading" @confirm="save">
<div class="delivery-documents">
<div class="document-identifiers"><label>派送单号<el-input :model-value="deliveryNo" readonly /></label><label>BOL号<el-input v-model="bolNo" :readonly="!canEdit" :disabled="busy || conflict" placeholder="未填写" /></label></div>
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<AppDialog class="admin-density-dialog" v-model="open" :title="'派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/3 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
<template v-if="snapshot">
<div class="document-summary"><span>{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</span><strong>{{ uploadedCount }}/3 已上传</strong></div>
<div class="document-groups">
<section v-for="group in draft" :key="group.category" class="document-group" :data-category="group.category">
<header><strong>{{ group.label }}</strong><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="document-empty">尚未上传文件</p>
<div v-for="file in group.files" :key="file.fileId" class="document-file">
<div class="document-file-info"><button type="button" class="document-file-name" :title="file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">{{ file.fileName }}</button><small>{{ file.uploadedAt?.replace('T', ' ') || '—' }}{{ isSaved(group.category, file.fileId) ? '' : ' · 待保存' }}</small></div>
<template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template>
</div>
</section>
</div>
<section class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" placeholder="未填写" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
<section v-for="group in draft" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
<header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
<table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-time">上传时间</th><th class="document-file-state">保存状态</th></tr></thead><tbody>
<tr v-for="file in group.files" :key="file.fileId" class="document-file">
<td><div class="density-actions"><el-button link :aria-label="'下载 ' + file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">下载</el-button><template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template></div></td>
<td><LineClampText :content="file.fileName" /></td><td>{{ file.uploadedAt?.replace('T', ' ') || '—' }}</td><td>{{ isSaved(group.category, file.fileId) ? '已保存' : '待保存' }}</td>
</tr>
</tbody></table>
</section>
</template>
</div>
</AppDialog>
</template>
<style scoped>
.delivery-documents { display:grid; gap:var(--admin-space-5); padding:2px var(--admin-space-2) var(--admin-space-1) 0; }
.document-identifiers { display:grid; grid-template-columns:1fr 1fr; gap:var(--admin-space-5); }.document-identifiers label { display:grid; gap:var(--admin-space-3); font-weight:600; }
.document-summary { display:flex; justify-content:space-between; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-5) var(--admin-space-5); background:var(--el-color-primary-light-9); color:var(--el-color-primary); border-radius:var(--admin-radius); }.document-summary strong { white-space:nowrap; }
.document-groups { border:1px solid var(--cc-border); border-radius:var(--admin-radius); overflow:hidden; }.document-group { padding:var(--admin-space-5) var(--admin-space-5); }.document-group + .document-group { border-top:1px solid var(--cc-border); }
.document-group header { display:flex; align-items:center; gap:var(--admin-space-4); }.document-group header > span { color:var(--cc-muted); font-size:var(--admin-font-caption); }.document-group header :deep(.el-upload) { margin-left:auto; }.document-group header > :last-child { margin-left:auto; }
.document-file { display:flex; align-items:center; gap:var(--admin-space-5); padding-top:var(--admin-space-4); }.document-file-info { display:grid; min-width:0; flex:1; gap:var(--admin-space-1); }.document-file-name { border:0; background:none; padding:0; text-align:left; color:var(--cc-primary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; cursor:pointer; font:inherit; }.document-file-name:disabled { color:var(--cc-muted); cursor:default; }.document-file-info small,.document-empty { color:var(--cc-muted); }.document-empty { margin:var(--admin-space-4) 0 0; }
.document-group header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
.document-group header h3 { margin: 0; }
.document-group header > :last-child { margin-left: auto; }
.document-files { table-layout: fixed; }
.document-file-actions { width: 220px; }
.document-file-time { width: 170px; }
.document-file-state { width: 90px; }
</style>
@@ -0,0 +1,35 @@
import ElementPlus from 'element-plus'
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import type { ArrangeGroup, CargoLine, PreviewGroup } from '../dispatchContracts'
import { destination } from '../dispatchTestFixtures'
const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}`, sourceOrigin: null }) as CargoLine
function values() {
const preview: PreviewGroup = { groupKey: 'group-1', customerId: 'C1', customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo('A'), cargo('B')], externalFreightHash: 'external-hash', externalQuotes: [], feeCoverageCargoHash: 'coverage-hash', feeSources: [{ financeFeeId: 'fee-1', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 2, sourceHash: 'fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE', 'LOADING_FEE'], sourceKeys: ['A'] }], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }
const group: ArrangeGroup = { groupKey: 'group-1', groupCargoHash: 'group-cargo', plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: 'PALLET', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: 'external-hash', feeCoverageCargoHash: 'coverage-hash', feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: feeType, direction: 'RECEIVABLE', currency: 'USD', amount: null })) }
return { preview, group }
}
it('requires explicit per-service coverage and preserves uncovered cargo instead of treating a partial fee as full coverage', async () => {
const { preview, group } = values()
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const covered = (wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }).covered
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(group.feeCoverage).toEqual([{ financeFeeId: 'fee-1', sourceVersion: 2, sourceHash: 'fee-hash', services: ['DELIVERY_FEE'] }])
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
preview.feeSources[0]!.sourceKeys.push('B'); expect(covered(0, preview, 'DELIVERY_FEE')).toBe(true)
expect(covered(0, preview, 'LOADING_FEE')).toBe(false)
preview.feeSources[0]!.customerId = 'C2'; expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false); wrapper.unmount()
})
it('matches external quotes to the original customer and inbound source and shows only applicable additional fees', async () => {
const { preview, group } = values(); preview.cargo = [cargo('A')]
preview.externalQuotes = [{ customerId: 'C2', groupKey: 'group-1', bookingNo: 'BOOK-1', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'external-source' }]
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }
expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(false)
expect(wrapper.findAll('.el-input-number')).toHaveLength(1)
preview.externalQuotes[0]!.customerId = 'C1'; expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(true)
group.loadingType = 'FLOOR'; group.appointmentAccount = 'BUY_APPOINTMENT'; await wrapper.setProps({ modelValue: [{ ...group }] })
expect(wrapper.findAll('.el-input-number')).toHaveLength(2); expect(wrapper.text()).toContain('卖家姓名'); wrapper.unmount()
})
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { ArrangeGroup, PreviewGroup } from '../dispatchContracts'
const groups = defineModel<ArrangeGroup[]>({ required: true })
defineProps<{ preview: PreviewGroup[]; disabled: boolean }>()
const serviceLabels: Record<string, string> = { DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' }
function setCoverage(index: number, fee: PreviewGroup['feeSources'][number], service: string, checked: unknown) {
const group = groups.value[index]; if (!group) return
let selection = group.feeCoverage.find(row => row.financeFeeId === fee.financeFeeId)
if (!selection && checked === true) { selection = { financeFeeId: fee.financeFeeId, sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: [] }; group.feeCoverage.push(selection) }
if (!selection) return
selection.services = checked === true ? [...new Set([...selection.services, service])] : selection.services.filter(value => value !== service)
group.feeCoverage = group.feeCoverage.filter(row => row.services.length)
}
function covered(index: number, preview: PreviewGroup, service: string) {
if (!preview.cargo.length) return false
const selections = groups.value[index]?.feeCoverage || []
return preview.cargo.every(cargo => service === 'DELIVERY_FEE' && preview.externalQuotes.some(quote => quote.customerId === cargo.customerId && quote.inboundCode === cargo.inboundCode)
|| preview.feeSources.some(fee => fee.customerId === cargo.customerId && fee.sourceKeys.includes(cargo.sourceKey) && selections.some(selection => selection.financeFeeId === fee.financeFeeId && selection.services.includes(service))))
}
function required(index: number, preview: PreviewGroup, service: string) {
const group = groups.value[index]
return preview.requiredServices.includes(service)
|| service === 'LOADING_FEE' && ['FLOOR', 'MIXED'].includes(group?.loadingType || '')
|| service === 'APPOINTMENT_FEE' && group?.appointmentAccount === 'BUY_APPOINTMENT'
}
defineExpose({ covered })
</script>
<template>
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group density-dialog-sections">
<template v-if="groups[index]">
<section class="density-dialog-section" data-tone="source" :aria-label="'派送分组依据 ' + (index + 1)"><h3 class="density-dialog-section__title">{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
<el-alert v-for="reason in previewGroup.blockReasons" :key="reason" :title="reason" type="warning" :closable="false" show-icon />
<el-table :data="previewGroup.cargo" border><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" show-overflow-tooltip /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
<template v-if="previewGroup.externalQuotes.length"><h4 class="density-dialog-section__title">外接约定运费</h4><table class="density-table"><thead><tr><th>外接预订号</th><th>入仓码</th><th>币种</th><th>金额</th></tr></thead><tbody><tr v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo"><td>{{ quote.bookingNo }}</td><td>{{ quote.inboundCode }}</td><td>{{ quote.currency }}</td><td>{{ quote.amount }}</td></tr></tbody></table></template>
<h4 class="density-dialog-section__title">原费用覆盖</h4><p v-if="!previewGroup.feeSources.length" class="density-dialog-note">暂无可引用原费用。</p>
<table v-else class="density-table"><thead><tr><th>覆盖服务</th><th>币种</th><th>金额</th><th>状态</th></tr></thead><tbody><tr v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><td><el-checkbox v-for="service in fee.services" :key="service" :disabled="disabled" :model-value="groups[index]!.feeCoverage.some(row => row.financeFeeId === fee.financeFeeId && row.services.includes(service))" @update:model-value="setCoverage(index, fee, service, $event)">已包含{{ serviceLabels[service] || '服务费用' }}</el-checkbox></td><td>{{ fee.currency }}</td><td>{{ fee.amount }}</td><td>{{ fee.status }}</td></tr></tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" :aria-label="'派送分组登记 ' + (index + 1)"><h3 class="density-dialog-section__title">派送信息</h3>
<table class="density-table density-table--editable arrange-editor-table"><tbody>
<tr><td><el-form-item label="预计派送时间" required><el-date-picker v-model="groups[index]!.plannedDeliveryAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择派送时间" /></el-form-item></td><td><el-form-item label="派送方式" required><el-select v-model="groups[index]!.deliveryMethod" :disabled="disabled" placeholder="请选择"><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="提货方式" required><el-select v-model="groups[index]!.pickupMethod" :disabled="disabled" placeholder="请选择"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item></td><td><el-form-item label="装车类型" required><el-select v-model="groups[index]!.loadingType" :disabled="disabled" placeholder="请选择"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item></td><td><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item></td></tr>
<tr><td><el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item></td><td><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item></td></tr>
<tr><td><el-form-item label="预约账号"><el-select v-model="groups[index]!.appointmentAccount" :disabled="disabled" clearable placeholder="请选择"><el-option label="自有账号" value="SELF" /><el-option label="买约" value="BUY_APPOINTMENT" /></el-select></el-form-item></td><td><el-form-item v-if="groups[index]!.appointmentAccount === 'BUY_APPOINTMENT'" label="卖家姓名" required><el-input v-model="groups[index]!.sellerName" :disabled="disabled" placeholder="请输入卖家姓名" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item></td></tr>
</tbody></table>
<h4 class="density-dialog-section__title">本组新增费用</h4><table class="density-table density-table--editable"><tbody><tr v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><td><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="(serviceLabels[fee.feeType] || fee.feeName) + '(未覆盖部分)'" required><div class="arrange-fee-controls"><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" controls-position="right" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></div></el-form-item><p v-else class="density-dialog-note">本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></td></tr></tbody></table>
</section>
</template>
</section>
</template>
<style scoped>
.arrange-editor-table { table-layout: fixed; }
.arrange-editor-table td:not([colspan]) { width: 50%; }
.arrange-fee-controls { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 120px; gap: var(--admin-space-3); }
.arrange-group h4 { margin-top: var(--admin-space-4); }
</style>
@@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import * as api from '../api/dispatches'
import type { DispatchEvidenceFile } from '../dispatchContracts'
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
it('previews and replaces real string-size metadata through the JSON parser and API boundary', async () => {
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
const envelope = (fileId: string) => parseApiJsonResponse(JSON.stringify({ code: '200', data: { ...metadata(fileId), sizeBytes: '3' } }))
const get = vi.spyOn(http, 'get').mockResolvedValue(envelope('9007199254740993'))
const post = vi.spyOn(http, 'post').mockResolvedValue(envelope('9007199254740994'))
vi.mocked(api.getDispatchEvidence).mockImplementation(actual.getDispatchEvidence); vi.mocked(api.uploadDispatchEvidence).mockImplementation(actual.uploadDispatchEvidence)
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: ['9007199254740993'] } })
try {
await flushPromises(); expect(wrapper.text()).toContain('本车证据.png')
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
await button(wrapper, '关闭预览').trigger('click'); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['9007199254740994']]); expect(post).toHaveBeenCalledOnce()
} finally { wrapper.unmount(); get.mockRestore(); post.mockRestore() }
})
it('reads owned metadata without displaying IDs or downloading content until explicitly previewed, including read-only mode', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, readonly: true } }); await flushPromises()
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('本车证据.png'); expect(wrapper.text()).not.toContain('301'); expect(wrapper.find('input').exists()).toBe(false)
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
await button(wrapper, '关闭预览').trigger('click'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); wrapper.unmount()
})
it('retains original references when metadata or replacement upload fails and preserves request diagnostics', async () => {
vi.mocked(api.getDispatchEvidence).mockRejectedValueOnce(new ApiResponseError('Forbidden', 'A0403', 403, undefined, 'request-1', 'trace-1'))
const failed = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(failed.emitted('update:modelValue')).toBeUndefined(); expect(failed.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-1', traceId: 'trace-1' }); failed.unmount()
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('unknown upload'))
await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' })); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('原附件引用保留'); wrapper.unmount()
})
it('replaces the selected reference only after a valid successful upload and rejects unsupported types locally', async () => {
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await selectFile(wrapper, new File(['exe'], 'file.exe', { type: 'application/octet-stream' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('10MiB以内')
await button(wrapper, '替换').trigger('click'); const file = new File(['png'], 'new.png', { type: 'image/png' }); await selectFile(wrapper, file)
expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', file); expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); expect(wrapper.emitted('busy')?.slice(-2)).toEqual([[true], [false]]); wrapper.unmount()
})
it('ignores a late upload after switching dispatches instead of binding the file to the new vehicle', async () => {
let resolve!: (value: DispatchEvidenceFile) => void; vi.mocked(api.uploadDispatchEvidence).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
await wrapper.setProps({ dispatchNo: 'PC/2' }); resolve(metadata('302')); await flushPromises(); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
})
it('does not turn business-error JSON into an image and retains diagnostic evidence', async () => {
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce({ type: 'application/json', size: 100, text: async () => JSON.stringify({ code: 'A0403', message: 'Forbidden', requestId: 'request-content', traceId: 'trace-content' }) } as Blob)
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(wrapper.find('img').exists()).toBe(false); expect(wrapper.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-content', traceId: 'trace-content' }); wrapper.unmount()
})
it('ignores content arriving after preview close and cleans up object URLs on unmount', async () => {
let resolve!: (value: Blob) => void; vi.mocked(api.getDispatchEvidenceContent).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await button(wrapper, '关闭预览').trigger('click'); resolve(new Blob(['png'], { type: 'image/png' })); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled()
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
})
it('opens PDF in a user-requested window using owned blob content and never an anonymous URL', async () => {
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
const popup = { location: { href: '' }, close: vi.fn() }; const open = vi.spyOn(window, 'open').mockReturnValueOnce(popup as unknown as Window)
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(open).toHaveBeenCalledWith('', '_blank'); expect(popup.location.href).toBe('blob:authenticated'); expect(wrapper.find('img').exists()).toBe(false); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); open.mockRestore()
})
it('rejects mismatched file identity and a blocked PDF popup without fetching content', async () => {
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('wrong-file'))
const mismatch = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(button(mismatch, '预览').attributes('disabled')).toBeDefined(); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); mismatch.unmount()
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null)
const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore()
})
@@ -0,0 +1,93 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean }>()
const ids = defineModel<string[]>({ required: true })
const emit = defineEmits<{ busy: [value: boolean] }>()
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const label = computed(() => props.kind === 'VEHICLE' ? '本趟车辆证据' : '本趟司机证照')
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
function valid(value: DispatchEvidenceFile, expected?: string) {
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
return { ...value, fileId: String(value.fileId) }
}
function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback) }
function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...ids.value]
closePreview(); metadata.value = {}; error.value = null
if (!no) return
const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id)))
if (current !== generation) return
results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') })
}
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
if (!file || props.readonly || props.disabled || uploading.value) return
error.value = null
if (!types.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError('请选择10MiB以内的PDF、JPG或PNG文件'), '附件不符合要求'); return }
const current = generation, no = props.dispatchNo, kind = props.kind, replaceId = replacing
uploading.value = true; emit('busy', true)
try {
const value = valid(await uploadDispatchEvidence(no, kind, file))
if (current !== generation) return
if (replaceId && !ids.value.includes(replaceId)) throw new DispatchInputError('原附件选择已变化,请重新核对')
ids.value = [...new Set(replaceId ? ids.value.map(id => id === replaceId ? value.fileId : id) : [...ids.value, value.fileId])]
} catch (cause) { if (current === generation) fail(cause, '上传结果未确认,原附件引用保留,请核对后重试') }
finally { uploading.value = false; emit('busy', false) }
}
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
async function preview(id: string) {
const file = metadata.value[id]; if (!file || previewLoading.value) return
closePreview(); const current = ++previewGeneration, scope = generation
error.value = null; previewLoading.value = true
if (file.contentType === 'application/pdf') { pendingPopup = window.open('', '_blank'); if (!pendingPopup) { previewLoading.value = false; fail(new DispatchInputError('请允许本站打开PDF预览窗口'), 'PDF预览未打开'); return } }
else { imageName.value = file.fileName; imageOpen.value = true }
try {
const blob = await getDispatchEvidenceContent(props.dispatchNo, props.kind, id)
if (current !== previewGeneration || scope !== generation) return
const type = blob.type.split(';')[0]?.toLowerCase()
if (type?.includes('json')) {
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') }
throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId)
}
if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致')
const url = URL.createObjectURL(blob); urls.add(url)
if (file.contentType === 'application/pdf') { pendingPopup!.location.href = url; pendingPopup = null; window.setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url) }, 60_000) }
else imageUrl.value = url
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}</h4>
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
<p v-else class="density-dialog-note">暂无本趟附件。</p>
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button><p class="density-dialog-note">PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
</AppDialog>
</section>
</template>
<style scoped>
.dispatch-evidence-table { table-layout: fixed; }
.dispatch-evidence__actions { width: 190px; }
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
.evidence-input { display: none; }
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
</style>
@@ -0,0 +1,28 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchResourceFields from './DispatchResourceFields.vue'
import { resources } from '../dispatchTestFixtures'
import { listDrivers, listVehicles } from '@/modules/overseas-master-data'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).toContain('CA-100'); expect(wrapper.text()).toContain('司机甲'); expect(wrapper.text()).not.toContain('90071992547409930'); wrapper.unmount()
})
it.each([false, true])('resets only the switched resource and keeps choices independent (continuous=%s)', async continuous => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { changeDriver(): void }; state.changeDriver(); await flushPromises()
expect(wrapper.emitted('update:driver')![0]![0]).toMatchObject({ mode: 'EXTERNAL', driverId: null, name: null, evidence: { fileIds: [], licenseNo: null } })
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(value.vehicle.vehicleId).toBe('vehicle-1'); wrapper.unmount()
})
it.each([false, true])('requires each directory permission before reading candidates (continuous=%s)', async continuous => {
can.mockReturnValue(false); const value = resources()
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
})
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref } from 'vue'
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Driver, Vehicle } from '../dispatchContracts'
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
const driver = defineModel<Driver>('driver', { required: true })
defineProps<{ disabled: boolean; continuous?: boolean }>()
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
let vehicleSequence = 0, driverSequence = 0
async function searchVehicles(keyword = '') {
if (!access.can(resourcePermission.vehicleList)) { error.value = '缺少车辆档案读取权限'; return }
const sequence = ++vehicleSequence
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (sequence === vehicleSequence) vehicles.value = result.records }
catch (cause) { if (sequence === vehicleSequence) error.value = cause instanceof Error ? cause.message : '车辆候选读取失败' }
}
async function searchDrivers(keyword = '') {
if (!access.can(resourcePermission.driverList)) { error.value = '缺少司机档案读取权限'; return }
const sequence = ++driverSequence
try { const result = await listDrivers({ page: 1, size: 100, keyword, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: driver.value.evidence?.expectedTaskEndAt || undefined }); if (sequence === driverSequence) drivers.value = result.records }
catch (cause) { if (sequence === driverSequence) error.value = cause instanceof Error ? cause.message : '司机候选读取失败' }
}
function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } }
function changeDriver() { driver.value = { mode: driver.value.mode, driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
function selectVehicle(id: string) {
const selected = vehicles.value.find(row => row.id === id)
vehicle.value = { mode: 'MASTER', vehicleId: id, plate: selected?.plateNumber || null, carrierName: null, capacity: null, evidenceFileIds: [] }
}
function selectDriver(id: string) {
const selected = drivers.value.find(row => row.id === id), end = driver.value.evidence?.expectedTaskEndAt || null
driver.value = { mode: 'MASTER', driverId: id, name: selected?.details.name || null, phone: selected?.details.phone || null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: end } }
}
</script>
<template>
<template v-if="continuous">
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
<tr><td><el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
<template v-if="vehicle.mode === 'EXTERNAL'">
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
</template>
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr>
</tbody></table>
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" class="density-dialog-note" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
</template>
<template v-else>
<p v-if="error" role="alert">{{ error }}</p>
<div class="resource-grid">
<el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
<template v-else>
<el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item>
<template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template>
</template>
<el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item>
<el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item>
<template v-else><el-form-item label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item><el-form-item v-if="driver.evidence" label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></template>
<el-form-item v-if="driver.evidence" label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
</div>
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
</template>
</template>
<style scoped>
.dispatch-resource-table { table-layout: fixed; }
.dispatch-resource-table td:not([colspan]) { width: 50%; }
.resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.resource-grid :deep(.el-select), .resource-grid :deep(.el-date-editor), .resource-grid :deep(.el-input-number) { width: 100%; }
</style>
@@ -0,0 +1,217 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/dispatches'
import { command, detail, destination, loadingPreview, page, resources, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchCargoItem, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const gate = { allowed: true, blockCode: null, blockReason: null }
const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate }
const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 })
function item(id: string, picked = 0): DispatchCargoItem { return { dispatchItemId: id, customerId: 'C1', customerName: '客户甲', sourceKey: 'source-1', sourceVersion: 'source-v1', sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', palletNo: 'PALLET-1', orderNo: 'ORDER-1', inboundCode: 'IN-1', destination: destination(), scheduledPackages: 2, pickedPackages: picked, remove: picked ? { allowed: false, blockCode: 'PICKED', blockReason: '已经扫码,不可移除' } : gate } }
function cargo(key: string, customerId: string): CargoLine { return { dispatchItemId: 'item-' + key, customerId, customerName: '客户' + customerId, orderId: null, orderNo: 'ORDER-' + key, sourceKey: key, sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: null, inboundCode: 'IN-' + key, planPalletId: null, warehousePalletId: 'pallet-' + key, palletNo: 'PALLET-' + key, pickId: null, attributionId: null, inventoryPositionId: null, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: 'BOX-' + key, relabelNo: null, destination: destination(), packages: 2, productQuantity: 4, grossWeightKg: 10, volumeCbm: 1, batchAllocations: [], sourceOrigin: null } }
function arrangement(): ArrangementPreview {
const group = (key: string, customerId: string): PreviewGroup => ({ groupKey: key, customerId, customerName: '客户' + customerId, destination: destination(), groupCargoHash: 'cargo-' + key, cargo: [cargo(key, customerId)], externalFreightHash: 'external-' + key, externalQuotes: [], feeCoverageCargoHash: 'coverage-' + key, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] })
const first = group('A', 'C1'), second = group('B', 'C2')
first.feeSources = [{ financeFeeId: 'original-fee', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 0, sourceHash: 'original-fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE'], sourceKeys: ['A'] }]
second.externalQuotes = [{ customerId: 'C2', groupKey: 'B', bookingNo: 'BOOK-2', inboundCode: 'IN-B', currency: 'USD', amount: 200, sourceHash: 'quote-hash' }]
return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', arrange: gate, groups: [first, second], arrangement: null }
}
beforeEach(() => {
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
URL.createObjectURL = vi.fn().mockReturnValue('blob:owned-dispatch'); URL.revokeObjectURL = vi.fn()
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, actions }))
vi.mocked(api.getDispatchCommand).mockRejectedValue(new ApiResponseError('not found', 'A0404', 404))
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.reviewDispatch).mockImplementation(async (_, body) => command('DISPATCH_REVIEW', body.idempotencyKey))
vi.mocked(api.reviewDispatchLoading).mockImplementation(async (_, body) => command('LOADING_REVIEW', body.idempotencyKey))
vi.mocked(api.cancelDispatch).mockImplementation(async (_, body) => command('CANCEL', body.idempotencyKey))
vi.mocked(api.returnDispatchToReview).mockImplementation(async (_, body) => command('RETURN_TO_REVIEW', body.idempotencyKey))
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.departDispatch).mockImplementation(async (_, body) => command('DEPART', body.idempotencyKey))
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => command('ADD_CARGO', body.idempotencyKey))
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => command('REMOVE_CARGO', body.idempotencyKey))
vi.mocked(api.reorderDispatchRoute).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview())
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] })
vi.mocked(api.listDispatches).mockResolvedValue(page())
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302'))
})
async function render(action: DispatchAction, operation?: 'ROUTE', files = false) {
const initial = detail({ actions }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
vi.mocked(api.getDispatch).mockResolvedValueOnce(initial)
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action, operation }, global: { plugins: [ElementPlus] } }); await flushPromises()
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
}
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
describe('dispatch workflow native layout', () => {
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
const { dialog } = await render('EDIT')
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
await field(dialog, '司机姓名').get('input').setValue('新司机')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('维护备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
})
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT')
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('保留本次输入')
dialog.vm.$emit('confirm'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
expect(dialog.text()).toContain('workflow-request')
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
expect((dialog.get('textarea[placeholder="请输入本次备注(选填)"]').element as HTMLTextAreaElement).value).toBe('保留本次输入')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
const { dialog } = await render(action)
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
expect(dialog.text()).toContain('审核通过不代替后续现场扫码')
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
})
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW', 'DEPART'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
const { dialog } = await render(action)
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : action === 'DEPART' ? api.departDispatch : api.submitDispatchReview
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
dialog.vm.$emit('confirm'); await flushPromises()
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
})
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
const { dialog } = await render('EDIT', 'ROUTE')
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
expect(rows).toHaveLength(2)
expect(rows[0]!.findAll('button')[0]!.attributes('disabled')).toBeDefined()
await rows[1]!.findAll('button').find(button => button.text() === '上移')!.trigger('click'); await flushPromises()
await dialog.get('textarea').setValue(' 顺序调整 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
})
it('removes the real item identity while keeping scanned cargo unavailable', async () => {
const { dialog } = await render('REMOVE_CARGO')
const radios = dialog.get('[aria-label="本车可移除货物"]').findAllComponents({ name: 'ElRadio' })
expect(radios[1]!.props('disabled')).toBe(true)
radios[0]!.vm.$emit('update:modelValue', '9223372036854775001'); await dialog.get('textarea').setValue(' 计划变更 ')
expect(dialog.text()).not.toContain('9223372036854775001')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9223372036854775001', { recordVersion: '9007199254740993', idempotencyKey: expect.any(String), reason: '计划变更' })
})
it('adds the complete filtered shared-pallet group and keeps every source visible when expanded', async () => {
const [first, second] = sharedPalletCandidates()
const { dialog } = await render('ADD_CARGO')
const source = dialog.get('details')
await source.get('summary').trigger('click'); await flushPromises()
expect(source.findAll('li')).toHaveLength(2)
await dialog.get('input[type="checkbox"]').setValue(true); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), cargo: [first!.selection, second!.selection], batchAllocations: [] })
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
})
it('keeps references separate from real loading measurements and submits the exact manifest', async () => {
const { dialog } = await render('LOADING_COMPLETE')
const numbers = dialog.get('[aria-label="真实实载登记"]').findAllComponents({ name: 'ElInputNumber' })
expect(numbers[1]!.props('modelValue')).toBeNull()
expect(dialog.get('[aria-label="实载与参考依据"]').text()).toContain('仅供参考')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.completeDispatchLoading).not.toHaveBeenCalled()
numbers[0]!.vm.$emit('update:modelValue', 1); numbers[1]!.vm.$emit('update:modelValue', 120); numbers[2]!.vm.$emit('update:modelValue', 1.2); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 120, volumeCbm: 1.2, deliveryIndex: 1 }], remark: '真实备注' })
})
it('keeps grouped delivery fields, explicit old-fee coverage and only actual uncovered charges', async () => {
const { dialog } = await render('ARRANGE_DELIVERY')
const sections = dialog.findAll('[data-tone="work"]').filter(section => section.attributes('aria-label')?.startsWith('派送分组登记'))
expect(sections).toHaveLength(2)
const groups = dialog.getComponent(DispatchArrangeFields).props('modelValue')
await dialog.get('[aria-label="派送分组依据 1"] input[type="checkbox"]').setValue(true); await flushPromises()
for (let index = 0; index < 2; index++) {
const section = sections[index]!
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'ONE_PICKUP_ONE_DROP'], ['装车类型', index ? 'PALLET' : 'FLOOR']] as const) field(section, label).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(section, '预计派送时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
}
field(sections[0]!, '预约账号').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'BUY_APPOINTMENT'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
await field(sections[0]!, '卖家姓名').get('input').setValue('卖家甲')
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CNY')
field(sections[0]!, '买约费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40); await flushPromises()
expect(groups[0]!.feeCoverage).toEqual([{ financeFeeId: 'original-fee', sourceVersion: 0, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }])
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.transferDispatchToDelivery).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', groups: [expect.objectContaining({ groupKey: 'A', sellerName: '卖家甲', feeCoverage: groups[0]!.feeCoverage, fees: [expect.objectContaining({ feeType: 'LOADING_FEE', amount: 25, currency: 'CNY' }), expect.objectContaining({ feeType: 'APPOINTMENT_FEE', amount: 40, currency: 'USD' })] }), expect.objectContaining({ groupKey: 'B', fees: [] })] }))
})
it('previews owned images fullscreen and locks the parent while a real file reference upload is pending', async () => {
let resolve!: (value: DispatchEvidenceFile) => void
vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await render('EDIT', undefined, true)
const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]!
await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
const image = files.findComponent(AppDialog)
expect(image.props('modelValue')).toBe(true)
expect(image.get('img').attributes('src')).toBe('blob:owned-dispatch')
await image.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await image.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-dispatch')
const input = files.get('input[type="file"]'), file = new File(['png'], 'new.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled()
resolve(evidence('302')); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(false)
expect(files.props('modelValue')).toEqual(['301', '302'])
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: expect.objectContaining({ evidenceFileIds: ['301', '302'] }) }))
})
it('keeps server gates and write permissions independent from readable facts', async () => {
can.mockReturnValue(false)
const { dialog } = await render('EDIT')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,157 @@
import ElementPlus, { ElMessage } from 'element-plus'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import * as api from '../api/dispatches'
import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
interface State { submit(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
const [first, second] = sharedPalletCandidates()
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable<typeof first>, checked: boolean): void }
selection.selectCargo(first!, true)
first!.selectionGroup = { ...first!.selectionGroup, groupVersion: 'composition-2' }
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('组成已变化')
first!.selectionGroup.complete = false; selection.selectCargo(first!, false)
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整')
first!.selectionGroup.complete = true
selection.selectCargo(first!, true)
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] }))
wrapper.unmount()
})
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
try {
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true)
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit()
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, batchAllocations: [{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }] }))
} finally { wrapper.unmount() }
})
it('blocks cargo addition when the authoritative batch version is absent', async () => {
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
try {
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit()
expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
} finally { wrapper.unmount() }
})
it('removes an unpicked authoritative item after real JSON and API normalization of pickedPackages string zero', async () => {
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
const source = candidate()
const data = { dispatchNo: 'PC/1', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: '2', pickedPackages: '0', remove: { allowed: true, blockCode: null, blockReason: null } }] }
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data })))
vi.mocked(api.getDispatchItems).mockImplementation(actual.getDispatchItems)
const { wrapper, state } = await open('REMOVE_CARGO')
try {
(wrapper.vm as unknown as { itemId: string }).itemId = '9007199254740993'; state.form.reason = '本车计划调整'
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, request) => command('REMOVE_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit()
expect(api.removeDispatchItem).toHaveBeenCalledWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: '0', reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1)
} finally { wrapper.unmount(); get.mockRestore() }
})
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
return { wrapper, state: wrapper.vm as unknown as State }
}
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
it('keeps order allocation references out of actual measurements and submits native version zero only after real input', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
const { wrapper, state } = await open('LOADING_COMPLETE')
expect(state.measurements[0]).toMatchObject({ weightKg: null, volumeCbm: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('真实托位')
state.measurements[0]!.weightKg = 92; state.measurements[0]!.volumeCbm = 1.5
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING', recordVersion: 1 }))
await state.submit()
expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }))
expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 92; value.pallets[0]!.volumeCbm = 1.5; value.pallets[0]!.deliveryIndex = null
value.pallets[0]!.evidence.push({ field: 'weightKg', actual: true, value: 92, sourceType: 'MEASURED', sourceBusinessNo: 'PALLET-1', observedAt: null })
value.confirmedLoad = { manifestHash: 'manifest', operatorId: 'operator', confirmedAt: '2026-10-19T00:00:00Z', pallets: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it('requires a rejection reason and retains the full original command after an unknown review result', async () => {
const { wrapper, state } = await open('DISPATCH_REVIEW'); state.form.decision = 'REJECTED'
await state.submit(); expect(api.reviewDispatch).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(api.reviewDispatch).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('DISPATCH_REVIEW', key))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.reviewDispatch).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.reviewDispatch).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('blocks an action without a literal service allowance', async () => {
const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
})
it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2, sourceOrigin: null } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null })
const { wrapper, state } = await open('ARRANGE_DELIVERY')
const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups
groups.forEach(group => Object.assign(group, { plannedDeliveryAt: '2026-10-20T00:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'ONE_PICKUP_MULTI_DROP', loadingType: 'PALLET' }))
await state.submit(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
groups[1]!.fees.find(fee => fee.feeType === 'DELIVERY_FEE')!.amount = 77
vi.mocked(api.transferDispatchToDelivery).mockImplementation(async (_, body): Promise<CommandResult> => ({ ...command('ARRANGE_DELIVERY', body.idempotencyKey), action: 'ARRANGE_DELIVERY', result: { dispatchNo: 'PC/1', recordVersion: 1, arrangementKey: 'arrangement', cargoHash: 'whole-cargo', arrangedAt: '2026-10-19T00:00:00Z', outbounds: [], deliveries: [] } }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, stage: 'AFTER_LOADING' })); await state.submit()
const sent = vi.mocked(api.transferDispatchToDelivery).mock.calls[0]![1]
expect(sent).toMatchObject({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash' })
expect(sent.groups).toHaveLength(2); expect(sent.groups[0]!.fees).toEqual([]); expect(sent.groups[1]!.fees).toEqual([expect.objectContaining({ feeType: 'DELIVERY_FEE', amount: 77 })])
expect(api.getDispatchArrangement).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
const { wrapper, state } = await open('DEPART')
let resolve!: (value: DispatchDetail) => void
vi.mocked(api.getDispatch).mockImplementationOnce(() => new Promise(done => { resolve = done }))
await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled()
resolve(detail({ dispatchNo: 'PC/2', actions: { DEPART: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
})
it('removes only the authoritative unpicked item using its item ID as path identity and retains that original target on recovery', async () => {
const source = candidate()
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: { allowed: true, blockCode: null, blockReason: null } }] })
const { wrapper, state } = await open('REMOVE_CARGO'); const target = wrapper.vm as unknown as { itemId: string }; target.itemId = '9007199254740993'
await state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled()
state.form.reason = '本车计划调整'; vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown')); await state.submit()
target.itemId = 'another-item'; state.form.reason = 'later changed'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update', async () => {
const toast = vi.spyOn(ElMessage, 'success')
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: '实际起点', destinationKey: null }, { index: 1, name: 'ONT8', address: '实际地址A', destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接卸货点', address: '实际地址B', destinationKey: 'PRIVATE:actual' }] })
const { wrapper, state } = await open('EDIT'); await wrapper.setProps({ operation: 'ROUTE' }); await flushPromises()
const routeState = wrapper.vm as unknown as { moveStop(index: number, offset: number): void }; routeState.moveStop(1, -1); state.form.reason = '按本车现场顺序卸货'
vi.mocked(api.reorderDispatchRoute).mockRejectedValueOnce(new Error('unknown route')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
await state.submit(); vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('not found', 'HTTP_404'))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })); await state.submit()
const first = vi.mocked(api.reorderDispatchRoute).mock.calls[0]![1]
expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(api.reorderDispatchRoute).toHaveBeenNthCalledWith(2, 'PC/1', first); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
})
it('refuses already picked item removal and a route that drops an authoritative destination', async () => {
const source = candidate()
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: 'item-picked', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 1, remove: { allowed: false, blockCode: 'PICKED', blockReason: '本来源已经真实扫码' } }] })
const removed = await open('REMOVE_CARGO'); (removed.wrapper.vm as unknown as { itemId: string }).itemId = 'item-picked'; removed.state.form.reason = '调整'; await removed.state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled(); expect(removed.wrapper.text()).toContain('已经真实扫码'); removed.wrapper.unmount()
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
})
@@ -0,0 +1,273 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string; action: DispatchAction; operation?: 'ROUTE' }>()
const emit = defineEmits<{ saved: [] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const { pending, busy } = recovery
const currentAction = computed(() => pending.value?.action || props.action)
const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
let generation = 0
let editorRequestKey = ''
const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
items.value = null; itemId.value = ''; route.value = null; stops.value = []
try {
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
const value = await api.getDispatch(no)
if (sequence !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
detail.value = value
const oldVehicle = value.vehicleInfo.vehicle
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
if (!driver.value.evidence) driver.value.evidence = blankDriver().evidence
Object.assign(form, { plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
if (currentOperation.value === 'ROUTE') {
const data = await api.getDispatchRoute(no)
if (sequence !== generation) return
if (data.dispatchNo !== no || !data.stops.length || data.stops[0]?.index !== 0) throw new DispatchInputError('本车真实起点及路线未完整返回')
route.value = data; stops.value = data.stops.slice(1).map(stop => ({ ...stop }))
}
if (action === 'REMOVE_CARGO') {
const data = await api.getDispatchItems(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('移货清单与原排车单不一致')
items.value = data
}
if (action === 'LOADING_COMPLETE') {
const data = await api.getDispatchLoading(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('实载预览与排车单不一致')
preview.value = data
measurements.value = data.pallets.map(row => {
const confirmed = data.confirmedLoad?.manifestHash === data.manifestHash ? data.confirmedLoad.pallets.find(item => item.warehousePalletId === row.warehousePalletId) : null
const actual = (field: string) => row.evidence.some(item => item.field === field && item.actual === true)
return { warehousePalletId: row.warehousePalletId, palletNo: row.palletNo, palletSpaces: row.palletSpaces, deliveryIndex: row.deliveryIndex,
weightKg: confirmed?.weightKg ?? (actual('weightKg') ? row.weightKg : null), volumeCbm: confirmed?.volumeCbm ?? (actual('volumeCbm') ? row.volumeCbm : null) }
})
}
if (action === 'ARRANGE_DELIVERY') {
const data = await api.getDispatchArrangement(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('派送预览与排车单不一致')
arrangement.value = data
groups.value = data.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: '', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: ({ DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' })[feeType] || '服务费用', direction: 'RECEIVABLE', currency: 'USD', amount: null })) }))
}
if (action === 'ADD_CARGO') await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
finally { if (sequence === generation) loading.value = false }
}
async function loadCandidates(sequence = generation) {
// Detail has no warehouse field: resolve the authoritative list row by this business number.
const page = await api.listDispatches({ page: 1, size: 1, dispatchNo: currentNo.value, status: 'ACTIVE' })
const row = page.records.find(item => item.dispatchNo === currentNo.value)
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
const result = await api.listDispatchCandidates({ warehouseId: row.warehouseId, orderNo: candidateKeyword.value || undefined })
if (sequence !== generation) return
if (result.complete !== true) throw new DispatchInputError('候选来源未完整返回,请缩小范围重新查询')
if (result.candidates.some(source => source.warehouseId !== row.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion)) throw new DispatchInputError('货物起运仓或来源版本不完整,请重新读取')
candidates.value = result.candidates
}
async function searchCargo() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
function requirePlanPallet(row: Candidate): string {
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
return row.selection.planPalletId
}
function isCargoSelected(row: Candidate): boolean { return selected.value.some(value => value.groupKey === row.selectionGroup?.groupKey) }
function selectCargo(row: Candidate, checked: unknown) {
try {
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
const group = requireSelectionGroup(row)
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion })
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
}
function payload(): object {
if (!detail.value || !allowed.value) throw new DispatchInputError(detail.value?.actions[currentAction.value]?.blockReason || '当前操作未获得服务端允许,请刷新核对')
const common = { recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID() }
if (currentOperation.value === 'ROUTE') {
const data = route.value, keys = stops.value.map(stop => stop.destinationKey)
if (!data || data.edit.allowed !== true || !form.reason.trim() || !keys.length || keys.some(key => !key) || new Set(keys).size !== keys.length || keys.length !== data.stops.length - 1 || data.stops.slice(1).some(stop => !keys.includes(stop.destinationKey))) throw new DispatchInputError('请完整保留本车既有卸货点并填写调整原因')
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
}
switch (currentAction.value) {
case 'REMOVE_CARGO': {
const row = items.value?.items.find(value => value.dispatchItemId === itemId.value)
if (!row || row.remove.allowed !== true || row.pickedPackages !== 0 || !form.reason.trim()) throw new DispatchInputError(row?.remove.blockReason || '请选择允许移除的未扫码货物并填写原因')
return { recordVersion: requireVersion(items.value!.recordVersion), idempotencyKey: common.idempotencyKey, reason: form.reason.trim() } satisfies ReasonCommand
}
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'ADD_CARGO': {
const chosen = selected.value.map(value => {
const row = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
return row
})
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
if (!chosen.length || chosen.some(row => row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
}
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
case 'CANCEL': case 'RETURN_TO_REVIEW':
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
case 'LOADING_COMPLETE': {
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
}
case 'ARRANGE_DELIVERY': {
const data = arrangement.value
if (!data || data.arrange.allowed !== true || !data.groups.length || data.groups.some(row => row.blockReasons.length)) throw new DispatchInputError(data?.arrange.blockReason || '本车派送资料未就绪')
const prepared = groups.value.map((group, index) => {
if (!group.plannedDeliveryAt || !group.deliveryMethod || !group.pickupMethod || !group.loadingType) throw new DispatchInputError('请完整填写每个客户及目的仓的派送信息')
if (group.appointmentAccount === 'BUY_APPOINTMENT' && !group.sellerName?.trim()) throw new DispatchInputError('买约时卖家姓名必填')
const required = [...new Set([...(data.groups[index]?.requiredServices || []), ...(['FLOOR', 'MIXED'].includes(group.loadingType) ? ['LOADING_FEE'] : []), ...(group.appointmentAccount === 'BUY_APPOINTMENT' ? ['APPOINTMENT_FEE'] : [])])]
const fees = group.fees.filter(fee => required.includes(fee.feeType) && !arrangeFields.value?.covered(index, data.groups[index]!, fee.feeType))
if (fees.some(fee => fee.amount == null || !Number.isFinite(fee.amount) || fee.amount <= 0)) throw new DispatchInputError('请填写本组未覆盖服务的正数新增费用')
return { ...group, fees }
})
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, cargoHash: data.cargoHash, loadingCompletionHash: data.loadingCompletionHash, groups: prepared } satisfies ArrangeDelivery
}
default: return { ...common, remark: form.remark || null }
}
}
async function send(command: PendingDispatchCommand): Promise<CommandResult> {
const no = command.dispatchNo, body = command.body
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
switch (command.action) {
case 'REMOVE_CARGO': if (!command.itemId) throw new DispatchInputError('原移货请求缺少真实明细,请保留现场核对'); return api.removeDispatchItem(no, command.itemId, body as unknown as ReasonCommand)
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
case 'ADD_CARGO': return api.addDispatchCargo(no, body as unknown as AddCargo)
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
case 'RETURN_TO_REVIEW': return api.returnDispatchToReview(no, body as unknown as ReasonCommand)
case 'CANCEL': return api.cancelDispatch(no, body as unknown as ReasonCommand)
case 'LOADING_COMPLETE': return api.completeDispatchLoading(no, body as unknown as LoadingComplete)
case 'ARRANGE_DELIVERY': return api.transferDispatchToDelivery(no, body as unknown as ArrangeDelivery)
case 'SUBMIT_REVIEW': return api.submitDispatchReview(no, body as unknown as VersionCommand & { remark: string | null })
case 'DEPART': return api.departDispatch(no, body as unknown as VersionCommand & { remark: string | null })
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
}
}
async function submit() {
if (busy.value || loading.value || uploadingEvidence.value) return
error.value = null
try {
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
if (!pending.value) {
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
}
const routeCommand = pending.value?.operation === 'ROUTE'
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
if (!result) return
ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
emit('saved'); open.value = false
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '操作结果尚未确认,请保留原请求核对') }
}
function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] }
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="pending" role="status" title="原操作结果尚待核对,当前使用原版本和原请求内容。" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button v-if="pending?.rejected" :disabled="busy || loading || uploadingEvidence" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
</section>
<section v-else-if="currentAction === 'REMOVE_CARGO' && items" class="density-dialog-section" data-tone="source" aria-label="本车可移除货物"><h3 class="density-dialog-section__title">本车货物</h3>
<el-table :data="items.items" border><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || loading || uploadingEvidence || !allowed || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="scheduledPackages" label="本次箱数" width="100" /><el-table-column prop="pickedPackages" label="真实已拣" width="100" /><el-table-column label="移货资格" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table>
</section>
<template v-else-if="currentAction === 'EDIT'">
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
</template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><h3 class="density-dialog-section__title">审核依据</h3><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th></tr></thead><tbody><tr v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ appointment.status }}</td></tr></tbody></table>
<el-alert title="请核对本车资源与本趟证据;审核通过不代替后续现场扫码。" type="info" :closable="false" show-icon />
</section>
<section class="density-dialog-section" data-tone="work" aria-label="审核结果登记"><h3 class="density-dialog-section__title">审核结果</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
</template>
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
</section>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
<section class="density-dialog-section" data-tone="source" aria-label="实载与参考依据"><h3 class="density-dialog-section__title">测量依据</h3><table class="density-table"><thead><tr><th>托盘码</th><th>依据</th><th>来源业务号</th><th>值</th></tr></thead><tbody><template v-for="pallet in preview.pallets" :key="pallet.warehousePalletId"><tr v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType"><td>{{ pallet.palletNo }}</td><td>{{ evidence.actual ? '实测依据' : '仅供参考' }}</td><td>{{ evidence.sourceBusinessNo || '—' }}</td><td>{{ evidence.value ?? '—' }}</td></tr></template></tbody></table></section>
</template>
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen || loading || uploadingEvidence || !allowed" />
<el-alert v-else-if="currentAction === 'DEPART'" title="确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</template>
<section v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
</template>
<style scoped>
.workflow-editor-table { table-layout: fixed; }
.workflow-editor-table td { width: 50%; }
.workflow-order-actions { width: 160px; white-space: nowrap; }
.workflow-cargo-query { display: flex; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.workflow-cargo-query > .el-input { max-width: 320px; }
.workflow-source-group > summary { cursor: pointer; }
.workflow-source-group ul { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
</style>
@@ -1,8 +1,27 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import type { AppointmentForm } from '../appointmentForm'
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean }>()
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean }>()
</script>
<template>
<div v-if="continuous" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
<el-form class="density-form" label-position="left" label-width="140px" :disabled="disabled"><table class="density-table density-table--editable appointment-editor-table"><tbody>
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
<tr><td><el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
</template>
<tr><td colspan="2"><el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item></td></tr>
</tbody></table>
<details><summary>补充计划时段与夏令时信息</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<tr><td><el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item></td><td><el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
</tbody></table></details></el-form>
</section>
</div>
<template v-else>
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
@@ -24,8 +43,13 @@ defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string
</div>
</details>
</el-form>
</template>
</template>
<style scoped>
.appointment-editor-table { table-layout: fixed; }
.appointment-editor-table td:not([colspan]) { width: 50%; }
details:has(.appointment-editor-table) { margin-top: var(--admin-space-4); }
details:has(.appointment-editor-table) > summary { cursor: pointer; margin-bottom: var(--admin-space-3); }
.appointment-fields, .appointment-fields__extra-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 var(--admin-space-6); }
.appointment-fields :deep(.el-select), .appointment-fields :deep(.el-date-editor) { width:100%; }
.appointment-fields__extra { grid-column:1 / -1; }
@@ -59,8 +59,8 @@ onBeforeUnmount(() => { ++generation })
<p v-if="loading">资料加载中…</p>
<p v-else-if="error" role="alert">{{ error }} <el-button link type="primary" @click="load">重试</el-button></p>
<el-table v-else :data="documents" row-key="key" border empty-text="暂无上传的贴标文件">
<el-table-column prop="kind" label="文件类型" min-width="150" />
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="downloadFile(String(row.key))">下载</el-button></template></el-table-column>
<el-table-column prop="kind" label="文件类型" min-width="150" />
</el-table>
</section>
</template>
@@ -0,0 +1,204 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft, Vehicle } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const warehouse: PlatformWarehouseResource = { id: '321', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
const vehicleRecord: VehicleDetail = { id: '9223372036854775001', plateNumber: 'CA-MASTER', model: '档案车型', maxPalletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62, fuelConsumptionPer100Km: 10, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'operator', updatedAt: '' }
const driverRecord: DriverDetail = { id: '9223372036854775002', driverNo: 'DR-1', active: true, reviewStatus: 'APPROVED', licenseFileId: 'LICENSE-FILE', recordVersion: 0, updatedBy: 'operator', updatedAt: '', licenseState: 'VALID', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-01-01' }, details: { name: '档案司机', phone: '555-0101', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE-1', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-01-01', licenseZone: 'America/Los_Angeles', remark: null, profile: null } }
function draft(): DispatchDraft {
const first = candidate(), second = candidate({ sourceKey: 'source-2', customerId: 'C2', customerName: '客户乙', selection: { ...first.selection, planPalletId: '12', warehousePalletId: '22' }, palletNo: 'PALLET-2' })
return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [first.sourceKey] }, { draftContainerId: 'container-2', sequenceNo: 2, capacity: 15, sourceKeys: [second.sourceKey] }], sources: [first, second] }
}
beforeEach(() => {
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' })
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicleRecord], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
})
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '显式恢复已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function externalResources(dialog: VueWrapper, plate: string, name: string) {
for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() }
await field(dialog, '车牌号').get('input').setValue(plate)
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate immediately after typing').toBe(plate)
await field(dialog, '拖车公司名称').get('input').setValue('本趟承运公司')
for (const [label, value] of [['实际托位容量', 17], ['最大载重(kg)', 15000], ['最大容积(m³)', 62]] as const) field(dialog, label).getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', value)
await field(dialog, '司机姓名').get('input').setValue(name); await field(dialog, '联系电话').get('input').setValue('555-0102'); await field(dialog, '驾驶证号').get('input').setValue('EXTERNAL-LICENSE')
field(dialog, '驾驶证到期日').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01')
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate after completing driver fields').toBe(plate)
}
describe('new dispatch native layout', () => {
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
const { dialog } = await render()
expect(dialog.text()).toContain('请先选择装车组')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false)
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
})
it('preserves every external field and actual capacity through fullscreen in a real empty creation', async () => {
const { wrapper, dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
await externalResources(dialog, 'CA-EXTERNAL', '临时司机')
const state = wrapper.vm as unknown as { vehicle: Vehicle }
expect(state.vehicle.plate, 'parent vehicle after external inputs').toBe('CA-EXTERNAL')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
await field(dialog, '备注').get('input').setValue('本趟备注')
expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL')
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.text()).toContain('空单不能核平、装车或发车')
expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false)
await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })
})
it('queries real master directories and keeps business labels and task-end evidence in the command', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
const vehicleSelect = field(dialog, '车辆').getComponent({ name: 'ElSelect' })
vehicleSelect.vm.$emit('focus'); await flushPromises()
vehicleSelect.vm.$emit('update:modelValue', vehicleRecord.id); vehicleSelect.vm.$emit('change', vehicleRecord.id); await flushPromises()
const driverSelect = field(dialog, '司机').getComponent({ name: 'ElSelect' })
driverSelect.vm.$emit('focus'); await flushPromises()
driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises()
expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' })
expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' })
expect(dialog.text()).not.toContain(vehicleRecord.id)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }))
})
it('keeps each group configuration independent and verifies proven conversions without creating them twice', async () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
await externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组')
const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises()
await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组')
radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A')
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
expect(request.recordVersion).toBe(0)
expect(request.groups.map(group => [group.dispatch.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']])
expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17])
expect(wrapper.emitted('created')).toBeUndefined()
expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('locks the real frame and all resource edits while pending and reuses the original conversion after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
const { wrapper, dialog } = await render(); await restore(dialog); await externalResources(dialog, 'CA-A', '司机甲')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(dialog.text()).toContain('new-dispatch-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
})
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
rows[0] = sharedPalletCandidates()[0]!
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' })
const { dialog } = await render(); await selectWarehouse(dialog)
const table = dialog.get('[aria-label="排车候选货物"]')
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
expect(table.text()).toContain('PALLET-24')
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50)
const sourceGroup = table.findAll('details')[0]!
await sourceGroup.get('summary').trigger('click'); await flushPromises()
expect(sourceGroup.element.hasAttribute('open')).toBe(true)
expect(sourceGroup.findAll('li')).toHaveLength(2)
expect(sourceGroup.text()).toContain('客户A')
expect(sourceGroup.text()).toContain('客户B')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
})
it('keeps query conditions and regional tabs on the original candidate transport', async () => {
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined })
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
})
it('shows loading and initialization diagnostics without issuing create commands', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
const { dialog } = await render()
expect(dialog.text()).toContain('draft-load-request')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises()
expect(dialog.text()).toContain('draft-rejected-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
})
it('keeps creation unavailable without its write permission', async () => {
can.mockReturnValue(false)
const { dialog } = await render()
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled()
})
})
@@ -1,338 +1,130 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessage, ElMessageBox } from 'element-plus'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, lockDispatchDraftPallet, unlockDispatchDraftPallet, releaseDispatchDraft, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('../api/dispatches', () => ({
listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(), preArrangeDraftDispatch: vi.fn(),
createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(),
lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(),
releaseDispatchDraft: vi.fn(),
}))
const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } })
const button = defineComponent({ props: { disabled: Boolean }, emits: ['click'], setup(props, { slots, emit }) {
return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.())
} })
const retry = defineComponent({ emits: ['retry'], setup(_, { emit }) {
return () => h('button', { 'data-test': 'retry', onClick: () => emit('retry') }, '重试加载')
} })
const plan: DispatchPalletCandidateDto = { orderId: '1', orderNo: 'LM-A', planPalletId: '11', sourceType: 'INBOUND_PLAN',
warehousePalletId: '101', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-A', packageCount: 2, sourceStage: 'RECEIVED' }
const returned: DispatchPalletCandidateDto = { orderId: '2', orderNo: 'LM-B', returnReceiptId: '22', returnReceiptNo: 'RSR-B', sourceType: 'RETURN_RECEIPT',
warehousePalletId: '102', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-B', packageCount: 2, sourceStage: 'RECEIVED' }
beforeEach(() => vi.resetAllMocks())
function mountDraft() {
return shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
ElTag: shell, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
} })
}
const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', warehousePalletId: '103', palletNo: 'PAL-C' }
const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', warehousePalletId: '104', palletNo: 'PAL-D' }
const twoGroups: DispatchDraftDto = { draftToken: 'original-draft', containers: [
{ id: 'container-1', capacity: 30, palletIds: ['11', '13'] },
{ id: 'container-2', capacity: 30, palletIds: ['14', 'RETURN:22'] },
{ id: 'empty-container', capacity: 15, palletIds: [] },
] }
const expectedBatch = { draftToken: 'original-draft', groups: [
{ draftContainerId: 'container-1', planPalletIds: ['11', '13'], returnReceiptIds: [] },
{ draftContainerId: 'container-2', planPalletIds: ['14'], returnReceiptIds: ['22'] },
] }
const goodResult = [{ draftContainerId: 'container-2', dispatch: { id: '102', dispatchNo: 'DS-2', appointmentBatches: [] } },
{ draftContainerId: 'container-1', dispatch: { id: '101', dispatchNo: 'DS-1', appointmentBatches: [] } }]
async function submitDraft(wrapper: ReturnType<typeof mountDraft>) {
await wrapper.findAll('button').find(node => node.text() === '预排车')!.trigger('click')
await flushPromises()
}
it('keeps two explicit groups in one atomic request, omits empty containers and accepts reordered matching results', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
import AppDialog from '@/shared/components/AppDialog.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
const [first, second] = sharedPalletCandidates(), original = draft()
original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey]
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original)
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void>; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('LOCK', first, original.containers[0]!.draftContainerId)
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first!.selection }))
await state.submit(); expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual([first!.selection, second!.selection])
wrapper.unmount()
})
it.each([
['one collapsed result', goodResult.slice(0, 1)],
['foreign container', [{ ...goodResult[0], draftContainerId: 'foreign' }, goodResult[1]]],
['duplicate container', [goodResult[0], goodResult[0]]],
['duplicate dispatch', [goodResult[0], { ...goodResult[1], dispatch: goodResult[0].dispatch }]],
['missing business number', [goodResult[0], { ...goodResult[1], dispatch: { id: '101', dispatchNo: '', appointmentBatches: [] } }]],
])('retains the original groups and token after %s', async (_label, result) => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(result)
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(wrapper.emitted('created')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, expectedBatch)
expect(preArrangeDispatch).not.toHaveBeenCalled()
wrapper.unmount()
it('allows one atomic removal of a restored group that is no longer selectable', async () => {
const first = sharedPalletCandidates()[0]!, original = draft()
first.selectionGroup = { ...first.selectionGroup, complete: false, blockReasons: ['来源待核齐'] }
original.sources = [first]; original.containers[0]!.sourceKeys = [first.sourceKey]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.unlockDispatchDraftPallet).mockResolvedValue(original)
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('UNLOCK', first, original.containers[0]!.draftContainerId)
expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount()
})
it('retains all groups on batch rejection without issuing a flat request', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockRejectedValue(new Error('草稿来源已变化'))
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1)
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toBeUndefined()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
wrapper.unmount()
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options)
try {
await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit()
const request = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(request.groups[0]!.dispatch.batchAllocations).toEqual([{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }])
original.sources[0]!.appointmentBatches[0]!.recordVersion = '9007199254740994'
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenNthCalledWith(2, request)
} finally { wrapper.unmount() }
})
it('does not submit a physical pallet fallback when its actual plan source identity is missing', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'container', capacity: 30, palletIds: ['101'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: undefined }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('来源标识缺失'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restored candidate with missing or invalid batch version %s', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: recordVersion as Version, sources: [] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
const wrapper = mount(SmartDispatchDialog, options)
try {
await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
} finally { wrapper.unmount() }
})
it('submits relabel physical pallet identity without requiring an inbound plan pallet', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'relabel-draft', containers: [
{ id: 'container', capacity: 30, palletIds: ['101'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: null, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-1', appointmentBatches: [] })
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { mode: string; warehouseId: string; submit: () => Promise<void> }
state.mode = 'empty'; await wrapper.getComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.createDispatch).not.toHaveBeenCalled()
state.warehouseId = '321'; vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
await state.submit(); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', cargo: [], vehicle: null, driver: null }))
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('does not send a second batch while the original command is pending', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
let resolveCommand!: (result: typeof goodResult) => void
vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve }))
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
expect(wrapper.emitted('created')).toBeUndefined()
resolveCommand(goodResult); await flushPromises()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('restores an explicit draft and submits complete server source versions in each container', async () => {
const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙', selection: { ...source().selection, planPalletId: '22', warehousePalletId: '32' } })); original.containers[0]!.sourceKeys.push('source-B')
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, 'PC/1') })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] }))
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('keeps group membership frozen while the submitted batch is awaiting its response', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
let resolveCommand!: (result: typeof goodResult) => void
vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve }))
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click')
await wrapper.findAll('button').find(node => node.attributes('aria-label') === '删除装车车型1')!.trigger('click')
await flushPromises()
expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
expect(deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
resolveCommand(goodResult); await flushPromises()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('network unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]); wrapper.unmount()
})
it('retains unresolved sources when retry hydration still cannot recover the complete original draft', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, returned])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
})
it.each([
{ ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['11'] }] },
{ ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c1', capacity: 30, palletIds: ['13'] }] },
{ ...twoGroups, containers: [{ id: '', capacity: 30, palletIds: ['11'] }] },
])('rejects duplicate sources or invalid container identities before writing', async draft => {
vi.mocked(listDispatchDrafts).mockResolvedValue([draft])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => {
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.text()).toContain('尚未完整返回'); wrapper.unmount()
})
it('rejects a relabel order split across multiple containers before either API command', async () => {
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'c1', capacity: 30, palletIds: ['101'] }, { id: 'c2', capacity: 30, palletIds: ['103'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...secondPlan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('一组排车'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; activeContainerId: string; vehicle: { vehicleId: string | null }; form: { remark: string } }
state.restoreDraft('server-token'); await flushPromises(); state.vehicle.vehicleId = 'vehicle-A'; state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.vehicle.vehicleId = 'vehicle-B'; state.form.remark = '车乙'
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
expect(groups.map(group => [group.dispatch.vehicle?.vehicleId, group.dispatch.remark])).toEqual([['vehicle-A', '车甲'], ['vehicle-B', '车乙']])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('keeps one complete relabel order on the single-dispatch endpoint', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'c1', capacity: 30, palletIds: ['101', '103'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan].map(p => ({ ...p, sourceStage: 'RELABEL', orderRefs: 'RL-1' })))
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-RL', appointmentBatches: [] })
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101', '103'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('never sends an existing departure command through the empty creation transport', async () => {
useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
await (wrapper.vm as unknown as { submit(): Promise<void> }).submit()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
})
it('retains every restored selection on candidate failure and submits both sources only after retry hydration', async () => {
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container', dispatch: { id: '1', dispatchNo: 'DS-1', appointmentBatches: [] } }])
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [{ id: 'container', capacity: 30, palletIds: ['11', 'RETURN:22'] }] }])
vi.mocked(listDispatchCandidates)
.mockResolvedValueOnce([plan])
.mockRejectedValueOnce(new Error('恢复来源失败'))
.mockResolvedValueOnce([plan])
.mockResolvedValueOnce([plan, returned])
const wrapper = shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
} })
await flushPromises()
const submit = () => wrapper.findAll('button').find(node => node.text() === '预排车')!
await submit().trigger('click')
await flushPromises()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="retry"]').trigger('click')
await flushPromises()
await submit().trigger('click')
await flushPromises()
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith({ draftToken: 'original-draft', groups: [{ draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: ['22'] }] })
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(listDispatchCandidates).toHaveBeenLastCalledWith({ draftToken: 'original-draft' })
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
})
it('shows a draft recovery error and only loads candidates after a successful retry with the original token', async () => {
vi.mocked(listDispatchDrafts).mockRejectedValueOnce(new Error('草稿查询超时')).mockResolvedValueOnce([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
expect(listDispatchCandidates).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
wrapper.unmount()
})
it('keeps local relabel choices on close and reopen while describing their actual unlocked state', async () => {
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel])
const wrapper = mountDraft(); await flushPromises()
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('已选择,尚未锁定')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.text()).toContain('已选择,尚未锁定')
expect(listDispatchDrafts).toHaveBeenCalledTimes(2)
await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click'); await flushPromises()
expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
wrapper.unmount()
})
it.each(['RELABEL_APPLICATION', 'RETURN_RELABEL'])('rejects a partial %s application after refreshing its complete candidate set', async orderType => {
const relabel = { ...plan, sourceStage: 'RELABEL' as const, orderRefs: 'RL-1', orderType }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: ['101'] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...relabel, planPalletId: '13', warehousePalletId: '103' }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(listDispatchCandidates).toHaveBeenLastCalledWith({ sourceStage: 'RELABEL', orderNo: 'RL-1', draftToken: 'local-relabel' })
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')?.message).toContain('全部应排托盘')
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.emitted('created')).toBeUndefined()
wrapper.unmount()
})
it('preserves all previous selections when reopening fails to read drafts, then recovers the same groups on retry', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockRejectedValueOnce(new Error('查询超时')).mockResolvedValueOnce([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('button').find(button => button.text() === '预排车')!.attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
wrapper.unmount()
})
it('requires explicit release to restart an expired draft and keeps choices when release fails', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockResolvedValue([])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
vi.mocked(releaseDispatchDraft).mockRejectedValueOnce(new Error('释放超时')).mockResolvedValueOnce(null)
const wrapper = mountDraft(); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原草稿已失效')
expect(releaseDispatchDraft).not.toHaveBeenCalled()
const restart = () => wrapper.findAll('button').find(button => button.text() === '释放草稿并重新选择')!
await restart().trigger('click'); await flushPromises()
expect(releaseDispatchDraft).toHaveBeenLastCalledWith('original-draft')
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原选择已保留')
await restart().trigger('click'); await flushPromises()
expect(releaseDispatchDraft).toHaveBeenNthCalledWith(2, 'original-draft')
expect(wrapper.find('[data-test="retry"]').exists()).toBe(false)
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
wrapper.unmount()
})
it.each([plan, returned])('does not claim a failed source lock succeeded for $sourceType', async candidate => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'lock-draft', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([candidate])
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' } } }).mockResolvedValueOnce(null)
const wrapper = mountDraft(); await flushPromises()
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')).toMatchObject({ message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' })
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.text()).toContain('已锁定')
expect(lockDispatchDraftPallet).toHaveBeenLastCalledWith(candidate.sourceType === 'RETURN_RECEIPT' ? 'RETURN:22' : '11', 'lock-draft', 'c1')
wrapper.unmount()
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }; state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约客户不一致'); wrapper.unmount()
})
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { ApiResponseError } from '@/shared/api/http'
import WarehouseTransferFulfillmentPanel from './WarehouseTransferFulfillmentPanel.vue'
import { allocateWarehouseTransfer, getWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
import { listWarehouseTransferSources } from '../api/warehouseTransferSources'
import { listWarehouseTransferSources, type WarehouseTransferSourceCandidate } from '../api/warehouseTransferSources'
const { push, can } = vi.hoisted(() => ({ push: vi.fn(), can: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
@@ -21,8 +22,15 @@ function fixture(transferNo = 'WT-1', values: Partial<WarehouseTransferDetailDto
function render(customerId = 'customer-1', transferNo = 'WT-1') {
return mount(WarehouseTransferFulfillmentPanel, { props: { customerId, transferNo }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub } } })
}
const sourceCandidate: WarehouseTransferSourceCandidate = { inventoryPositionId: '9223372036854775001', palletId: '9223372036854775011', inboundCode: 'IN-1', skuCode: 'SKU-A', productNameZh: '测试货物', palletNo: 'DP-1', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }
async function openNativeAllocation() {
const wrapper = mount(WarehouseTransferFulfillmentPanel, { attachTo: document.body, props: { customerId: 'customer-1', transferNo: 'WT-1' }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
return { wrapper, dialog: wrapper.getComponent(AppDialog) }
}
describe('WarehouseTransferFulfillmentPanel', () => {
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture()) })
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockReset().mockResolvedValue(fixture()); vi.mocked(allocateWarehouseTransfer).mockReset(); vi.mocked(listWarehouseTransferSources).mockReset() })
it('requires manage and inventory read permissions before offering allocation', async () => {
can.mockImplementation(code => code !== 'lastleg:warehouse-transfer:manage')
const wrapper = render(); await flushPromises()
@@ -190,4 +198,67 @@ describe('WarehouseTransferFulfillmentPanel', () => {
await wrapper.findAll('button').find(x => x.text().includes('重新加载'))!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('RL-RETRY')
})
it('retains partial and complete selections through native fullscreen and submits every exact source', async () => {
const second = { ...sourceCandidate, inventoryPositionId: '9223372036854775002', palletId: '9223372036854775012', palletNo: 'DP-2', skuCode: 'SKU-B' }
const blocked = { ...sourceCandidate, inventoryPositionId: 'BLOCKED', palletNo: 'DP-BLOCKED', canSelect: false, availablePackages: 0, blockReason: '当前无可分配箱数' }
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ candidates: [sourceCandidate, second, blocked], issues: [] })
vi.mocked(allocateWarehouseTransfer).mockResolvedValue(fixture('WT-1', { status: 'ALLOCATED' }))
const { dialog } = await openNativeAllocation()
expect(dialog.props('title')).toBe('选择本票库存来源 · WT-1')
expect(dialog.get('label[aria-label="选择 DP-BLOCKED SKU-A"] input').attributes('disabled')).toBeDefined()
await dialog.get('label[aria-label="选择 DP-1 SKU-A"] input').setValue(true); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(allocateWarehouseTransfer).not.toHaveBeenCalled()
await dialog.get('label[aria-label="选择 DP-2 SKU-B"] input').setValue(true); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.get('[aria-label="库存分配核对"]').text()).toContain('已选择2 箱')
expect(dialog.props('confirmDisabled')).toBe(false)
await dialog.findAll('button').find(button => button.text() === '确认库存分配')!.trigger('click'); await flushPromises()
expect(allocateWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-1', {
idempotencyKey: expect.any(String), allocations: [
{ inventoryPositionId: sourceCandidate.inventoryPositionId, palletId: sourceCandidate.palletId, packageCount: 1 },
{ inventoryPositionId: second.inventoryPositionId, palletId: second.palletId, packageCount: 1 },
],
})
expect(dialog.props('modelValue')).toBe(false)
})
it('locks the native frame while saving and retains the identical command after an uncertain failure', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ candidates: [sourceCandidate], issues: [] })
let reject!: (reason: unknown) => void
vi.mocked(allocateWarehouseTransfer).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce(fixture('WT-1', { status: 'ALLOCATED' }))
const { wrapper, dialog } = await openNativeAllocation()
await dialog.get('label[aria-label="选择 DP-1 SKU-A"] input').setValue(true); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(allocateWarehouseTransfer).toHaveBeenCalledTimes(1)
const original = vi.mocked(allocateWarehouseTransfer).mock.calls[0]!
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[aria-label="DP-1 分配箱数"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('库存分配响应未确认', 'A0503', 503, null, 'allocation-frame-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('allocation-frame-request')
expect(dialog.get('input[aria-label="DP-1 分配箱数"]').attributes('disabled')).toBeDefined()
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '重试本次库存分配')!.trigger('click'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '重试本次分配')!.trigger('click'); await flushPromises()
expect(vi.mocked(allocateWarehouseTransfer).mock.calls[1]).toEqual(original)
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
})
it('retries source errors in the native frame and keeps a long candidate table in the main body', async () => {
const candidates = Array.from({ length: 25 }, (_, index) => ({ ...sourceCandidate, inventoryPositionId: `POSITION-${index}`, palletId: `PALLET-${index}`, palletNo: `DP-${index}` }))
vi.mocked(listWarehouseTransferSources).mockRejectedValueOnce(new ApiResponseError('来源读取失败', 'A0503', 503, null, 'source-frame-request')).mockResolvedValueOnce({ candidates, issues: ['绑定资料待核查'] })
const { dialog } = await openNativeAllocation()
expect(dialog.text()).toContain('source-frame-request')
expect(dialog.props('confirmDisabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '重新加载')!.trigger('click'); await flushPromises()
const table = dialog.get('[aria-label="本票来源库存"]')
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
expect(table.text()).toContain('DP-24')
expect(dialog.findComponent(DataTable).props('maxHeight')).toBe('')
expect((dialog.get('.el-table__body-wrapper .el-scrollbar__wrap').element as HTMLElement).style.maxHeight).toBe('')
expect(dialog.text()).toContain('绑定资料待核查')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(allocateWarehouseTransfer).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
@@ -164,18 +165,24 @@ onBeforeUnmount(() => { request++; sourceRequest++ })
<WarehouseTransferMovementPanel :customer-id="customerId" :detail="detail" @updated="detail = $event" />
</template>
</section>
<AppDialog v-model="allocationOpen" class="admin-density-dialog" title="选择本票库存来源" width="min(1320px, calc(100vw - 40px))" :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
<p>{{ transferNo }} · 本票 {{ detail?.packageCount }} 箱 · 已选择 {{ selectedPackages }} 箱</p>
<p v-if="originalCommand && !allocationRejected" role="status">分配结果尚未确认,已保留本次选择,重试使用相同内容。</p>
<p v-if="allocationRejected" role="status">本次分配未成功,请重新选择来源并核对当前可分配箱数后再次提交。</p>
<section v-if="previousRejection" aria-label="上次分配诊断"><p>上次分配未成功:{{ previousRejection.message }}</p><ApiErrorMeta :details="previousRejection" /></section>
<LoadErrorState v-if="sourceError" compact title="来源库存加载失败" :message="sourceError.message" @retry="loadSources"><template #details><ApiErrorMeta :details="sourceError" /></template></LoadErrorState>
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
<p v-for="issue in sourceIssues" :key="issue" role="status">{{ issue }}</p>
<DataTable v-if="!sourceError" :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
</DataTable>
<AppDialog v-model="allocationOpen" class="admin-density-dialog" :title="`选择本票库存来源 · ${transferNo || '—'}`" width="min(1320px, calc(100vw - 40px))" fullscreenable :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="库存分配核对"><h3 class="density-dialog-section__title">分配核对</h3>
<table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">本票箱数</th><td>{{ detail?.packageCount ?? '—' }} 箱</td><th scope="row">已选择</th><td>{{ selectedPackages }} 箱</td></tr></tbody></table>
</section>
<el-alert v-if="originalCommand && !allocationRejected" role="status" type="warning" :closable="false" show-icon title="分配结果尚未确认,已保留本次选择,重试使用相同内容。" />
<el-alert v-if="allocationRejected" role="status" type="warning" :closable="false" show-icon title="本次分配未成功,请重新选择来源并核对当前可分配箱数后再次提交。" />
<section v-if="previousRejection" class="density-dialog-section" data-tone="source" aria-label="上次分配诊断"><h3 class="density-dialog-section__title">上次分配诊断</h3><p class="density-dialog-note">上次分配未成功:{{ previousRejection.message }}</p><ApiErrorMeta :details="previousRejection" /></section>
<LoadErrorState v-if="sourceError" compact title="来源库存加载失败" :message="sourceError.message" @retry="loadSources"><template #details><ApiErrorMeta :details="sourceError" /></template></LoadErrorState>
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
<el-alert v-for="issue in sourceIssues" :key="issue" role="status" :title="issue" type="warning" :closable="false" show-icon />
<section v-if="!sourceError" class="density-dialog-section" data-tone="source" aria-label="本票来源库存"><h3 class="density-dialog-section__title">来源库存</h3>
<DataTable :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" max-height="" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
</DataTable>
</section>
</div>
</AppDialog>
</template>
@@ -1,18 +1,23 @@
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WarehouseTransferReceiptPanel from './WarehouseTransferReceiptPanel.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, increaseWarehouseTransferForecast, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), getWarehouseTransferCartonLabel: vi.fn(), increaseWarehouseTransferForecast: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
enableAutoUnmount(afterEach)
const DataTableStub = defineComponent({
props: ['data', 'columns'],
setup(props, { slots }) { return () => h('div', { class: 'test-table' }, [JSON.stringify(props.data), ...(props.data || []).flatMap((row: object) => [slots.boxAction?.({ row }), slots.quantityDifference?.({ row }), slots.boxDifference?.({ row })])]) },
})
const DialogStub = defineComponent({ props: ['modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'box-dialog' }, slots.default?.()) : null } })
const DialogStub = defineComponent({ props: ['modelValue', 'confirmText', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', { 'data-test': 'box-dialog' }, [slots.default?.(), props.confirmText ? h('button', { disabled: props.confirmDisabled || props.confirmLoading, onClick: () => emit('confirm') }, props.confirmText) : null]) : null } })
const render = (transferNo = 'WT-SOURCE') => mount(WarehouseTransferReceiptPanel, { props: { customerId: 'CUSTOMER', transferNo }, global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, AppDialog: DialogStub } } })
function fixture(): WarehouseTransferDetailDto {
return {
@@ -27,7 +32,7 @@ function fixture(): WarehouseTransferDetailDto {
pallets: [{ id: '100', sequenceNo: 1, palletNo: 'PALLET-A', cartonCount: 2, grossWeightKg: 6, volumeCbm: .2, status: 'COMPLETED' }, { id: '200', sequenceNo: 2, palletNo: 'PALLET-B', cartonCount: 1, grossWeightKg: 4, volumeCbm: .1, status: 'COMPLETED' }], relabelBindings: [],
}
}
beforeEach(() => { vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset() })
beforeEach(() => { can.mockReset().mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset(); vi.mocked(increaseWarehouseTransferForecast).mockReset() })
afterEach(() => vi.restoreAllMocks())
describe('转运预报与实际签收明细', () => {
it('keeps historical receipt totals after discard or shipment and leaves unknown declaration differences blank', async () => {
@@ -158,3 +163,153 @@ it('平台可补整单预报箱数,无需选择SKU', async () => {
expect(increaseWarehouseTransferForecast).toHaveBeenCalledWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 })
expect(wrapper.text()).toContain('预报箱数 6 箱')
})
async function nativeReceipt() {
const wrapper = mount(WarehouseTransferReceiptPanel, { attachTo: document.body, props: { customerId: 'CUSTOMER', transferNo: 'WT-SOURCE' }, global: { plugins: [ElementPlus], stubs: { transition: false } } })
await flushPromises()
return wrapper
}
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
async function openNativeForecast() {
const wrapper = await nativeReceipt()
await wrapper.findAll('button').find(button => button.text() === '增加预报箱数')!.trigger('click'); await flushPromises()
return { wrapper, dialog: popup(wrapper, '增加预报箱数') }
}
function forecastFixture() { return { ...fixture(), inboundCode: 'WT-SOURCE', status: 'WAIT_PDA_RECEIVING' } }
describe('receipt native popup integration', () => {
it('keeps counted boxes and mixed SKU contents through two independent fullscreen frames', async () => {
const value = fixture()
value.cartons[0] = { ...value.cartons[0]!, quantity: 10, discrepancyReason: '少收两件,客户确认', items: [{ transferItemId: '10', skuCode: 'SKU-A', quantity: 3 }, { transferItemId: '20', skuCode: 'SKU-B', quantity: 7 }] }
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
const wrapper = await nativeReceipt()
await wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.trigger('click'); await flushPromises()
const cartonsDialog = popup(wrapper, '签收箱号明细')
expect(cartonsDialog.props('title')).toBe('签收箱号明细 · WT-SOURCE')
expect(cartonsDialog.get('[aria-label="已清点箱号"]').findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(3)
expect(cartonsDialog.text()).not.toContain('BOX-MISSING')
await cartonsDialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await cartonsDialog.findAll('.el-table__body-wrapper button').find(button => button.text() === '箱内明细')!.trigger('click'); await flushPromises()
const boxDialog = popup(wrapper, '箱内清点明细')
expect(boxDialog.props('title')).toBe('箱内清点明细 · BOX-1')
await boxDialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(boxDialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(boxDialog.findComponent(DataTable).props('data')).toEqual([{ sku: 'SKU-A', name: '产品 A', quantity: 3 }, { sku: 'SKU-B', name: '产品 B', quantity: 7 }])
expect(boxDialog.text()).toContain('少收两件,客户确认')
expect(boxDialog.get('[aria-label="当前签收箱"]').text()).toContain('2026-10-15')
await boxDialog.get('.el-dialog__headerbtn').trigger('click'); await new Promise(resolve => setTimeout(resolve, 80)); await flushPromises()
expect(boxDialog.props('modelValue')).toBe(false)
expect(cartonsDialog.props('modelValue')).toBe(true)
expect(cartonsDialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE')
})
it('retains every counted box in the main dialog body without a nested height cap', async () => {
const value = fixture()
value.cartons = Array.from({ length: 30 }, (_, index) => ({ ...value.cartons[0]!, cartonMark: `BOX-${index + 1}`, cartonIndex: index + 1, totalCartons: 30 }))
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
const wrapper = await nativeReceipt()
await wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '签收箱号明细')
expect(dialog.get('[aria-label="实收箱数核对"]').text()).toContain('30 箱')
expect(dialog.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(30)
expect(dialog.text()).toContain('BOX-30')
expect(dialog.findComponent(DataTable).props('maxHeight')).toBe('')
expect((dialog.get('.el-table__body-wrapper .el-scrollbar__wrap').element as HTMLElement).style.maxHeight).toBe('')
})
it('freezes the original forecast count through fullscreen and sends only the scoped count command', async () => {
const value = forecastFixture()
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
vi.mocked(increaseWarehouseTransferForecast).mockResolvedValue({ ...value, packageCount: 6 })
const { wrapper, dialog } = await openNativeForecast()
expect(dialog.props('title')).toBe('增加预报箱数 · WT-SOURCE')
expect(dialog.get('[aria-label="预报箱数核对"]').findAll('input')).toHaveLength(0)
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.get('[aria-label="预报箱数核对"]').text()).toContain('增加后6 箱')
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(2)
await dialog.findAll('button').find(button => button.text() === '确认增加')!.trigger('click'); await flushPromises()
expect(increaseWarehouseTransferForecast).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 })
expect(dialog.props('modelValue')).toBe(false)
expect(wrapper.text()).toContain('预报箱数 6 箱')
})
it('locks pending edits and closing, preserves the failed count and retries the same snapshot', async () => {
const value = forecastFixture()
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
let reject!: (reason: unknown) => void
vi.mocked(increaseWarehouseTransferForecast).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce({ ...value, packageCount: 6 })
const { dialog } = await openNativeForecast()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="请输入新增箱数"]').attributes('disabled')).toBeDefined()
expect(dialog.findAll('button').find(button => button.text() === '取消')!.attributes('disabled')).toBeDefined()
reject(new ApiResponseError('箱数已被其他操作修改', 'A0409', 409, null, 'forecast-frame-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(2)
expect(dialog.text()).toContain('forecast-frame-request')
expect(dialog.get('[aria-label="预报箱数核对"]').text()).toContain('原预报箱数4 箱')
await dialog.findAll('button').find(button => button.text() === '重试本次增加')!.trigger('click'); await flushPromises()
expect(vi.mocked(increaseWarehouseTransferForecast).mock.calls).toEqual([
['CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 }],
['CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 }],
])
})
it('keeps whole-box and 5000-box limits at both the frame and submit handler', async () => {
const value = { ...forecastFixture(), packageCount: 4999 }
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
vi.mocked(increaseWarehouseTransferForecast).mockResolvedValue({ ...value, packageCount: 5000 })
const { dialog } = await openNativeForecast()
const input = dialog.findComponent({ name: 'ElInputNumber' })
expect(input.props('max')).toBe(1)
for (const count of [0, -1, 1.5, Number.NaN, 2]) {
input.vm.$emit('update:modelValue', count)
dialog.vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).not.toHaveBeenCalled()
input.vm.$emit('update:modelValue', 1); await flushPromises()
}
input.vm.$emit('update:modelValue', 1); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4999, packageCount: 5000 })
})
it('resets a cancelled draft and its failure context when reopened', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(forecastFixture())
vi.mocked(increaseWarehouseTransferForecast).mockRejectedValue(new ApiResponseError('增加失败', 'A0503', 503, null, 'old-forecast-request'))
const { wrapper, dialog } = await openNativeForecast()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(dialog.text()).toContain('old-forecast-request')
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '增加预报箱数')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false)
expect(increaseWarehouseTransferForecast).toHaveBeenCalledTimes(1)
})
it('closes the old forecast frame on a scope change and ignores a late failure', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValueOnce(forecastFixture()).mockResolvedValueOnce({ ...forecastFixture(), transferNo: 'WT-NEW', inboundCode: 'WT-NEW', packageCount: 8 })
let reject!: (reason: unknown) => void
vi.mocked(increaseWarehouseTransferForecast).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { wrapper, dialog } = await openNativeForecast()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
await wrapper.setProps({ customerId: 'CUSTOMER-NEW', transferNo: 'WT-NEW' }); await flushPromises()
expect(dialog.props('modelValue')).toBe(false)
reject(new ApiResponseError('旧票响应', 'A0503', 503, null, 'stale-forecast-request')); await flushPromises()
expect(wrapper.text()).toContain('预报箱数 8 箱')
expect(wrapper.text()).not.toContain('stale-forecast-request')
await wrapper.findAll('button').find(button => button.text() === '增加预报箱数')!.trigger('click'); await flushPromises()
expect(dialog.props('title')).toBe('增加预报箱数 · WT-NEW')
expect(dialog.get('[aria-label="预报箱数核对"]').text()).toContain('原预报箱数8 箱')
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
expect(increaseWarehouseTransferForecast).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 })
})
it('keeps forecast modification unavailable without approval permission', async () => {
can.mockReturnValue(false)
vi.mocked(getWarehouseTransfer).mockResolvedValue(forecastFixture())
const wrapper = await nativeReceipt()
expect(wrapper.findAll('button').some(button => button.text() === '增加预报箱数')).toBe(false)
popup(wrapper, '增加预报箱数').vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,12 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Eye, Printer } from '@lucide/vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { ElMessage } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, increaseWarehouseTransferForecast, type WarehouseTransferCartonDto, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
@@ -20,20 +22,25 @@ const increaseOpen = ref(false)
const increasing = ref(false)
const additionalCount = ref(1)
const forecastSnapshot = ref(0)
const increaseError = ref<ApiErrorDetails | null>(null)
const canIncreaseForecast = computed(() => access.can(permission.warehouseTransferApprove) && detail.value?.transferNo === detail.value?.inboundCode && ['PENDING_REVIEW', 'WAIT_PDA_RECEIVING'].includes(detail.value?.status || '') && Number(detail.value?.packageCount) < 5000)
const forecastInputValid = computed(() => Number.isSafeInteger(additionalCount.value) && additionalCount.value >= 1 && forecastSnapshot.value + additionalCount.value <= 5000)
/** 记录打开时箱数,提交时由后端核对并发修改。 */
function openIncreaseForecast() { forecastSnapshot.value = Number(detail.value?.packageCount); additionalCount.value = 1; increaseOpen.value = true }
function openIncreaseForecast() {
if (increasing.value || !canIncreaseForecast.value) return
forecastSnapshot.value = Number(detail.value?.packageCount); additionalCount.value = 1; increaseError.value = null; increaseOpen.value = true
}
/** 只补充箱号,不改变原SKU预报或仓库已录入的测量。 */
async function saveIncreaseForecast() {
if (increasing.value || !Number.isSafeInteger(additionalCount.value) || additionalCount.value < 1) return
if (increasing.value || !increaseOpen.value || !canIncreaseForecast.value || !forecastInputValid.value) return
const ticket = generation
increasing.value = true
increasing.value = true; increaseError.value = null
try {
const value = await increaseWarehouseTransferForecast(props.customerId, props.transferNo, { expectedPackageCount: forecastSnapshot.value, packageCount: forecastSnapshot.value + additionalCount.value })
if (ticket !== generation) return
detail.value = value; increaseOpen.value = false
ElMessage.success('预报箱数已增加,请通知PDA刷新后继续清点')
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '增加预报箱数失败').message) }
} catch (cause) { if (ticket === generation) increaseError.value = getApiErrorDetails(cause, '增加预报箱数失败') }
finally { increasing.value = false }
}
const printing = ref(false)
@@ -131,7 +138,7 @@ async function printCartons() {
}
async function load() {
const ticket = ++generation
detail.value = null; selectedBox.value = null; boxOpen.value = false; cartonsOpen.value = false; error.value = null; printError.value = null
detail.value = null; selectedBox.value = null; boxOpen.value = false; cartonsOpen.value = false; increaseOpen.value = false; increaseError.value = null; error.value = null; printError.value = null
if (!props.customerId || !props.transferNo) { loading.value = false; error.value = { message: '缺少客户或来源转运单号,无法查询签收明细' }; return }
loading.value = true
try { const value = await getWarehouseTransfer(props.customerId, props.transferNo); if (ticket === generation) detail.value = value }
@@ -147,7 +154,7 @@ onBeforeUnmount(() => { generation++ })
<LoadErrorState v-if="error" title="签收明细加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<p v-for="reason in discrepancyReasons" :key="reason" class="receipt-discrepancy-reason"><strong>实收差异原因:</strong>{{ reason }}</p>
<div class="receipt-box-heading"><el-button v-if="canIncreaseForecast" type="primary" size="small" @click="openIncreaseForecast">增加预报箱数</el-button><span>预报箱数 {{ detail.packageCount }} 箱 · 预报SKU总件数 {{ detail.items.some(item => item.quantity == null) ? '未记录' : detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) + ' 件' }}</span></div>
<div class="receipt-box-heading"><el-button v-if="canIncreaseForecast" type="primary" size="small" :disabled="increasing" @click="openIncreaseForecast">增加预报箱数</el-button><span>预报箱数 {{ detail.packageCount }} 箱 · 预报SKU总件数 {{ detail.items.some(item => item.quantity == null) ? '未记录' : detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) + ' 件' }}</span></div>
<h3>预报与实际签收对比 <small>差异 = 实收 − 预报{{ cartons.length ? '(按当前已清点箱汇总)' : ',待签收后显示' }}</small></h3>
<DataTable class="receipt-aligned-table" :fit="false" :data="comparisonRows" :columns="forecastColumns" row-key="id" :show-pagination="false" empty-text="暂无预报货物明细">
<template #quantityDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.quantityDifference) !== 0 }">{{ differenceText(row.quantityDifference) }}</span></template>
@@ -156,17 +163,30 @@ onBeforeUnmount(() => { generation++ })
<h3>托盘签收汇总</h3><DataTable class="receipt-aligned-table" :fit="false" :data="palletRows" :columns="palletColumns" row-key="id" :show-pagination="false" empty-text="尚未创建托盘" />
<div class="receipt-box-heading"><h3>签收箱号明细({{ cartons.length }} 箱)</h3><el-button type="primary" size="small" :disabled="!cartons.length" @click="cartonsOpen = true">查看箱号明细</el-button><el-button v-if="detail.status !== 'DRAFT'" type="primary" size="small" :loading="printing" :disabled="!detail.cartons.length" @click="printCartons"><Printer :size="14" />打印箱唛</el-button></div>
<LoadErrorState v-if="printError" title="箱唛打印失败" :message="printError.message" :loading="printing" @retry="printCartons"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
<AppDialog v-model="cartonsOpen" class="admin-density-dialog" title="签收箱号明细" width="1120px" :show-footer="false"><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
<template #boxAction="{ row }"><el-button link type="primary" @click="showBox(row.cartonMark)"><Eye :size="14" />箱内明细</el-button></template>
</DataTable></AppDialog>
<AppDialog v-model="cartonsOpen" class="admin-density-dialog" :title="`签收箱号明细 · ${transferNo || '—'}`" width="min(1120px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="实收箱数核对"><h3 class="density-dialog-section__title">实收汇总</h3><table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">已清点箱数</th><td>{{ cartons.length }} 箱</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="已清点箱号"><h3 class="density-dialog-section__title">签收箱号</h3><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" max-height="" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
<template #boxAction="{ row }"><el-button link type="primary" @click="showBox(row.cartonMark)"><Eye :size="14" />箱内明细</el-button></template>
</DataTable></section>
</div>
</AppDialog>
</template>
<AppDialog class="admin-density-dialog" v-model="increaseOpen" title="增加预报箱数" width="520px" :show-footer="false">
<el-form label-width="100px"><el-form-item label="新增箱数"><el-input-number v-model="additionalCount" placeholder="请输入新增箱数" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" /></el-form-item></el-form>
<p>保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。</p>
<el-button :disabled="increasing" @click="increaseOpen = false">取消</el-button><el-button type="primary" :loading="increasing" @click="saveIncreaseForecast">确认增加</el-button>
<AppDialog class="admin-density-dialog" v-model="increaseOpen" :title="`增加预报箱数 · ${transferNo || '—'}`" width="min(520px, calc(100vw - 28px))" fullscreenable confirm-text="确认增加" :confirm-loading="increasing" :confirm-disabled="!canIncreaseForecast || !forecastInputValid" @confirm="saveIncreaseForecast">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="预报箱数核对"><h3 class="density-dialog-section__title">箱数核对</h3><table class="density-table"><tbody><tr><th scope="row">原预报箱数</th><td>{{ forecastSnapshot }} 箱</td><th scope="row">增加后</th><td>{{ forecastInputValid ? forecastSnapshot + additionalCount : '—' }} 箱</td></tr></tbody></table></section>
<LoadErrorState v-if="increaseError" title="预报箱数增加失败" :message="increaseError.message" compact :loading="increasing" retry-text="重试本次增加" @retry="saveIncreaseForecast"><template #details><ApiErrorMeta :details="increaseError" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="work" aria-label="新增箱数登记"><h3 class="density-dialog-section__title">增加预报 <InlineHelp title="增加预报说明">保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。</InlineHelp></h3>
<el-form class="density-form" label-position="left" label-width="100px" :disabled="increasing"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="新增箱数" required><el-input-number v-model="additionalCount" placeholder="请输入新增箱数" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" controls-position="right" /></el-form-item></td></tr></tbody></table></el-form>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="boxOpen" title="箱内清点明细" width="820px" :show-footer="false">
<template v-if="selectedBox"><p>{{ selectedBox.cartonMark }} · 清点时间 {{ formatOverseasDateTime(selectedBox.countedAt) }}</p><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" :show-pagination="false" /><p v-if="selectedBox.discrepancyReason">差异说明:{{ selectedBox.discrepancyReason }}</p></template>
<AppDialog class="admin-density-dialog" v-model="boxOpen" :title="`箱内清点明细 · ${selectedBox?.cartonMark || '—'}`" width="min(820px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<div v-if="selectedBox" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="当前签收箱"><h3 class="density-dialog-section__title">签收箱</h3><table class="density-table"><tbody><tr><th scope="row">箱唛</th><td>{{ selectedBox.cartonMark }}</td><th scope="row">清点时间</th><td>{{ formatOverseasDateTime(selectedBox.countedAt) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="箱内SKU实收"><h3 class="density-dialog-section__title">箱内实收</h3><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" max-height="" :show-pagination="false" /></section>
<el-alert v-if="selectedBox.discrepancyReason" :title="`差异说明:${selectedBox.discrepancyReason}`" type="warning" :closable="false" show-icon />
</div>
</AppDialog>
</section>
</template>
@@ -37,7 +37,8 @@ describe('WarehouseTransferReviewDialog', () => {
it('shows a concise confirmation without fetching detail tables or review inputs', () => {
const wrapper = mountDialog()
expect(wrapper.text()).toContain('仓储转运单审核')
expect(wrapper.text()).toContain('仓储转运单审核 · WT-1')
expect(wrapper.text()).toContain('测试客户')
expect(wrapper.text()).toContain('审核通过后,转运单将进入海外仓收货打托作业。')
expect(wrapper.find('.back').text()).toBe('返回')
expect(wrapper.find('.approve').text()).toBe('审核通过')
@@ -31,7 +31,7 @@ async function submit() {
<AppDialog
v-model="open"
class="admin-density-dialog density-confirm-dialog"
title="仓储转运单审核"
:title="`仓储转运单审核 · ${transferNo || '—'}`"
width="min(420px, calc(100vw - 28px))"
align-center
cancel-text="返回"
@@ -39,6 +39,6 @@ async function submit() {
:confirm-loading="saving"
@confirm="submit"
>
<div>审核通过后,转运单将进入海外仓收货打托作业。</div>
<section class="density-dialog-section" data-tone="source" aria-label="转运审核对象"><p class="density-dialog-note">客户:{{ customerName || '—' }}</p><div>审核通过后,转运单将进入海外仓收货打托作业。</div></section>
</AppDialog>
</template>