style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -98,10 +98,9 @@ describe('native whole-vehicle dispatch API', () => {
for (const invalid of [undefined, null, -1, '', 'bad', Number.MAX_SAFE_INTEGER + 1]) await expect(Promise.resolve().then<unknown>(() => invoke(invalid))).rejects.toThrow('版本')
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
})
it('updates only an existing appointment reference and its explicit version', async () => {
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: 0 } }
it('updates vehicle resources and timing with the explicit dispatch version', async () => {
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
await expect(api.updateDispatch('PC/1', { ...request, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: null as unknown as Version } })).rejects.toThrow('版本')
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
})
it('uses versioned removal and cancellation POSTs instead of unversioned deletes', async () => {
@@ -28,7 +28,6 @@ export const getDispatchCommand = (dispatchNo: string, key: string) => get<Comma
export const createDispatch = (body: CreateDispatch) => { requireBatchVersions(body.batchAllocations); return postCommand(base, body) }
export async function updateDispatch(dispatchNo: string, body: UpdateDispatch) {
versioned(body)
if (body.vehicleAppointment) requireVersion(body.vehicleAppointment.recordVersion)
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(path(dispatchNo), body)))
}
export const addDispatchCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${path(no)}/items`, body) }
@@ -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>
@@ -4,7 +4,7 @@ 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}` }) as CargoLine
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 })) }
@@ -26,29 +26,32 @@ function required(index: number, preview: PreviewGroup, service: string) {
defineExpose({ covered })
</script>
<template>
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group">
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group density-dialog-sections">
<template v-if="groups[index]">
<h3>{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
<p v-for="reason in previewGroup.blockReasons" :key="reason" role="alert">{{ reason }}</p>
<div class="arrange-grid">
<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>
<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>
<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>
<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>
<el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item>
<el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item>
<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><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><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item>
</div>
<el-table :data="previewGroup.cargo" border max-height="240"><el-table-column prop="orderNo" label="来源订单" min-width="150" /><el-table-column prop="inboundCode" label="入仓码" min-width="150" /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
<p v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.inboundCode }}:{{ quote.currency }} {{ quote.amount }}(外接约定运费)</p>
<h4>原费用覆盖</h4><p v-if="!previewGroup.feeSources.length">暂无可引用原费用。</p>
<div v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><span>{{ fee.currency }} {{ fee.amount }} · {{ fee.status }}</span><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></div>
<h4>本组新增费用</h4><div class="arrange-grid"><template v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="`${serviceLabels[fee.feeType] || fee.feeName}(未覆盖部分)`" required><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item><p v-else>本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></template></div>
<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-group { padding-bottom: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.arrange-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.arrange-grid :deep(.el-select), .arrange-grid :deep(.el-date-editor), .arrange-grid :deep(.el-input-number) { width: 100%; }
.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>
@@ -1,6 +1,9 @@
<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'
@@ -70,16 +73,21 @@ watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { i
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
<section class="dispatch-evidence" :aria-label="label"><h4>{{ label }}</h4>
<p v-if="error" role="alert">{{ error.message }} <el-button link @click="load">重新读取资料</el-button></p><ApiErrorMeta v-if="error" :details="error" />
<ul><li v-for="id in ids" :key="id"><span>{{ metadata[id]?.fileName || '附件资料待核实' }}</span> <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></li></ul>
<p v-if="!ids.length">暂无本趟附件。</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>PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="900px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><p v-if="previewLoading">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></AppDialog>
<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 { padding: var(--admin-space-4) 0; }
.dispatch-evidence ul { margin: 0; padding-left: 20px; }
.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>
@@ -8,21 +8,21 @@ 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('renders known business labels for existing master resources without falling back to their internal IDs', async () => {
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 }, global: { plugins: [ElementPlus] } }); await flushPromises()
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('resets only the resource being switched and keeps external resource choices independent', async () => {
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 }, global: { plugins: [ElementPlus] } })
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('requires each real directory permission before reading candidates', async () => {
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 }, global: { plugins: [ElementPlus] } })
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()
})
@@ -5,7 +5,7 @@ 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 }>()
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 = '') {
@@ -32,6 +32,21 @@ function selectDriver(id: string) {
}
</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>
@@ -46,8 +61,11 @@ function selectDriver(id: string) {
<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()
})
})
@@ -5,7 +5,7 @@ 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 } from '../dispatchTestFixtures'
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 }) }))
@@ -13,12 +13,28 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
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 { selected: string[] }).selected = [value.sourceKey]
(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 }] }))
@@ -29,7 +45,7 @@ it('blocks cargo addition when the authoritative batch version is absent', async
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 { selected: string[] }).selected = [value.sourceKey]; await state.submit()
(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() }
})
@@ -89,7 +105,7 @@ 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 } 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: [] }))
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
@@ -1,16 +1,18 @@
<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 } 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'
@@ -30,13 +32,14 @@ const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<Dis
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<string[]>([]), candidateKeyword = ref('')
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))
@@ -45,6 +48,7 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
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 {
@@ -110,6 +114,15 @@ 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() }
@@ -126,10 +139,14 @@ function payload(): object {
}
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 = candidates.value.filter(row => selected.value.includes(row.sourceKey))
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: chosen.map(row => row.selection), 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
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('驳回时必须填写原因')
@@ -181,7 +198,10 @@ async function submit() {
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 } : {}) })
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
@@ -193,24 +213,61 @@ function moveStop(index: number, offset: number) { const target = index + offset
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]" :width="width" :confirm-loading="busy" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<p>排车单号 {{ currentNo || '—' }}</p><p v-if="pending" role="status">原操作结果尚待核对,当前使用原版本和原请求内容。</p>
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button v-if="pending?.rejected" :disabled="busy" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
<p v-if="detail?.actions[currentAction]?.allowed !== true && !pending">{{ detail?.actions[currentAction]?.blockReason || '当前操作资格未返回,请刷新核对' }}</p>
<el-form v-if="detail" class="density-form" label-position="top">
<template v-if="currentOperation === 'ROUTE' && route"><p>起运仓:{{ route.stops[0]?.name || '—' }} · {{ route.stops[0]?.address || '—' }}</p><p>{{ route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。' }}</p><ol><li v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index">{{ stop.name }} · {{ stop.address || '—' }} <el-button :disabled="frozen || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></li></ol></template><template v-else-if="currentAction === 'REMOVE_CARGO' && items"><el-table :data="items.items" border max-height="420"><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="来源订单" /><el-table-column prop="inboundCode" label="入仓码" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="scheduledPackages" label="本次箱数" /><el-table-column prop="pickedPackages" label="真实已拣" /><el-table-column label="移货资格"><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table></template><template v-else-if="currentAction === 'EDIT'"><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><div class="form-grid"><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></div></template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'"><p>{{ detail.vehicleInfo.vehicle?.plate || '—' }} · {{ detail.vehicleInfo.driver?.name || '—' }} · {{ detail.counts.palletCount }}托/{{ detail.counts.packageCount }}箱</p><p v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status }}</p><p>请核对本车资源与本趟证据;审核通过不代替后续现场扫码。</p><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></template>
<template v-else-if="currentAction === 'ADD_CARGO'"><div class="density-actions"><el-input v-model="candidateKeyword" :disabled="frozen" placeholder="订单号/入仓码" /><el-button :disabled="frozen" @click="searchCargo">查询</el-button></div><el-table :data="candidates" border max-height="380"><el-table-column label="选择" width="70"><template #default="{ row }"><el-checkbox :model-value="selected.includes(row.sourceKey)" @change="selected = $event === true ? [...selected, row.sourceKey] : selected.filter(key => key !== row.sourceKey)" :disabled="frozen || row.blockReasons.length > 0" :aria-label="`选择货物 ${row.palletNo || row.orderNo || '待核实'}`" /></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="订单号" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="availablePackages" label="可排箱数" /><el-table-column label="目的仓"><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table></template>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview"><p>{{ preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。' }}</p><el-table :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" /><el-table-column label="实际托位"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen" :min="1" placeholder="请确认" /></template></el-table-column><el-table-column label="实测重量(kg)"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="实测体积(m³)"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="已匹配卸货顺序"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table><p v-for="pallet in preview.pallets" :key="pallet.warehousePalletId">{{ pallet.palletNo }}:<span v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType">{{ evidence.actual ? '实测依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }} {{ evidence.value ?? '—' }};</span></p></template>
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen" />
<p v-else-if="currentAction === 'DEPART'">确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。</p><p v-else-if="currentAction === 'RETURN_TO_REVIEW'">仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。</p><p v-else-if="currentAction === 'CANCEL'">确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。</p>
<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" @busy="evidenceBusy.VEHICLE = $event" /><DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen" @busy="evidenceBusy.DRIVER = $event" /></template><el-form-item v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item>
<el-form-item v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" label="备注"><el-input v-model="form.remark" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item>
</el-form>
<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>
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.form-grid :deep(.el-date-editor) { width: 100%; }
.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()
})
})
@@ -5,7 +5,7 @@ import SmartDispatchDialog from './SmartDispatchDialog.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import * as api from '../api/dispatches'
import { candidate, command, detail } from '../dispatchTestFixtures'
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' }))
@@ -17,6 +17,29 @@ 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('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()
})
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 => {
@@ -55,12 +78,12 @@ it('requires an explicit warehouse and allows saving a true empty dispatch with
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: '客户乙' })); original.containers[0]!.sourceKeys.push('source-B')
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, source().selection] }) })] }))
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('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
@@ -8,11 +8,13 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.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 { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
@@ -36,7 +38,8 @@ const visibleCandidates = computed(() => candidates.value.filter(row => matchesS
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
const currentSettings = computed(() => {
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
return settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
@@ -78,10 +81,11 @@ async function loadCandidates(sequence = generation) {
async function search() { 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 restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
assertSelectionGroupVersions(cargo, candidates.value)
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
const config = containerId ? settings[containerId] : currentSettings.value
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: cargo.map(row => row.selection), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) {
if (!canCreate.value || loading.value || saving.value) return
@@ -96,7 +100,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId || source.blockReasons.length) throw new DispatchInputError('请选择可排货物和装车容器'); body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') requireSelectionGroup(source); body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
else if (action === 'RELEASE') body = common
else {
@@ -162,24 +166,66 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="pending || recovery.pending.value" role="status">原请求结果尚待核对,请勿更改选择、版本或重新建单。</p><el-button v-if="pending?.rejected" :disabled="saving" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<el-form label-position="top" class="density-form"><div class="create-grid"><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></div>
<p v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? `装车组 ${draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo}` : '请先选择装车组' }};每组车辆、司机和时间独立保存。</p><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen" placeholder="请输入本趟备注(选填)" /></el-form-item>
</el-form>
<p v-if="mode === 'empty'">空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。</p>
<template v-else><div class="density-actions"><el-select :model-value="draft?.draftToken" :disabled="frozen" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="`${warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实'} · ${saved.containers.length}组 · ${saved.sources.length}条货物`" /></el-select><el-button :disabled="frozen || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select v-model="capacity" :disabled="frozen" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
<div class="density-actions"><el-input v-model="query.orderNo" :disabled="frozen" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || !warehouseId" @click="search">查询</el-button></div>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen" @click="warehouseTab = tab.value">{{ tab.label }}</button></div><div class="cargo-workspace"><el-table :data="visibleCandidates" border height="420"><el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || !activeContainerId || row.blockReasons.length > 0" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" /><el-table-column label="来源" min-width="130"><template #default="{ row }">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}<br>{{ row.selection.relabelNo || row.planNo || '—' }}</template></el-table-column><el-table-column prop="orderNo" label="订单号" min-width="170" /><el-table-column prop="inboundCode" label="入仓码" min-width="170" /><el-table-column prop="palletNo" label="托盘码" min-width="170" /><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150"><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column><el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³<br>仅供预排参考</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" /><el-table-column label="资格" min-width="160"><template #default="{ row }">{{ row.blockReasons.join(';') || '可排' }}</template></el-table-column></el-table>
<section><article v-for="container in draft?.containers || []" :key="container.draftContainerId" class="draft-container"><el-radio v-model="activeContainerId" :disabled="frozen" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio><el-button link :disabled="frozen" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button><ul><li v-for="key in container.sourceKeys" :key="key">{{ draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复' }}<el-button link :disabled="frozen || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></article><p v-if="!draft?.containers.length">新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></section>
</div>
</template>
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<el-form label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" data-tone="work" aria-label="排车设置"><h3 class="density-dialog-section__title">建单设置 <small v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? '装车组 ' + draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo : '请先选择装车组' }}</small><InlineHelp v-if="mode === 'cargo'" title="装车组说明">每组车辆、司机和时间独立保存。</InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item></td><td><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" 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 || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" placeholder="请输入本趟备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
<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 || (mode === 'cargo' && !activeContainerId)" /></section>
</el-form>
<el-alert v-if="mode === 'empty'" title="空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-else>
<section class="density-dialog-section" data-tone="summary" aria-label="整车草稿管理"><h3 class="density-dialog-section__title">整车草稿</h3>
<div class="dispatch-draft-tools density-actions"><el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select><el-button :disabled="frozen || loading || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || loading || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || loading || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
</section>
<div class="cargo-workspace">
<section class="density-dialog-section" data-tone="source" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="warehouseTab = tab.value">{{ tab.label }}</button></div>
<el-table :data="visibleCandidates" border>
<el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !activeContainerId || Boolean(selectionGroupBlockReason(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="170" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary><div class="dispatch-group-summary"><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}个实托</span></div></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></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, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
</el-table>
</section>
<section class="density-dialog-section" data-tone="summary" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组</h3>
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table"><tbody>
<tr><td><el-radio v-model="activeContainerId" :disabled="frozen || loading" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio></td><td><el-button link :disabled="frozen || loading" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button></td></tr>
<tr><td colspan="2"><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
</tbody></table>
<p v-if="!draft?.containers.length" class="density-dialog-note">新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p>
</section>
</div>
</template>
</div>
</AppDialog>
</template>
<style scoped>
.create-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.create-grid :deep(.el-select), .create-grid :deep(.el-date-editor) { width: 100%; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); gap: var(--admin-space-5); }
.draft-container { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); gap: var(--admin-space-5); align-items: start; }
.cargo-workspace > .density-dialog-section { margin: 0; }
.dispatch-draft-tools { flex-wrap: wrap; }
.dispatch-draft-picker { width: 320px; }
.dispatch-capacity-picker { width: 130px; }
.dispatch-candidate-query { display: grid; grid-template-columns: minmax(160px, 1fr) minmax(120px, 1fr) minmax(130px, .8fr) auto; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-group-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--admin-space-2); }
.dispatch-source-group > summary { cursor: pointer; }
.dispatch-source-list { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
.draft-group-table + .draft-group-table { margin-top: var(--admin-space-4); }
.dispatch-draft-sources { margin: 0; padding: 0; list-style: none; }
.dispatch-draft-source { display: flex; align-items: center; gap: var(--admin-space-3); }
.dispatch-draft-source :deep(.line-clamp-text) { flex: 1; }
</style>
@@ -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('shows every SKU in a mixed box and counts its physical measurements once', async () => {
@@ -145,3 +150,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)
@@ -129,7 +136,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 }
@@ -145,7 +152,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>
@@ -154,17 +161,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>
@@ -28,14 +28,13 @@ export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; s
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface VehicleAppointment { referenceNo: string; status: string; appointmentAt: string | null }
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string }
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; vehicleAppointment: VehicleAppointment | null; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; vehicleAppointment?: { referenceNo: string; recordVersion: Version } | null; remark: string | null }
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface ReasonCommand extends VersionCommand { reason: string }
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
@@ -43,7 +42,10 @@ export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: n
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] }
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[] }
export interface SelectionGroupSource { selection: CargoSelection; sourceKey: string; customerId: string; customerName: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; packages: number }
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
@@ -63,7 +65,8 @@ export interface FeeItem { feeType: string; feeName: string; direction: string;
export interface ArrangeGroup { groupKey: string; groupCargoHash: string; plannedDeliveryAt: string; deliveryMethod: string; waybillNo: string | null; carrierName: string | null; pickupMethod: string; loadingType: string; vehicleType: string | null; vehicleSize: string | null; appointmentAccount: string | null; sellerName: string | null; remark: string | null; externalFreightHash: string; feeCoverageCargoHash: string; feeCoverage: CoverageSelection[]; fees: FeeItem[] }
export interface ArrangeDelivery extends VersionCommand { cargoHash: string; loadingCompletionHash: string; groups: ArrangeGroup[] }
export interface ArrangementResult { dispatchNo: string; recordVersion: Version; arrangementKey: string; cargoHash: string; arrangedAt: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[] }
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[] }
export interface SourceOrigin { dispatchItemSourceId: string | null; relabelApplicationId: string | null; relabelItemId: string | null; sourceInventoryBalanceId: string | null; sourceInboundOrderId: string | null; sourceInboundOrderNo: string | null; sourceInboundCode: string | null; productQuantity: number | null }
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[]; sourceOrigin: SourceOrigin | null }
export interface PreviewGroup { groupKey: string; customerId: string; customerName: string; destination: Destination; groupCargoHash: string; cargo: CargoLine[]; externalFreightHash: string; externalQuotes: Array<{ customerId: string; groupKey: string; bookingNo: string; inboundCode: string; currency: string; amount: number; sourceHash: string }>; feeCoverageCargoHash: string; feeSources: Array<{ financeFeeId: string; customerId: string; currency: string; amount: number; sourceVersion: number; sourceHash: string; status: string; services: string[]; sourceKeys: string[] }>; requiredServices: string[]; blockReasons: string[] }
export interface ArrangementPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; cargoHash: string; loadingCompletionHash: string; arrange: Gate; groups: PreviewGroup[]; arrangement: ArrangementResult | null }
interface CommandBase { dispatchNo: string; idempotencyKey: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; occurredAt: string; effect: 'APPLIED' | 'EXISTING'; counts: Counts; replayed: boolean; alreadyArranged: boolean }
@@ -98,3 +101,44 @@ export function requireBatchVersions(values: readonly { batchKey: string; record
versions.set(value.batchKey, canonical)
}
}
function selectionIdentity(value: CargoSelection): string {
return JSON.stringify([value.sourceType, value.planPalletId, value.returnReceiptId, value.relabelNo, value.warehousePalletId])
}
export function requireSelectionGroup(row: Candidate): SelectionGroup {
const group = row.selectionGroup
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
if (!group.sources.some(source => source.sourceKey === row.sourceKey && source.customerId === row.customerId && selectionIdentity(source.selection) === selectionIdentity(row.selection) && source.selection.sourceVersion === row.sourceVersion && row.selection.sourceVersion === row.sourceVersion)) throw new DispatchInputError('候选与完整选货组来源不一致,请重新读取候选')
return group
}
export function selectionGroupBlockReason(row: Candidate): string {
try { requireSelectionGroup(row); return '' } catch (cause) { return cause instanceof Error ? cause.message : '完整选货组无效,请重新读取候选' }
}
export function assertSelectionGroupVersions(rows: readonly Candidate[], latest: readonly Candidate[]): void {
const versions = new Map<string, string>()
for (const row of rows) {
const group = row.selectionGroup
if (!group?.groupKey || !group.groupVersion) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
versions.set(group.groupKey, group.groupVersion)
}
for (const row of latest) {
const group = row.selectionGroup, selected = rows.find(value => value.sourceKey === row.sourceKey)
if (selected && selected.selectionGroup.groupKey !== group?.groupKey || group && versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
}
}
export function expandSelectionGroups(rows: readonly Candidate[]): CargoSelection[] {
assertSelectionGroupVersions(rows, [])
const sources = new Map<string, SelectionGroupSource>(), selections = new Map<string, CargoSelection>()
for (const row of rows) for (const source of requireSelectionGroup(row).sources) {
const previous = sources.get(source.sourceKey), identity = selectionIdentity(source.selection), selected = selections.get(identity)
if (previous && (selectionIdentity(previous.selection) !== identity || previous.selection.sourceVersion !== source.selection.sourceVersion || previous.customerId !== source.customerId || previous.packages !== source.packages)) throw new DispatchInputError('完整选货组来源事实不一致,请重新读取候选')
if (selected && selected.sourceVersion !== source.selection.sourceVersion) throw new DispatchInputError('完整选货组来源版本不一致,请重新读取候选')
sources.set(source.sourceKey, source); selections.set(identity, source.selection)
}
return [...selections.values()]
}
@@ -0,0 +1,43 @@
import { expect, it } from 'vitest'
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts'
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
import { normalizeCandidateList, type CandidateWire } from './dispatchWire'
it('expands a filtered physical group into both exact relabel selections once while retaining one physical pallet', () => {
const [first, second] = sharedPalletCandidates()
expect(expandSelectionGroups([first!])).toEqual([first!.selection, second!.selection])
expect(expandSelectionGroups([first!, second!, first!])).toEqual([first!.selection, second!.selection])
expect(requireSelectionGroup(first!).pallets).toEqual([{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: 8 }])
})
it.each(['INBOUND_PLAN', 'RETURN_RECEIPT'] as const)('allows authoritative nullable business display fields for ordinary %s groups', sourceType => {
const row = candidate({ selection: { ...candidate().selection, sourceType, planPalletId: sourceType === 'INBOUND_PLAN' ? '11' : null, returnReceiptId: sourceType === 'RETURN_RECEIPT' ? '12' : null } })
expect(row.selectionGroup.sources[0]).toMatchObject({ sourceBusinessType: null, sourceBusinessNo: null })
expect(expandSelectionGroups([row])).toEqual([row.selection])
})
it('requires the actual RELABEL_ORDER business identity for relabel sources without accepting a legacy alias', () => {
const row = sharedPalletCandidates()[0]!
row.selectionGroup.sources[0]!.sourceBusinessNo = null
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
row.selectionGroup.sources[0]!.sourceBusinessNo = 'RL-A'; row.selectionGroup.sources[0]!.sourceBusinessType = 'RELABEL'
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
})
it.each([undefined, { complete: false }, { blockReasons: ['换标来源尚未核齐'] }])('rejects a missing, incomplete or blocked selection group instead of falling back to the visible row', change => {
const row = sharedPalletCandidates()[0]!
row.selectionGroup = change ? { ...row.selectionGroup, ...change } : undefined as unknown as SelectionGroup
expect(() => expandSelectionGroups([row])).toThrow(/完整选货组|尚未完整|尚未核齐/)
})
it('rejects changed group composition and conflicting source versions before creating a command', () => {
const [first, second] = sharedPalletCandidates()
expect(() => assertSelectionGroupVersions([first!], [{ ...second!, selectionGroup: { ...second!.selectionGroup, groupVersion: 'composition-2' } }])).toThrow('组成已变化')
const conflict = { ...second!, selectionGroup: { ...second!.selectionGroup, sources: second!.selectionGroup.sources.map((source, index) => index ? source : { ...source, selection: { ...source.selection, sourceVersion: 'changed' } }) } }
expect(() => expandSelectionGroups([first!, conflict])).toThrow('来源事实不一致')
})
it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => {
const row = sharedPalletCandidates()[0]!, id = '9007199254740993'
const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } }
const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8)
expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id)
value.selectionGroup.sources[0]!.packages = '9007199254740993'
expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
})
@@ -3,10 +3,15 @@ export const counts = () => ({ itemCount: 1, palletCount: 1, packageCount: 2, pi
export const reviews = () => ({ dispatchReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } })
export const resources = (): { vehicle: VehicleSnapshot; driver: Driver } => ({ vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-100', carrierName: 'Carrier', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 70 }, evidenceFileIds: [], vehicleStatus: null }, driver: { mode: 'EXTERNAL', driverId: null, name: '司机甲', phone: '555-0100', evidence: { licenseNo: 'TEST-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-01T10:00:00Z' } } })
export const destination = () => ({ key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null })
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1' }], ...overrides } }
export function sharedPalletCandidates(): Candidate[] {
const rows = ['A', 'B'].map((suffix, index) => candidate({ sourceKey: `RELABEL:${suffix}`, sourceVersion: `source-${suffix}`, customerId: `C${index + 1}`, customerName: `客户${suffix}`, availablePackages: index ? 5 : 3, selection: { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: `RL-${suffix}`, warehousePalletId: '21', sourceVersion: `source-${suffix}` } }))
const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] }
return rows.map(row => ({ ...row, selectionGroup: group }))
}
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
export function candidate(overrides: Partial<Candidate> = {}): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides } }
export function candidate(overrides: Partial<Candidate> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
@@ -13,6 +13,19 @@ const draft = (): wire.DispatchDraftWire => ({ draftToken: 'draft', recordVersio
const references = () => ({ outbounds: [{ customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', status: 'WAIT_OUTBOUND', packageCount: '12', palletCount: '2' }], deliveries: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DEL-1', destination: destination(), status: 'WAIT_DELIVERY', packageCount: '12', palletCount: '2' }] })
const arrangement = () => ({ dispatchNo: 'PC/1', recordVersion: id, arrangementKey: 'key', cargoHash: 'hash', arrangedAt: '', ...references() })
it('keeps nullable original-source provenance distinct from the fulfillment order and normalizes only its evidenced quantity', () => {
const origin: wire.SourceOriginWire = { dispatchItemSourceId: id, relabelApplicationId: '9007199254740994', relabelItemId: '9007199254740995', sourceInventoryBalanceId: '9007199254740996', sourceInboundOrderId: '9007199254740997', sourceInboundOrderNo: 'ORIGINAL-IN-1', sourceInboundCode: null, productQuantity: '7' }
const cargo: wire.ArrangementPreviewWire['groups'][number]['cargo'][number] = { dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'FULFILLMENT-1', sourceKey: 'source-1', sourceStage: 'RELABEL', sourceRegion: null, sourceInboundId: null, inboundCode: 'CURRENT-IN-1', planPalletId: null, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: 'RL-1', destination: destination(), packages: '2', productQuantity: '24', grossWeightKg: null, volumeCbm: null, batchAllocations: [], sourceOrigin: origin }
const value: wire.ArrangementPreviewWire = { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'cargo', loadingCompletionHash: 'loading', arrange: gate, arrangement: null, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }] }
const normalized = wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!
expect(normalized.sourceOrigin).toEqual({ ...origin, productQuantity: 7 }); expect(normalized.orderId).toBe(id); expect(normalized.orderNo).toBe('FULFILLMENT-1'); expect(normalized.inboundCode).toBe('CURRENT-IN-1'); expect(normalized.productQuantity).toBe(24)
cargo.sourceOrigin = { ...origin, sourceInboundOrderNo: null, sourceInboundCode: null, productQuantity: null }
expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toEqual({ ...cargo.sourceOrigin })
cargo.sourceOrigin = null; expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toBeNull()
cargo.sourceOrigin = { ...origin, productQuantity: '9007199254740993' }
expect(() => wire.normalizeArrangementPreview(parsed(value))).toThrow('安全整数')
})
describe('native dispatch Long quantity boundary', () => {
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
@@ -68,7 +81,7 @@ describe('native dispatch Long quantity boundary', () => {
expect(result).toMatchObject({ fileId: id, sizeBytes: 3 }); expect(Number.isSafeInteger(result.sizeBytes)).toBe(true); expect(new Blob(['png']).size).toBe(result.sizeBytes)
})
it('normalizes every arrangement cargo and existing outbound/delivery quantity', () => {
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [] }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [], sourceOrigin: null }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
expect(result.groups[0]!.cargo[0]).toMatchObject({ dispatchItemId: id, packages: 12, productQuantity: 24, grossWeightKg: 12.25 }); expect(result.arrangement).toMatchObject({ recordVersion: id, outbounds: [{ packageCount: 12, palletCount: 2 }], deliveries: [{ packageCount: 12, palletCount: 2 }] })
})
it('normalizes typed command payloads and converted drafts and preserves immutable proof versions', () => {
+18 -5
View File
@@ -1,4 +1,4 @@
import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts } from './dispatchContracts'
import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts, type SelectionGroup, type SourceOrigin } from './dispatchContracts'
// common-web serializes both Long and long as decimal strings. Only quantity
// fields are normalized here; IDs, recordVersion and reviewRound stay lossless.
@@ -17,14 +17,17 @@ type StatisticsWire = Omit<DispatchStatistics, 'matchedTotal' | 'activeTotal' |
type ListRowWire = Omit<DispatchListRow, 'counts'> & { counts: CountsWire }
export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' | 'size' | 'pages' | 'statistics'> & { records: ListRowWire[]; total: LongQuantityWire; page: LongQuantityWire; size: LongQuantityWire; pages: LongQuantityWire; statistics: StatisticsWire }
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
export type CandidateWire = LongFields<Candidate, 'availablePackages'>
type SelectionGroupWire = Omit<SelectionGroup, 'sources' | 'pallets'> & { sources: Array<LongFields<SelectionGroup['sources'][number], 'packages'>>; pallets: Array<LongFields<SelectionGroup['pallets'][number], 'packageCount'>> }
export type CandidateWire = Omit<LongFields<Candidate, 'availablePackages'>, 'selectionGroup'> & { selectionGroup: SelectionGroupWire }
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
export type DispatchEvidenceFileWire = LongFields<DispatchEvidenceFile, 'sizeBytes'>
type ArrangementResultWire = Omit<ArrangementResult, keyof ReferencesWire> & ReferencesWire
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: Array<LongFields<CargoLine, 'packages' | 'productQuantity'>> }
export type SourceOriginWire = Omit<SourceOrigin, 'productQuantity'> & { productQuantity: LongQuantityWire | null }
type CargoLineWire = Omit<LongFields<CargoLine, 'packages' | 'productQuantity'>, 'sourceOrigin'> & { sourceOrigin: SourceOriginWire | null }
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: CargoLineWire[] }
export type ArrangementPreviewWire = Omit<ArrangementPreview, 'groups' | 'arrangement'> & { groups: PreviewGroupWire[]; arrangement: ArrangementResultWire | null }
type PickedWire = LongFields<Extract<CommandResult, { action: 'PICK' }>['result'], 'packages' | 'pickedPackages'>
type DepartedWire = Omit<Extract<CommandResult, { action: 'DEPART' }>['result'], 'shippedPackages' | keyof ReferencesWire> & ReferencesWire & { shippedPackages: LongQuantityWire }
@@ -46,7 +49,12 @@ function numbers<T, K extends keyof T>(value: LongFields<T, K>, fields: readonly
const counts = (value: CountsWire) => numbers<Counts, keyof Counts>(value, ['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount'])
const reviewCounts = (value: ReviewCountsWire) => numbers<ReviewCounts, keyof ReviewCounts>(value, ['notSubmitted', 'pending', 'approved', 'rejected'])
const references = (value: ReferencesWire) => ({ outbounds: value.outbounds.map(row => numbers<OutboundReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])), deliveries: value.deliveries.map(row => numbers<DeliveryReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])) })
const candidate = (value: CandidateWire): Candidate => { requireBatchVersions(value.appointmentBatches); return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages') } }
const candidate = (value: CandidateWire): Candidate => {
requireBatchVersions(value.appointmentBatches)
const group = value.selectionGroup
if (!group || typeof group.groupKey !== 'string' || typeof group.groupVersion !== 'string' || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages'), selectionGroup: { ...group, sources: group.sources.map(source => ({ ...source, packages: dispatchQuantity(source.packages, 'selectionGroup.sources.packages') })), pallets: group.pallets.map(pallet => ({ ...pallet, packageCount: dispatchQuantity(pallet.packageCount, 'selectionGroup.pallets.packageCount') })) } }
}
function statistics(value: StatisticsWire): DispatchStatistics {
return { ...value, matchedTotal: dispatchQuantity(value.matchedTotal, 'matchedTotal'), activeTotal: dispatchQuantity(value.activeTotal, 'activeTotal'), cancelledTotal: dispatchQuantity(value.cancelledTotal, 'cancelledTotal'), stageCounts: Object.fromEntries(Object.entries(value.stageCounts).map(([stage, count]) => [stage, dispatchQuantity(count, `stageCounts.${stage}`)])) as Record<DispatchStage, number>, beforeLoadingReviewCounts: { vehicleInfoTotal: dispatchQuantity(value.beforeLoadingReviewCounts.vehicleInfoTotal, 'vehicleInfoTotal'), dispatchReview: reviewCounts(value.beforeLoadingReviewCounts.dispatchReview), loadingReview: reviewCounts(value.beforeLoadingReviewCounts.loadingReview) } }
}
@@ -58,7 +66,12 @@ export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
export function normalizeEvidenceFile(value: DispatchEvidenceFileWire): DispatchEvidenceFile { return { ...value, sizeBytes: dispatchQuantity(value.sizeBytes, 'sizeBytes') } }
function arrangementResult(value: ArrangementResultWire): ArrangementResult { return { ...value, ...references(value) } }
export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity') })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
function normalizeSourceOrigin(value: SourceOriginWire | null): SourceOrigin | null {
if (value == null) return null
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new DispatchInputError('排车响应原货源结构缺失,请重新读取')
return { ...value, productQuantity: value.productQuantity == null ? null : dispatchQuantity(value.productQuantity, 'sourceOrigin.productQuantity') }
}
export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity'), sourceOrigin: normalizeSourceOrigin(row.sourceOrigin) })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
export function normalizeDispatchCommand(value: CommandResultWire): CommandResult {
const normalizedCounts = counts(value.counts)
switch (value.action) {
+1 -1
View File
@@ -9,7 +9,7 @@ export { overseasWarehousePermissions } from './permissions'
export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
export { DispatchInputError, requireVersion, requireBatchVersions, stageLabel } from './dispatchContracts'
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
@@ -66,18 +66,20 @@ describe('overseas warehouse 1920px layout acceptance', () => {
expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(3)
expect(page('InventoryDetailView.vue')).toContain('.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }')
const outbounds = page('OutboundsView.vue')
expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }')
expect(outbounds).toContain('density-table density-table--editable outbound-editor-table')
const density = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')
expect(density).toContain('.density-form :is(.el-select, .el-input-number, .el-date-editor) { width: 100%; }')
expect(outbounds).not.toContain('.create-outbound-form :deep(.el-select)')
expect(outbounds).toContain('.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-date-editor) { width: 100%; }')
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-input-number) { width: 100%; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
expect(page('RelabelOrdersView.vue')).toContain('density-table density-table--editable relabel-editor-table')
expect(page('PalletsView.vue')).toContain('density-table density-table--editable pallet-editor-table')
expect(page('RelabelOrdersView.vue')).toContain('label-position="left" label-width="120px"')
expect(page('PalletsView.vue')).toContain('label-position="left" label-width="120px"')
const lastMileDetail = page('LastMileOrderDetailView.vue')
expect(lastMileDetail).toContain('class="fee-form-grid"')
expect(lastMileDetail).toContain('density-table density-table--editable fee-editor-table')
expect(lastMileDetail).toContain('filterable allow-create clearable default-first-option')
expect(lastMileDetail).toContain('.fee-form { min-width: 0; overflow-x: hidden; }')
expect(lastMileDetail).toContain('.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }')
expect(lastMileDetail).toContain('.fee-editor-table { table-layout: fixed; }')
expect(lastMileDetail).toContain('.fee-editor-table td:not([colspan]) { width: 50%; }')
expect(lastMileDetail).toContain("field.label === '入仓码' || field.label === '集装箱号'")
expect(lastMileDetail).toContain(':title="isSingleLineBusinessField(field) ? String(field.value) : undefined"')
expect(lastMileDetail).toContain('.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
@@ -0,0 +1,173 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { CargoDiscardDetail, CargoDiscardSummary } from '../api/cargoDiscards'
import CargoDiscardsView from './CargoDiscardsView.vue'
const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function summary(overrides: Partial<CargoDiscardSummary> = {}): CargoDiscardSummary { return { id: '9007199254740993', customerId: '9007199254740994', discardNo: 'CD-001', inboundCodes: 'INBOUND-1', overseasWarehouseName: '洛杉矶仓', outboundNo: 'OUT-1', status: 'PENDING_REVIEW', totalPackages: 2, totalFee: 20, currency: 'USD', ...overrides } }
function detail(): CargoDiscardDetail { return { summary: summary(), remark: '废弃原因与备注', items: [{ id: '9007199254740995', inboundCode: 'INBOUND-1', skuCode: 'SKU-LONG-ORIGINAL-001', productName: '原始完整品名'.repeat(10), discardPackages: 2, cartonMarks: ['MARK-001', 'MARK-002'] }], histories: [{ toStatus: 'PENDING_REVIEW', createdAt: '2026-10-20T09:00:00', remark: '已提交申请' }], photos: [] } }
function photo(fileId = '9007199254740996') { return { fileId, palletNo: 'PALLET-1', fileName: '废弃现场.png', createdAt: '2026-10-20T09:00:00', submittedAt: '2026-10-20T09:01:00' } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.customers.mockResolvedValue({ records: [{ id: '9007199254740994', tenantName: '客户一' }] })
api.list.mockResolvedValue({ records: [summary()], total: 1 })
api.detail.mockImplementation(async () => detail()); api.review.mockResolvedValue(detail())
api.photo.mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
URL.createObjectURL = vi.fn().mockReturnValue('blob:discard-photo'); URL.revokeObjectURL = vi.fn()
})
async function page() { const wrapper = mount(CargoDiscardsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function row(wrapper: VueWrapper, no = 'CD-001') { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(no)).at(-1)! }
async function open() { const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!) }
async function accept() { await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() }
function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: string) { return wrapper.findAllComponents(LoadErrorState).find(item => item.props('title') === title)! }
async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() }
describe('native cargo discard detail and approval', () => {
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
const { dialog } = await open()
expect(dialog.props('title')).toBe('废弃单详情 · CD-001'); expect(api.detail).toHaveBeenCalledExactlyOnceWith('CD-001', '9007199254740994')
const facts = dialog.get('[aria-label="废弃单基础资料"]')
for (const value of ['CD-001', 'OUT-1', '洛杉矶仓', 'INBOUND-1', '待审核', '废弃原因与备注']) expect(facts.text()).toContain(value)
expect(facts.findAll('input, textarea, select')).toHaveLength(0)
const clamps = dialog.findAllComponents(LineClampText).map(item => item.props('content'))
expect(clamps).toContain('原始完整品名'.repeat(10)); expect(clamps).toContain('MARK-001、MARK-002'); expect(clamps).toContain('SKU-LONG-ORIGINAL-001')
expect(dialog.get('[aria-label="废弃状态流转"]').text()).toContain('已提交申请')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.findAll('input, textarea')).toHaveLength(0); expect(api.review).not.toHaveBeenCalled()
})
it('locks reading and retries the original business number and customer scope after failure', async () => {
let reject!: (error: Error) => void
api.detail.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await open()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(dialog.get('[role="status"]').text()).toContain('正在读取'); expect(dialog.find('[aria-label="废弃单基础资料"]').exists()).toBe(false)
reject(new ApiResponseError('读取失败', 'A0503', 503, null, 'discard-detail-request')); await flushPromises()
expect(dialog.text()).toContain('discard-detail-request'); await retry(errorState(dialog, '废弃单详情加载失败'))
expect(api.detail.mock.calls).toEqual([['CD-001', '9007199254740994'], ['CD-001', '9007199254740994']])
expect(dialog.get('[aria-label="废弃单基础资料"]').text()).toContain('CD-001')
})
it('confirms approval with the business number and sends the original APPROVE request only once', async () => {
let resolve!: (value: CargoDiscardDetail) => void
api.review.mockReturnValueOnce(new Promise(done => { resolve = done }))
const wrapper = await page(); const button = row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!
await button.trigger('click'); await flushPromises()
expect(confirmation().text()).toContain('CD-001'); expect(confirmation().text()).toContain('推送 PDA')
expect(confirmation().classes()).toContain('admin-density-confirm'); expect(api.review).not.toHaveBeenCalled()
await accept(); await button.trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledExactlyOnceWith('CD-001', 'APPROVE', undefined, '9007199254740994')
expect(row(wrapper).findAll('button').every(item => item.attributes('disabled') !== undefined)).toBe(true)
resolve(detail()); await flushPromises(); expect(api.list).toHaveBeenCalledTimes(2)
})
it('preserves the list and diagnostics on approval failure, and ignores native cancel and close', async () => {
api.review.mockRejectedValueOnce(new ApiResponseError('审核未确认', 'A0503', 503, null, 'discard-review-request'))
const wrapper = await page()
const approve = () => row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!
await approve().trigger('click'); await flushPromises(); await accept()
expect(wrapper.text()).toContain('discard-review-request'); expect(row(wrapper).text()).toContain('CD-001')
await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledOnce()
await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__headerbtn').trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledOnce(); expect(errorState(wrapper, '审核失败')).toBeUndefined()
})
it('keeps list access readonly, checks approval status and rechecks permission after confirmation', async () => {
can.mockImplementation(code => code === permission.discardList)
const readonly = await page(); expect(row(readonly).findAll('button').map(item => item.text())).toEqual(['详情'])
can.mockReturnValue(true); api.list.mockResolvedValueOnce({ records: [summary({ status: 'APPROVED' })], total: 1 })
const approved = await page(); expect(row(approved).findAll('button').map(item => item.text())).toEqual(['详情'])
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!.trigger('click'); await flushPromises()
can.mockImplementation(code => code !== permission.discardApprove); await accept(); expect(api.review).not.toHaveBeenCalled()
})
it('downloads each photo in its explicit scope and keeps native viewer order, size and cleanup', async () => {
const result = detail(); result.photos = [photo(), photo('9007199254740997')]
const first = new Blob(['first'], { type: 'image/png' }); const second = new Blob(['second'], { type: 'image/png' })
let resolve!: (value: Blob) => void
api.detail.mockResolvedValueOnce(result)
api.photo.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(second)
vi.mocked(URL.createObjectURL).mockImplementation(blob => blob === first ? 'blob:first' : 'blob:second')
const { dialog } = await open(); expect(dialog.text()).toContain('照片加载中')
resolve(first); await flushPromises()
expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740997', '9007199254740994']])
const images = dialog.findAllComponents({ name: 'ElImage' }); expect(images).toHaveLength(2)
expect(images[0]!.props()).toMatchObject({ src: 'blob:first', previewSrcList: ['blob:first', 'blob:second'], initialIndex: 0, previewTeleported: true, fit: 'cover' })
expect(images[1]!.props('initialIndex')).toBe(1)
await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:first'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:second')
})
it('shows per-photo diagnostics and retries the same file, business number and customer', async () => {
const result = detail(); result.photos = [photo()]; api.detail.mockResolvedValueOnce(result)
api.photo.mockRejectedValueOnce(new ApiResponseError('图片失败', 'A0503', 503, null, 'discard-photo-request'))
const { dialog } = await open(); expect(dialog.text()).toContain('discard-photo-request')
await retry(errorState(dialog, '废弃照片加载失败'))
expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740996', '9007199254740994']])
expect(dialog.findComponent({ name: 'ElImage' }).exists()).toBe(true)
})
it('ignores late photos after closing and switching to another business number', async () => {
const old = detail(); old.photos = [photo()]; api.detail.mockResolvedValueOnce(old)
let resolve!: (value: Blob) => void
api.photo.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, dialog } = await open(); await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
const next = detail(); next.summary = summary({ discardNo: 'CD-002', customerId: 'CUSTOMER-2' }); next.photos = [photo()]
api.list.mockResolvedValueOnce({ records: [next.summary], total: 1 }); api.detail.mockResolvedValueOnce(next)
wrapper.findComponent(QueryToolbar).vm.$emit('reset', { customerId: '', discardNo: '', inboundCode: '', status: '' }); await flushPromises()
await row(wrapper, 'CD-002').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises()
expect(api.photo).toHaveBeenLastCalledWith('CD-002', '9007199254740996', 'CUSTOMER-2')
resolve(new Blob(['old'], { type: 'image/png' })); await flushPromises()
expect(URL.createObjectURL).toHaveBeenCalledOnce(); expect(dialog.props('title')).toBe('废弃单详情 · CD-002')
})
it('ignores a detail response after unmounting before it can start photo downloads', async () => {
let resolve!: (value: CargoDiscardDetail) => void
api.detail.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = await open(); wrapper.unmount()
const result = detail(); result.photos = [photo()]; resolve(result); await flushPromises()
expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('keeps empty evidence explicit and never infers all inventory from missing carton marks', async () => {
const result = detail(); result.items[0]!.cartonMarks = []; result.histories = []; result.summary.status = null
api.list.mockResolvedValueOnce({ records: [summary({ status: null })], total: 1 }); api.detail.mockResolvedValueOnce(result)
const { wrapper, dialog } = await open()
expect(wrapper.text()).not.toContain('undefined'); expect(dialog.text()).not.toContain('undefined')
expect(dialog.text()).toContain('暂无已提交的废弃现场照片'); expect(dialog.text()).toContain('暂无状态流转记录')
expect(dialog.text()).not.toContain('全部库存'); expect(dialog.text()).not.toContain('历史记录')
expect(dialog.findAllComponents(LineClampText).some(item => item.props('content') === '')).toBe(true)
expect(dialog.text()).not.toContain('9007199254740995')
})
it('blocks photo requests with a missing customer scope instead of sending an undefined identifier', async () => {
const result = detail(); result.summary.customerId = null; result.photos = [photo()]; api.detail.mockResolvedValueOnce(result)
const { dialog } = await open(); expect(dialog.text()).toContain('照片缺少客户范围')
expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('keeps customer and list failures independent and retries each original query', async () => {
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'discard-customer-request'))
api.list.mockRejectedValueOnce(new ApiResponseError('列表失败', 'A0503', 503, null, 'discard-list-request'))
const wrapper = await page(); expect(wrapper.text()).toContain('discard-customer-request'); expect(wrapper.text()).toContain('discard-list-request')
await retry(errorState(wrapper, '废弃单列表加载失败')); expect(row(wrapper).text()).toContain('CD-001')
await retry(errorState(wrapper, '客户列表加载失败'))
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(api.list).toHaveBeenLastCalledWith({ page: 1, size: 10, customerId: undefined, discardNo: undefined, inboundCode: undefined, status: undefined })
})
it('ignores a stale list response after a newer query has completed', async () => {
let resolve!: (value: { records: CargoDiscardSummary[]; total: number }) => void
api.list.mockReturnValueOnce(new Promise(done => { resolve = done }))
const wrapper = await page(); api.list.mockResolvedValueOnce({ records: [summary({ discardNo: 'CD-LATEST' })], total: 1 })
wrapper.findComponent(QueryToolbar).vm.$emit('search', { customerId: '', discardNo: 'CD-LATEST', inboundCode: '', status: '' }); await flushPromises()
resolve({ records: [summary({ discardNo: 'CD-OLD' })], total: 1 }); await flushPromises()
expect(wrapper.text()).toContain('CD-LATEST'); expect(wrapper.text()).not.toContain('CD-OLD')
})
})
@@ -8,43 +8,202 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const access = useAccessPreviewStore()
const rows = ref<CargoDiscardSummary[]>([])
const customers = ref<PlatformTenantResource[]>([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loading = ref(false)
const customersLoading = ref(false)
const listError = ref<ApiErrorDetails | null>(null)
const customersError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const auditingNo = ref('')
const detailOpen = ref(false)
const detail = ref<CargoDiscardDetail | null>(null)
const detailRequest = ref<Pick<CargoDiscardSummary, 'discardNo' | 'customerId'> | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '请选择客户', options: [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))], props: { disabled: customersLoading.value || Boolean(customersError.value), filterable: true } },
{ key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 },
{ key: 'status', type: 'select', label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, boolean>>({})
const photoUrls = reactive<Record<string, string>>({})
const photoLoading = reactive<Record<string, boolean>>({})
const photoErrors = reactive<Record<string, ApiErrorDetails | undefined>>({})
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
let photoGeneration = 0
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(URL.revokeObjectURL); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
let detailGeneration = 0
let listGeneration = 0
function clearPhotos() {
photoGeneration++
Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url))
for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key])
}
async function loadPhoto(fileId: string) {
const current = detail.value; const generation = photoGeneration
if (!current || photoLoading[fileId]) return
photoLoading[fileId] = true; photoErrors[fileId] = false
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, String(current.summary.customerId)); if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
catch { if (generation === photoGeneration) photoErrors[fileId] = true }
const current = detail.value
const generation = photoGeneration
if (!current || !detailOpen.value || photoLoading[fileId] || !access.can(permission.discardList)) return
photoLoading[fileId] = true; delete photoErrors[fileId]
try {
const customerId = String(current.summary.customerId ?? '').trim()
if (!customerId) { photoErrors[fileId] = getApiErrorDetails(null, '照片缺少客户范围,无法读取'); return }
const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, customerId)
if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return
if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId])
photoUrls[fileId] = URL.createObjectURL(blob)
} catch (error) { if (generation === photoGeneration) photoErrors[fileId] = getApiErrorDetails(error, '废弃照片加载失败,请重试') }
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
}
watch(detailOpen, value => { if (!value) clearPhotos() })
onBeforeUnmount(clearPhotos)
watch(detailOpen, value => {
if (value) return
detailGeneration++; detailLoading.value = false; detailRequest.value = null; detail.value = null; detailError.value = null; clearPhotos()
})
onBeforeUnmount(() => { detailGeneration++; listGeneration++; clearPhotos() })
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } }
onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } })
async function loadCustomers() {
if (customersLoading.value) return
customersLoading.value = true; customersError.value = null
try { customers.value = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records }
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
const generation = ++listGeneration
loading.value = true; listError.value = null
try {
const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') })
if (generation !== listGeneration) return
rows.value = result.records; total.value = result.total
} catch (error) {
if (generation !== listGeneration) return
rows.value = []; total.value = 0; listError.value = getApiErrorDetails(error, '废弃单列表加载失败')
} finally { if (generation === listGeneration) loading.value = false }
}
async function openDetail(row: CargoDiscardSummary) {
if (detailOpen.value || detailLoading.value || auditingNo.value || !access.can(permission.discardList)) return
detailRequest.value = { discardNo: row.discardNo, customerId: row.customerId }
detail.value = null; detailError.value = null; detailOpen.value = true
await loadDetail()
}
async function loadDetail() {
const request = detailRequest.value
if (!request || !detailOpen.value || detailLoading.value || !access.can(permission.discardList)) return
const generation = ++detailGeneration
detailLoading.value = true; detailError.value = null; detail.value = null; clearPhotos()
try {
const result = await getCargoDiscard(request.discardNo, request.customerId || undefined)
if (generation !== detailGeneration || !detailOpen.value) return
detail.value = result
void Promise.all(result.photos.map(photo => loadPhoto(photo.fileId)))
} catch (error) { if (generation === detailGeneration && detailOpen.value) detailError.value = getApiErrorDetails(error, '废弃单详情加载失败') }
finally { if (generation === detailGeneration) detailLoading.value = false }
}
function canAudit(row: CargoDiscardSummary) { return access.can(permission.discardApprove) && row.status === 'PENDING_REVIEW' }
async function audit(row: CargoDiscardSummary) {
if (!canAudit(row) || auditingNo.value || detailOpen.value || detailLoading.value) return
auditingNo.value = row.discardNo; actionError.value = null
try {
await ElMessageBox.confirm(`确认审核废弃单“${row.discardNo}”?审核通过后将推送 PDA 执行废弃,确认继续?`, '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm', type: 'warning' })
if (!canAudit(row)) return
await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined)
ElMessage.success('已审核通过并推送 PDA'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '审核失败') }
finally { auditingNo.value = '' }
}
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left>共 {{ total }} 条废弃单</template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="出库单号">{{ detail.summary.outboundNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请数量" width="100"/><el-table-column label="所选箱唛" min-width="180"><template #default="{ row }">{{ row.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</template></el-table-column></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
<AdminPage class="admin-density">
<ListPageLayout aria-label="废弃单列表"><section>
<DataTable appearance="integrated" :stripe="false" :results-visible="!listError" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>废弃单</h1></template>
<template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template>
<template #notices>
<LoadErrorState v-if="customersError" title="客户列表加载失败" :message="customersError.message" :loading="customersLoading" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState>
<LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
<LoadErrorState v-if="actionError" title="审核失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions">
<el-button v-if="access.can(permission.discardList)" class="density-action" data-tone="neutral" size="small" :disabled="detailOpen || detailLoading || Boolean(auditingNo)" @click="openDetail(row as CargoDiscardSummary)">详情</el-button>
<el-button v-if="canAudit(row as CargoDiscardSummary)" class="density-action" data-tone="success" size="small" :disabled="detailOpen || detailLoading || Boolean(auditingNo)" :loading="auditingNo === (row as CargoDiscardSummary).discardNo" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button>
</div></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '—'" /></template>
<template #pagination-left>共 {{ total }} 条废弃单</template>
</DataTable>
</section></ListPageLayout>
</AdminPage>
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="`废弃单详情 · ${detailRequest?.discardNo || '—'}`" fullscreenable :show-confirm="false" cancel-text="关闭" :loading="detailLoading" :confirm-loading="detailLoading" width="min(920px, calc(100vw - 28px))">
<p v-if="detailLoading" class="density-dialog-note" role="status">正在读取废弃单详情…</p>
<LoadErrorState v-if="detailError" title="废弃单详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="废弃单基础资料">
<table class="density-table discard-summary"><tbody>
<tr><th scope="row">废弃单号</th><td><LineClampText :content="detail.summary.discardNo" /></td><th scope="row">状态</th><td>{{ statusLabels[String(detail.summary.status)] || detail.summary.status || '—' }}</td></tr>
<tr><th scope="row">仓库</th><td><LineClampText :content="detail.summary.overseasWarehouseName" /></td><th scope="row">出库单号</th><td><LineClampText :content="detail.summary.outboundNo" /></td></tr>
<tr><th scope="row">入仓码</th><td colspan="3"><LineClampText :content="detail.summary.inboundCodes" :lines="2" /></td></tr>
<tr><th scope="row">备注</th><td colspan="3"><LineClampText :content="detail.remark" :lines="2" /></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="废弃明细">
<h3 class="density-dialog-section__title">废弃明细</h3>
<el-table :data="detail.items" border :stripe="false" row-key="id">
<el-table-column prop="inboundCode" label="入仓码" min-width="140"><template #default="{ row }"><LineClampText :content="row.inboundCode" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="140"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="productName" label="品名" min-width="160"><template #default="{ row }"><LineClampText :content="row.productName" /></template></el-table-column>
<el-table-column prop="discardPackages" label="申请数量" width="100" />
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、')" /></template></el-table-column>
</el-table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="废弃现场照片">
<h3 class="density-dialog-section__title">废弃现场照片</h3>
<div v-if="detail.photos.length" class="discard-photo-grid">
<div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :alt="photo.fileName || '废弃现场照片'" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
<div v-else class="discard-photo-stage">
<span v-if="photoLoading[photo.fileId]" role="status">照片加载中…</span>
<LoadErrorState v-else-if="photoErrors[photo.fileId]" compact title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" retry-text="重新加载照片" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button>
</div>
<small>托盘:{{ photo.palletNo || '—' }} · {{ photo.submittedAt || '—' }}</small>
</div>
</div>
<p v-else class="density-dialog-note">暂无已提交的废弃现场照片</p>
</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></tr></thead><tbody>
<tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
<tr v-if="!detail.histories.length"><td colspan="3">暂无状态流转记录</td></tr>
</tbody></table>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height:150px;border-radius:6px}.discard-photo-item small{color:var(--cc-text-secondary)}
.discard-summary { table-layout: fixed; }
.discard-summary th { width: 100px; }
.discard-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.discard-photo-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.discard-photo-item .el-image { width: 100%; height: 150px; }
.discard-photo-stage { display: grid; align-items: center; min-height: 150px; }
</style>
@@ -0,0 +1,172 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DeliveryOrdersView from './DeliveryOrdersView.vue'
import DeliveryDocumentsDialog from '../components/DeliveryDocumentsDialog.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { DeliveryOrderDetail, DeliveryOrderRow, DeliveryReturnRelation } from '../api/deliveryOrders'
import { deliveryDocumentCategories, type DeliveryDocuments, type DeliveryDocumentFile } from '../api/deliveryDocuments'
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), returns: vi.fn(), mark: vi.fn(), status: vi.fn(), documents: vi.fn(), save: vi.fn(), upload: vi.fn(), download: vi.fn(), warehouses: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: api.list, getDeliveryOrder: api.detail, listDeliveryReturnInboundOptions: api.returns, markDeliveryReturn: api.mark }))
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: api.status }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: api.warehouses }))
vi.mock('../api/deliveryDocuments', async original => ({ ...await original<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: api.documents, saveDeliveryDocuments: api.save, uploadDeliveryDocument: api.upload, downloadDeliveryDocument: api.download }))
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
const row = (): DeliveryOrderRow => ({ version: 0, allowedActions: ['START_DELIVERY', 'REJECT_DELIVERY', 'MARK_RETURN'], id: '100', customerId: 'C1', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 4, signedPackages: null, createdAt: '2026-10-20T10:00:00' })
const detail = (value = row()): DeliveryOrderDetail => ({ order: value, skus: [], fees: [], operations: [] })
const relation = (): DeliveryReturnRelation => ({ lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundCode: 'IN-1', customerId: 'C1', returnSources: [
{ outboundPalletItemId: '9007199254740993', inventoryPositionId: '9007199254740994', returnCartonId: null, palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 },
{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740996', cartonMark: 'RC-1', palletNo: 'PL-2', skuCode: null, maxReturnPackages: 1 },
] })
const file: DeliveryDocumentFile = { fileId: '9007199254740997', fileName: '完整文件名称.pdf', contentType: 'application/pdf', sizeBytes: 4, uploadedAt: '2026-10-20T10:00:00' }
const documents = (): DeliveryDocuments => ({ deliveryNo: 'DEL/1', customerId: 'C1', version: 0, bolNo: 'BOL-1', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'BOL' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'BOL' ? 1 : 0, files: item.category === 'BOL' ? [{ ...file }] : [] })) })
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
api.warehouses.mockResolvedValue({ records: [] })
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [row()], total: 1 }); api.detail.mockResolvedValue(detail()); api.returns.mockResolvedValue([relation()]); api.mark.mockResolvedValue({ exceptionNo: 'EXC-1' }); api.status.mockResolvedValue({ deliveryNo: 'DEL/1' })
api.documents.mockResolvedValue(documents()); api.save.mockResolvedValue(documents()); api.upload.mockResolvedValue({ ...file, fileId: '9007199254740998', fileName: 'receipt.pdf' }); api.download.mockResolvedValue(new Blob(['file']))
URL.createObjectURL = vi.fn().mockReturnValue('blob:delivery-file'); URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function openPage(action: '编辑状态' | '标记退货') {
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises()
const title = action === '编辑状态' ? '编辑派送状态' : action
return { wrapper, dialog: wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
}
async function openDocuments() { const wrapper = mount(DeliveryDocumentsDialog, { props: { modelValue: true, deliveryNo: 'DEL/1', customerId: 'C1' }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function validReturn(dialog: VueWrapper) { await field(dialog, '退货原因').get('textarea').setValue(' 客户拒收 '); await dialog.get('.return-source input[type="checkbox"]').setValue(true); dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises() }
describe('native delivery operation dialogs', () => {
it('keeps current facts read-only and submits an allowed status with version zero', async () => {
const { dialog } = await openPage('编辑状态')
expect(dialog.props('title')).toBe('编辑派送状态 · DEL/1')
expect(dialog.get('[aria-label="派送状态依据"]').findAll('input')).toHaveLength(0)
const select = field(dialog, '派送状态').getComponent({ name: 'ElSelect' })
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['DELIVERING', 'REJECTED'])
select.vm.$emit('update:modelValue', 'DELIVERING'); await field(dialog, '备注').get('textarea').setValue(' 本次状态备注 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { status: 'DELIVERING', expectedVersion: 0, remark: '本次状态备注' })
})
it('locks status loading and preserves notes through conflict and authoritative reload', async () => {
let resolve!: (value: DeliveryOrderDetail) => void
api.detail.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { dialog } = await openPage('编辑状态')
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('closeOnPressEscape')).toBe(false)
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
resolve(detail()); await flushPromises()
field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'REJECTED')
await field(dialog, '备注').get('textarea').setValue('保留状态备注')
api.status.mockRejectedValueOnce(new ApiResponseError('状态冲突', 'A0409', 409, null, 'delivery-status-request'))
dialog.vm.$emit('confirm'); await flushPromises(); expect(dialog.text()).toContain('delivery-status-request')
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
api.detail.mockResolvedValueOnce(detail({ ...row(), version: 8, allowedActions: ['START_DELIVERY'] }))
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('保留状态备注')
field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DELIVERING'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenLastCalledWith('DEL/1', 'C1', { status: 'DELIVERING', expectedVersion: 8, remark: '保留状态备注' })
})
it('explains when the latest record has no available status transition', async () => {
api.detail.mockResolvedValueOnce(detail({ ...row(), allowedActions: [] }))
const { dialog } = await openPage('编辑状态')
expect(dialog.text()).toContain('当前状态已无可操作的派送状态')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.status).not.toHaveBeenCalled()
})
it('keeps source identities lossless and original return boxes fixed at one package', async () => {
const { dialog } = await openPage('标记退货'); await validReturn(dialog)
expect(dialog.props('fullscreenable')).toBe(true)
expect(dialog.get('.delivery-return-sources th').text()).toBe('操作')
expect(dialog.text()).toContain('RC-1(整箱退回)'); expect(dialog.text()).not.toContain('9007199254740996')
const sources = dialog.findAll('.return-source'); await sources[1]!.get('input[type="checkbox"]').setValue(true); await flushPromises()
expect(sources[1]!.getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ modelValue: 1, disabled: true })
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect((field(dialog, '退货原因').get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户拒收 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.mark).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740993', inventoryPositionId: '9007199254740994', packageCount: 2 }, { outboundPalletItemId: '9007199254740995', returnCartonId: '9007199254740996', packageCount: 1 }], idempotencyKey: expect.any(String) })
})
it('retries failed return source reads within the original delivery and customer', async () => {
api.returns.mockRejectedValueOnce(new ApiResponseError('来源不可用', 'A0503', 503, null, 'return-source-request'))
const { dialog } = await openPage('标记退货'); expect(dialog.text()).toContain('return-source-request')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.mark).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.returns).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.findAll('.return-source')).toHaveLength(2)
})
it('locks duplicate returns and retains failed inputs and the original request', async () => {
let reject!: (cause: Error) => void
api.mark.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
const { dialog } = await openPage('标记退货'); await validReturn(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.mark).toHaveBeenCalledOnce(); expect(dialog.props('confirmLoading')).toBe(true)
expect(field(dialog, '退货原因').get('textarea').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
const original = JSON.parse(JSON.stringify(api.mark.mock.calls[0]![2]))
reject(new ApiResponseError('结果未确认', 'A0503', 503, null, 'return-save-request')); await flushPromises()
expect(dialog.text()).toContain('return-save-request'); expect((field(dialog, '退货原因').get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户拒收 ')
dialog.vm.$emit('confirm'); await flushPromises(); expect(JSON.parse(JSON.stringify(api.mark.mock.calls[1]![2]))).toEqual(original)
})
it('stages real uploads, locks the parent and saves all categories with version zero', async () => {
let resolve!: (value: DeliveryDocumentFile) => void
api.upload.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, dialog } = await openDocuments()
expect(dialog.get('.document-files th').text()).toBe('操作')
await dialog.get('[aria-label="下载 完整文件名称.pdf"]').trigger('click'); await flushPromises()
expect(api.download).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', 'BOL', file.fileId)
await field(dialog, 'BOL号').get('input').setValue(' BOL-NEW ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
const input = dialog.get('[data-category="RECEIPT"] header input[type="file"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['next'], 'receipt.pdf')] }); await input.trigger('change'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true); expect(field(dialog, 'BOL号').get('input').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
resolve({ ...file, fileId: '9007199254740998', fileName: 'receipt.pdf' }); await flushPromises()
expect(dialog.get('[aria-label="下载 receipt.pdf"]').attributes('disabled')).toBeDefined()
expect(api.save).not.toHaveBeenCalled(); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.save).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { expectedVersion: 0, bolNo: 'BOL-NEW', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [file.fileId] }, { category: 'RECEIPT', fileIds: ['9007199254740998'] }], idempotencyKey: expect.any(String) })
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('keeps readonly document facts plain and only exposes saved downloads', async () => {
api.documents.mockResolvedValueOnce({ ...documents(), allowedActions: [] })
const { dialog } = await openDocuments()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('当前账号仅可查看已保存资料')
expect(dialog.findAll('input')).toHaveLength(0); expect(dialog.text()).toContain('BOL-1')
expect(dialog.findAll('[data-category]')).toHaveLength(3)
expect(dialog.findAll('button').some(button => button.text() === '上传')).toBe(false)
await dialog.get('[aria-label="下载 完整文件名称.pdf"]').trigger('click'); await flushPromises(); expect(api.download).toHaveBeenCalledOnce()
})
it('shows an unavailable summary after a document read failure and retries within scope', async () => {
api.documents.mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'document-read-request'))
const { dialog } = await openDocuments()
expect(dialog.text()).toContain('document-read-request'); expect(dialog.text()).toContain('资料未返回')
expect(dialog.text()).not.toContain('正在读取…'); expect(dialog.props('showConfirm')).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.documents).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.text()).toContain('1/3 已上传')
})
it('retains conflicting document drafts until confirmed reload', async () => {
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
api.save.mockRejectedValueOnce(new ApiResponseError('资料版本冲突', 'A0409', 409, null, 'document-request'))
const { dialog } = await openDocuments(); await field(dialog, 'BOL号').get('input').setValue('保留草稿')
dialog.vm.$emit('confirm'); await flushPromises(); expect(dialog.text()).toContain('document-request')
expect(field(dialog, 'BOL号').get('input').attributes('disabled')).toBeDefined()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(confirmation).toHaveBeenCalledOnce(); expect(api.documents).toHaveBeenCalledOnce()
expect((field(dialog, 'BOL号').get('input').element as HTMLInputElement).value).toBe('保留草稿')
confirmation.mockRestore()
api.documents.mockResolvedValueOnce({ ...documents(), version: 1, bolNo: 'BOL-LATEST' })
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
const confirm = document.querySelector<HTMLButtonElement>('.el-message-box__btns .el-button--primary')
expect(confirm).not.toBeNull(); confirm!.click(); await flushPromises()
expect(api.documents).toHaveBeenCalledTimes(2); expect((field(dialog, 'BOL号').get('input').element as HTMLInputElement).value).toBe('BOL-LATEST')
})
})
@@ -56,18 +56,18 @@ describe('DeliveryOrdersView permissions and commands', () => {
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
await wrapper.find('[data-dialog^="编辑派送状态"] select').setValue('DELIVERING')
await wrapper.find('[data-dialog^="编辑派送状态"] .dialog-confirm').trigger('click')
await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: undefined })
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100')
await wrapper.find('[data-dialog="标记退货"] textarea').setValue(' 客户拒收 ')
await wrapper.find('[data-dialog="标记退货"] input[type="checkbox"]').setValue(true)
await wrapper.find('[data-dialog="标记退货"] input[type="number"]').setValue(2)
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
await wrapper.find('[data-dialog^="标记退货"] textarea').setValue(' 客户拒收 ')
await wrapper.find('[data-dialog^="标记退货"] input[type="checkbox"]').setValue(true)
await wrapper.find('[data-dialog^="标记退货"] input[type="number"]').setValue(2)
await wrapper.find('[data-dialog^="标记退货"] .dialog-confirm').trigger('click')
await flushPromises()
expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', {
lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
@@ -83,7 +83,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
await wrapper.find('[data-dialog^="标记退货"] .dialog-confirm').trigger('click')
expect(markDeliveryReturn).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -94,16 +94,16 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
const select = wrapper.find('[data-dialog="编辑派送状态"] select')
const select = wrapper.find('[data-dialog^="编辑派送状态"] select')
expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中')
expect(select.find('option[value="DELIVERED"]').exists()).toBe(false)
expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false)
expect(select.find('option[value="CANCELLED"]').exists()).toBe(false)
await select.setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123')
await wrapper.find('[data-dialog^="编辑派送状态"] textarea').setValue('123')
let reject!: (error: unknown) => void
vi.mocked(updateDeliveryOrderStatus).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
const button = wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm')
const button = wrapper.find('[data-dialog^="编辑派送状态"] .dialog-confirm')
await button.trigger('click'); await button.trigger('click')
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '123' })
@@ -155,7 +155,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '9007199254741299', lastMileOrderNo: 'LM-RETURN-1', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }] }])
const wrapper = mountView(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
expect(dialog.text()).toContain('原退货箱 RETURN-CARTON-1(整箱退回)')
expect(dialog.text()).toContain('LM-RETURN-1 · 无普通入仓关联')
expect(dialog.text()).not.toContain('9007199254741299')
@@ -175,7 +175,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
expect(dialog.text()).toContain('LM-RELABEL-1 · 无普通入仓关联')
expect(dialog.text()).not.toContain('9007199254741300')
await dialog.get('textarea').setValue('换标主单拒收')
@@ -198,7 +198,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
const masterSelect = dialog.get('select')
expect(masterSelect.findAll('option').map(option => option.attributes('value'))).toEqual(['109', '110'])
await masterSelect.setValue('109')
@@ -224,7 +224,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
await dialog.get('textarea').setValue('拒收')
const state = wrapper.vm as unknown as { returnItems: Array<typeof ownCarton & { selected: boolean; packageCount: number }> }
state.returnItems = [{ ...ownCarton, returnCartonId: '22', cartonMark: 'FOREIGN-CARTON', selected: true, packageCount: 1 }]
@@ -239,7 +239,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
const dialog = wrapper.find('[data-dialog="标记退货"]')
const dialog = wrapper.find('[data-dialog^="标记退货"]')
expect(dialog.text()).toContain('PL-1 · SKU-1(最多 3 箱)')
expect(dialog.text()).not.toContain('9007199254740995')
expect(dialog.text()).not.toContain('可自由填写')
@@ -278,7 +278,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
const dialog = wrapper.find('[data-dialog="标记退货"]')
const dialog = wrapper.find('[data-dialog^="标记退货"]')
await dialog.find('select').setValue('109')
await dialog.find('input[type="checkbox"]').setValue(true)
await dialog.find('input[type="number"]').setValue(2)
File diff suppressed because one or more lines are too long
@@ -16,6 +16,8 @@ const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTabl
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
it('uses only four accessible colored tabs and switches without another network read', async () => {
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatch).toHaveBeenCalledWith('PC/1')
const tabs = wrapper.findAll('[role="tab"]')
@@ -23,14 +25,18 @@ describe('whole-dispatch four-tab detail', () => {
expect(wrapper.findAll('.density-tab-icon')).toHaveLength(4)
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`).text()).toContain('LAX → ONT8')
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
})
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
const value = detail(); value.orderDetails.push({ ...value.orderDetails[0]!, customerId: 'C2', customerName: '客户乙', sourceKey: 'source-2', orderNo: 'LM-2', grossWeightKg: 12.25 })
value.orderDetails.forEach((row, index) => { row.sourceBusinessType = 'RELABEL_ORDER'; row.sourceBusinessNo = `RL-${index + 1}` })
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
expect(wrapper.getComponent(DataTableStub).vm.$attrs.columns).toContainEqual(expect.objectContaining({ prop: 'sourceBusinessNo', label: '来源业务号' }))
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
})
it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
@@ -34,7 +34,6 @@ const dispatchFields = computed(() => [
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
['车辆预约', detail.value?.dispatchInfo.vehicleAppointment ? [detail.value.dispatchInfo.vehicleAppointment.referenceNo, detail.value.dispatchInfo.vehicleAppointment.status, time(detail.value.dispatchInfo.vehicleAppointment.appointmentAt)].join(' · ') : '—'],
])
const documentFields = computed(() => [
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
@@ -44,6 +43,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
const orderColumns: DataTableColumn[] = [
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
@@ -0,0 +1,239 @@
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 { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
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 InboundOrdersView from './InboundOrdersView.vue'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import { createAppointmentForm, type AppointmentForm } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type FbaAppointmentDto, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
vi.mock('../api/locations', () => ({ listOverseasLocations: vi.fn() }))
enableAutoUnmount(afterEach)
const order: InboundOrderSummaryDto = { lineId: '9223372036854775001', planId: 'internal-plan', planNo: 'IP-1', customerId: 'C-1', orderId: 'internal-order', status: 'PENDING_ASSIGNMENT', overseasWarehouseId: 'WH-1', overseasWarehouseCode: 'LA1', overseasWarehouseName: '洛杉矶一仓', warehouseId: 'WH-1', warehouseCode: 'LA1', warehouseName: '洛杉矶一仓', fbaWarehouseCode: 'ONT8', appointmentNo: 'AP-1', inboundCode: 'IN-1', version: 0 }
const warehouse: ReceivingOverseasWarehouseDto = { id: '9223372036854775002', customerId: '0', warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }
const appointment: FbaAppointmentDto = { id: 'internal-appointment', appointmentNo: 'AP-1', version: 0, originWarehouseId: 'WH-1', fbaWarehouseCode: 'ONT8', appointmentStatus: 'PENDING', selectedCount: 1, timezone: 'America/Los_Angeles', items: [] }
const locations: OverseasLocationDto[] = [
{ id: 'normal', warehouseId: 'WH-1', locationCode: 'N01', locationType: 'NORMAL', sharedOverflow: false, status: 'ACTIVE', version: 1 },
{ id: 'overflow', warehouseId: 'WH-1', locationCode: 'A0', locationType: 'SHARED_OVERFLOW', sharedOverflow: true, status: 'ACTIVE', version: 0 },
{ id: 'virtual', warehouseId: 'WH-1', locationCode: 'VIRTUAL', locationType: 'VIRTUAL', sharedOverflow: false, status: 'ACTIVE', version: 1 },
{ id: 'disabled', warehouseId: 'WH-1', locationCode: 'DISABLED', locationType: 'NORMAL', sharedOverflow: false, status: 'DISABLED', version: 1 },
]
function plan(): InboundPlanDetailDto {
return { id: 'internal-plan', planNo: 'IP-1', customerId: 'C-1', sourceOrderId: 'internal-order', overseasWarehouseId: 'WH-1', overseasWarehouseCode: 'LA1', overseasWarehouseName: '洛杉矶一仓', lineCount: 1, palletCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 8, totalVolumeCbm: 1,
lines: [{ id: 'line', orderId: 'internal-order', inboundCode: 'IN-1', requiredPackageCount: 2, plannedPalletCount: 1 }],
pallets: [{ id: 'pallet', palletNo: 'PALLET-1', sequenceNo: 1, warehouseLocationCode: 'OLD', actualPackageCount: 2, version: 0 }],
allocations: [{ lineId: 'line', palletId: 'pallet', palletNo: 'PALLET-1', palletOwnerPlanNo: 'IP-1', palletOwnedByCurrentPlan: true, plannedPackageCount: 2, countedPackageCount: 2, pickedPackageCount: 2, version: 0 }],
}
}
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order }], total: 1, page: 1, size: 10, pages: 1 })
vi.mocked(getFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(createFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(updateFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(updateInboundPlan).mockResolvedValue({ ...order })
vi.mocked(listReceivingOverseasWarehouses).mockResolvedValue({ records: [warehouse], total: 1, page: 1, size: 200, pages: 1 })
vi.mocked(getInboundPlan).mockResolvedValue(plan())
vi.mocked(listOverseasLocations).mockResolvedValue({ records: locations, total: 4, page: 1, size: 200, pages: 1 })
vi.mocked(moveInboundPallet).mockResolvedValue({ commandId: 'command', status: 'COMPLETED', canMove: true })
})
async function listPage() { const wrapper = mount(InboundOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
async function openEditPlan() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '编辑')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑入库单') } }
async function openEditAppointment() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑预约') } }
async function openMove() { const wrapper = mount(InboundOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '托盘移库') } }
describe('inbound appointment native dialogs', () => {
it('keeps every confirmed appointment field through fullscreen and submits the exact selected origin and lines', async () => {
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order, appointmentNo: null }], total: 1, page: 1, size: 10, pages: 1 })
const wrapper = await listPage()
const table = wrapper.findComponent(DataTable)
table.vm.$emit('selection-change', table.props('data')); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '预约 ·')
const fields = dialog.getComponent(FbaAppointmentFields)
const model = fields.props('model')
model.appointmentStatus = 'SUCCESS'; await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(createFbaAppointment).not.toHaveBeenCalled()
Object.assign(model, { plannedDate: '2026-11-01', plannedAt: '2026-11-01T09:00:00', plannedEndAt: '2026-11-01T11:00:00', confirmationNo: ' EXT-1 ', appointmentAt: '2026-11-01T01:00:00', appointmentEndAt: '2026-11-01T01:30:00', appointmentRemark: ' 约仓确认 ', startOffset: '-07:00', endOffset: '-08:00' })
await flushPromises(); await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(fields.props('continuous')).toBe(true)
expect(dialog.get('[aria-label="预约来源信息"]').text()).toContain('保存后生成')
expect(fields.findAll('details')).toHaveLength(1)
dialog.vm.$emit('confirm'); await flushPromises()
expect(createFbaAppointment).toHaveBeenCalledExactlyOnceWith({ originWarehouseId: 'WH-1', fbaWarehouseCode: 'ONT8', lineIds: [order.lineId], idempotencyKey: expect.any(String), details: {
plannedDate: '2026-11-01', plannedAt: '2026-11-01T09:00:00', plannedEndAt: '2026-11-01T11:00:00', confirmationNo: 'EXT-1', appointmentAt: '2026-11-01T01:00:00', appointmentEndAt: '2026-11-01T01:30:00', appointmentStatus: 'SUCCESS', appointmentRemark: '约仓确认', startOffset: '-07:00', endOffset: '-08:00',
} })
})
it('locks an uncertain edit to the original zero-version request and keeps close and route guards intact', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateFbaAppointment).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce({ ...appointment })
const { dialog } = await openEditAppointment()
const fields = dialog.getComponent(FbaAppointmentFields)
fields.props('model').appointmentRemark = '交接备注'; await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledTimes(1)
const original = vi.mocked(updateFbaAppointment).mock.calls[0]!
expect(original[1]).toMatchObject({ version: 0, idempotencyKey: expect.any(String) })
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
reject(new ApiResponseError('结果暂未确认', 'A0503', 503, null, 'appointment-popup-request')); await flushPromises()
expect(fields.props('disabled')).toBe(true)
expect(dialog.text()).toContain('appointment-popup-request')
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(updateFbaAppointment).mock.calls[1]).toEqual(original)
expect(dialog.props('modelValue')).toBe(false)
})
it.each(['permission', 'replacement', 'cancelled'] as const)('keeps %s appointments read-only in the real continuous form', async mode => {
if (mode === 'permission') can.mockImplementation(code => code !== permission.inboundOrderAppointment)
vi.mocked(getFbaAppointment).mockResolvedValue({ ...appointment, appointmentStatus: mode === 'cancelled' ? 'CANCELLED' : 'PENDING', replacementAppointmentNos: mode === 'replacement' ? ['AP-NEW'] : [] })
const wrapper = await listPage()
await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === (mode === 'permission' ? '查看预约' : '预约'))!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '查看预约')
expect(dialog.getComponent(FbaAppointmentFields).props('disabled')).toBe(true)
expect(dialog.get('input[placeholder="有需交接的事项时填写"]').attributes('disabled')).toBeDefined()
expect(dialog.props('showConfirm')).toBe(false)
if (mode === 'replacement') expect(dialog.text()).toContain('替代预约:AP-NEW')
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled()
})
it('keeps the failed-appointment reason required and clears offsets after a confirmed-time change', async () => {
const { dialog } = await openEditAppointment()
const fields = dialog.getComponent(FbaAppointmentFields), model = fields.props('model')
model.appointmentStatus = 'FAILED'; model.appointmentRemark = ' '; await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateFbaAppointment).not.toHaveBeenCalled()
model.appointmentStatus = 'SUCCESS'; model.startOffset = '-07:00'; model.endOffset = '-08:00'; await flushPromises()
const dates = fields.findAllComponents({ name: 'ElDatePicker' })
dates.find(date => date.props('placeholder') === '目的仓当地时间')!.vm.$emit('change', '2026-11-01T10:00:00')
dates.find(date => date.props('placeholder') === '未明确可留空')!.vm.$emit('change', '2026-11-01T11:00:00'); await flushPromises()
expect(model.startOffset).toBe(''); expect(model.endOffset).toBe('')
})
it('keeps the shared default layout and all model fields available to existing consumers', () => {
const model: AppointmentForm = createAppointmentForm({ ...appointment, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-1', appointmentAt: '2026-10-21T10:00:00' })
const wrapper = mount(FbaAppointmentFields, { props: { model, appointmentNo: 'AP-1' }, global: { plugins: [ElementPlus] } })
expect(wrapper.find('.appointment-fields').exists()).toBe(true)
expect(wrapper.find('.appointment-editor-table').exists()).toBe(false)
expect(wrapper.findAllComponents({ name: 'ElDatePicker' })).toHaveLength(5)
expect(wrapper.findAll('input').some(input => input.element.value === 'EXT-1')).toBe(true)
})
})
describe('inbound plan and pallet move native dialogs', () => {
it('shows plan identity and version as text and saves the selected master warehouse through fullscreen', async () => {
const { dialog } = await openEditPlan()
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('版本0')
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', { customerId: 'C-1', overseasWarehouseId: warehouse.id, warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', version: 0 })
})
it('locks a pending plan edit and keeps the warehouse and diagnostic context after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateInboundPlan).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openEditPlan()
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(select.find('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('版本已变化', 'A0409', 409, null, 'plan-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(select.props('modelValue')).toBe(warehouse.id)
expect(dialog.text()).toContain('plan-popup-request')
})
it('does not save while the plan warehouse candidates are loading', async () => {
let resolve!: (value: Awaited<ReturnType<typeof listReceivingOverseasWarehouses>>) => void
vi.mocked(listReceivingOverseasWarehouses).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await openEditPlan()
expect(dialog.props('loading')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateInboundPlan).not.toHaveBeenCalled()
resolve({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 }); await flushPromises()
expect(dialog.props('loading')).toBe(false)
})
it('loads only physical usable locations and preserves a valid zero pallet version in the command', async () => {
const { dialog } = await openMove()
expect(getInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', 'C-1')
expect(listOverseasLocations).toHaveBeenCalledExactlyOnceWith('WH-1', { page: 1, size: 200 })
expect(dialog.get('[aria-label="移库来源"]').findAll('input')).toHaveLength(0)
expect(dialog.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['N01', 'A0'])
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'A0'); await dialog.get('input[placeholder="请输入移库原因"]').setValue(' 上架到溢出位 ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(moveInboundPallet).toHaveBeenCalledExactlyOnceWith({ overseasWarehouseId: 'WH-1', sourcePalletCode: 'PALLET-1', targetLocationCode: 'A0', expectedPalletVersion: 0, reason: '上架到溢出位', idempotencyKey: expect.stringMatching(/^admin-inbound-move-PALLET-1-\d+$/) })
expect(dialog.props('modelValue')).toBe(false)
})
it('locks pending move inputs and retains the chosen location, reason and API error after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(moveInboundPallet).mockReturnValue(new Promise((_, no) => { reject = no }))
const { dialog } = await openMove()
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'N01'); await dialog.get('input[placeholder="请输入移库原因"]').setValue('现场上架')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(moveInboundPallet).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="请输入移库原因"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('移库未完成', 'A0409', 409, null, 'move-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('N01')
expect((dialog.get('input[placeholder="请输入移库原因"]').element as HTMLInputElement).value).toBe('现场上架')
expect(dialog.text()).toContain('move-popup-request')
})
it('keeps failed location queries explicit and retries without losing the move reason', async () => {
vi.mocked(listOverseasLocations).mockRejectedValueOnce(new ApiResponseError('库位读取失败', 'A0503', 503, null, 'location-popup-request')).mockResolvedValueOnce({ records: locations, page: 1, size: 200, total: 4, pages: 1 })
const { dialog } = await openMove()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.text()).toContain('location-popup-request')
await dialog.get('input[placeholder="请输入移库原因"]').setValue('保留原因')
dialog.vm.$emit('confirm'); await flushPromises(); expect(moveInboundPallet).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(listOverseasLocations).toHaveBeenCalledTimes(2)
expect((dialog.get('input[placeholder="请输入移库原因"]').element as HTMLInputElement).value).toBe('保留原因')
expect(dialog.findAllComponents({ name: 'ElOption' })).toHaveLength(2)
})
it('keeps server move blockers visible and does not close the form or refresh as success', async () => {
vi.mocked(moveInboundPallet).mockResolvedValue({ commandId: 'blocked', status: 'REJECTED', canMove: false, blockReason: '托盘正在作业,禁止移库' })
const { dialog } = await openMove()
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'N01'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('托盘正在作业,禁止移库')
expect(getInboundPlan).toHaveBeenCalledTimes(1)
})
it('shows empty physical locations and rejects a move without a target or reason', async () => {
vi.mocked(listOverseasLocations).mockResolvedValue({ records: locations.filter(location => location.locationType === 'VIRTUAL' || location.status === 'DISABLED'), page: 1, size: 200, total: 2, pages: 1 })
const { dialog } = await openMove()
expect(dialog.text()).toContain('暂无可用普通库位或 A0')
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(moveInboundPallet).not.toHaveBeenCalled()
})
it('keeps missing pallet versions unavailable instead of treating them as valid zero versions', async () => {
const value = plan(); delete value.pallets[0]!.version
vi.mocked(getInboundPlan).mockResolvedValue(value)
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
const button = wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!
expect(button.attributes('disabled')).toBeDefined()
expect(listOverseasLocations).not.toHaveBeenCalled()
})
})
@@ -19,6 +19,7 @@ const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref<OverseasLocationDto[]>([])
const moveLocationsLoading = ref(false); const moveLocationError = ref<ApiErrorDetails | null>(null); const moveError = ref<ApiErrorDetails | null>(null)
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
@@ -63,20 +64,29 @@ const palletRows = computed(() => (detail.value?.allocations || []).map((allocat
palletNo: text(allocation.palletNo),
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? 0, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? null, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架',
}
}))
const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED'))
async function openMove(row: typeof palletRows.value[number]) {
if (moveSaving.value || moveLocationsLoading.value || row.palletVersion == null || row.palletNo === '—') return
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveOpen.value = true
try { const result = await listOverseasLocations(String((detail.value as InboundPlanDetailDto & { overseasWarehouseId?: string }).overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') } catch { locationOptions.value = []; ElMessage.error('可用库位加载失败,请重试') }
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveError.value = null; moveOpen.value = true
await loadMoveLocations()
}
async function loadMoveLocations() {
if (moveSaving.value || moveLocationsLoading.value) return
moveLocationsLoading.value = true; moveLocationError.value = null; locationOptions.value = []; targetLocationCode.value = ''
try { const result = await listOverseasLocations(String(detail.value?.overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') }
catch (cause) { moveLocationError.value = getApiErrorDetails(cause, '可用库位加载失败,请重试') }
finally { moveLocationsLoading.value = false }
}
async function submitMove() {
if (!moveOpen.value || moveSaving.value || moveLocationsLoading.value || moveLocationError.value) return
if (!moveRow.value || !targetLocationCode.value || !moveReason.value.trim()) { ElMessage.warning('请选择目标库位并填写操作原因'); return }
moveSaving.value = true
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { ElMessage.error('入库单未返回海外仓 ID,暂不能移库'); return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { ElMessage.error(result.blockReason || '当前托盘不可移库'); return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { ElMessage.error(getApiErrorDetails(error, '移库失败').message) } finally { moveSaving.value = false }
moveSaving.value = true; moveError.value = null
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { moveError.value = { message: '入库单未返回海外仓范围,暂不能移库' }; return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { moveError.value = { message: result.blockReason || '当前托盘不可移库' }; return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { moveError.value = getApiErrorDetails(error, '移库失败') } finally { moveSaving.value = false }
}
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push(appointmentReturnTarget(route.query) || { name: 'overseas-inbound-orders' }) }
@@ -86,10 +96,18 @@ onMounted(load)
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!(row as typeof palletRows[number]).palletNo || !(row as typeof palletRows[number]).palletVersion" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" title="托盘移库" width="min(560px, calc(100vw - 28px))" confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="!targetLocationCode" @confirm="submitMove"><el-form class="density-form" label-position="top"><el-form-item label="托盘号"><el-input :model-value="moveRow?.palletNo" readonly /></el-form-item><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ''}`" /></el-select></el-form-item><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" placeholder="请输入移库原因" /></el-form-item></el-form></AppDialog></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><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>{{ referenceValue }}</td></tr><tr><th scope="row">托盘号</th><td>{{ moveRow?.palletNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="moveLocationError" compact title="可用库位加载失败" :message="moveLocationError.message" :loading="moveLocationsLoading" @retry="loadMoveLocations"><template #details><ApiErrorMeta :details="moveLocationError" /></template></LoadErrorState>
<LoadErrorState v-if="moveError" compact title="移库未完成" :message="moveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="moveError" /></template></LoadErrorState>
<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="100px" :disabled="moveSaving || moveLocationsLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable :loading="moveLocationsLoading" placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ''}`" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" placeholder="请输入移库原因" /></el-form-item></td></tr>
</tbody></table></el-form><el-alert v-if="!moveLocationsLoading && !moveLocationError && !availableLocations.length" title="暂无可用普通库位或 A0" type="info" :closable="false" show-icon /></section>
</div></AppDialog></template>
<style scoped>
.detail-section { min-width: 0; padding: 0; border: 0; background: #fff; }
@@ -16,7 +16,7 @@ const render=()=>mount(InboundOrdersView,{global:{directives:{loading:{}},stubs:
AdminPage:{template:'<main><slot/></main>'},ListPageLayout:{template:'<div><slot/></div>'},
DataTable:{props:['data'],template:'<div><slot v-for="row in data" name="actions" :row="row"/></div>'},
AppDialog:{name:'AppDialog',props:['modelValue','beforeClose','showConfirm'],template:'<section v-if="modelValue"><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></section>'},
ElButton:{template:'<button><slot/></button>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
ElButton:{template:'<button><slot/></button>'}, ElAlert:{props:['title'],template:'<p role="status">{{ title }}</p>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
}}})
beforeEach(()=>{vi.clearAllMocks();access.canWrite=true;vi.mocked(getFbaAppointment).mockResolvedValue({id:'1',appointmentNo:'AP-1',version:2,originWarehouseId:'100',fbaWarehouseCode:'ONT8',appointmentStatus:'PENDING',selectedCount:1,items:[]})})
it('opens linked appointment details for list-only users and blocks saving',async()=>{
@@ -62,7 +62,7 @@ const pendingAppointment = ref<{appointmentNo: string | null; version: number; o
const appointmentViewOnly = ref(false)
const appointmentReplacementNos = ref<string[]>([])
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
const appointmentTitle = computed(() => appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约')
const appointmentTitle = computed(() => `${appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约'} · ${editingAppointmentNo.value || `${appointmentSelectedCount.value} 条入库单`}`)
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
function selectableRow(source: object) {
@@ -222,7 +222,7 @@ function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.destinationWarehouseName === '—' ? '' : row.destinationWarehouseName, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
async function submitPlanEdit() { const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
async function submitPlanEdit() { if (planEditSaving.value || planEditLoading.value || !access.can(permission.inboundOrderEdit)) return; const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
onMounted(() => { void load() })
</script>
@@ -241,23 +241,24 @@ onMounted(() => { void load() })
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<p v-if="appointmentReplacementNos.length">原批次已被替代,当前只读。替代预约:{{ appointmentReplacementNos.join('、') }}</p><FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" fullscreenable :loading="appointmentLoading" :confirm-disabled="appointmentLoading" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<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>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="mutationError" compact title="预约保存失败" :message="mutationError.message" :loading="saving" :show-retry="!appointmentReadOnly" :retry-text="pendingAppointment ? '重试本次保存' : '重新提交'" @retry="submitAppointment"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<FbaAppointmentFields continuous :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog class="admin-density-dialog" v-model="planEditOpen" title="编辑入库单" confirm-text="保存" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div v-loading="planEditLoading"><el-form class="density-form" label-position="top"><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item><el-form-item label="版本"><el-input-number v-model="planEditForm.version" :min="0" disabled /></el-form-item></el-form></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><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>{{ planEditTarget?.orderNo || '—' }}</td><th scope="row">版本</th><td>{{ planEditForm.version }}</td></tr></tbody></table></section>
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<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="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
</div></AppDialog>
</AdminPage>
</template>
<style scoped>
.appointment-summary { margin-bottom: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #cdddfd; border-radius: var(--admin-radius); background: #f5f9ff; color: #52627a; font-size: var(--admin-font-body); }
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
.row-actions { min-width: max-content; flex-wrap: nowrap; white-space: nowrap; }
.row-actions :deep(.el-button) { flex: 0 0 auto; }
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
</style>
@@ -50,9 +50,8 @@ async function exportLedger() { exportLoading.value = true; try { const range =
<template #toolbar-start><h1>进销存记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template></template>
</template>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style>
@@ -1,17 +1,47 @@
import ElementPlus from 'element-plus'
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 { getLastMileOrder } from '../api/lastMileOrders'
import { createLastMileOrderFee, getLastMileOrder } from '../api/lastMileOrders'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { getWarehouseTransfer } from '../api/warehouseTransfers'
const can = vi.hoisted(() => vi.fn(() => false))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn() }))
const feeSupplier: Supplier = { id: '9223372036854775001', tenantId: '0', name: '测试供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'PER_TRANSACTION', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
async function openFeeEditor() {
can.mockReturnValue(true)
const wrapper = mount(LastMileOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click'); await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
return { wrapper, dialog }
}
async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE') {
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'STORAGE_FEE'); selects[0]!.vm.$emit('change', 'STORAGE_FEE')
selects[1]!.vm.$emit('update:modelValue', direction); await flushPromises()
if (direction === 'PAYABLE') dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!.vm.$emit('update:modelValue', feeSupplier.id)
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择币种')!.vm.$emit('update:modelValue', direction === 'PAYABLE' ? 'CNY' : 'USD')
const numbers = dialog.findAllComponents({ name: 'ElInputNumber' })
numbers[0]!.vm.$emit('update:modelValue', 80); numbers[1]!.vm.$emit('update:modelValue', 2)
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
dates[0]!.vm.$emit('update:modelValue', '2026-10-20'); dates[1]!.vm.$emit('update:modelValue', '2026-10-01'); dates[2]!.vm.$emit('update:modelValue', '2026-10-20')
await flushPromises()
await dialog.get('input[placeholder="如 箱、托、天、m³"]').setValue('托')
}
enableAutoUnmount(afterEach)
describe('LastMileOrderDetailView domestic detail alignment', () => {
@@ -90,6 +120,9 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
})
beforeEach(() => {
can.mockReset().mockReturnValue(false)
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [feeSupplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
push.mockReset()
vi.mocked(getWarehouseTransfer).mockReset()
vi.mocked(getPlatformWarehouse).mockReset()
@@ -205,4 +238,37 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
expect(rows[1].text()).toContain('处理中')
expect(rows[1].text()).toContain('5')
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('keeps %s fee fields and exact payload through fullscreen editing', async direction => {
const { dialog } = await openFeeEditor()
expect(dialog.props('title')).toContain('FL-WH-001')
expect(dialog.props('width')).toContain('720px')
await fillFee(dialog, direction)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
const payable = direction === 'PAYABLE'
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', {
feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: '2026-10-20',
supplierId: payable ? feeSupplier.id : undefined, supplierName: payable ? feeSupplier.name : undefined, supplierCategory: payable ? feeSupplier.category : undefined,
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40, periodStart: '2026-10-01', periodEnd: '2026-10-20',
})
})
it('keeps fee required-field validation before a request', async () => {
const { dialog } = await openFeeEditor()
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('locks fee controls and preserves failed values and diagnostic context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createLastMileOrderFee).mockReturnValue(new Promise((_, no) => { reject = no }))
const { wrapper, dialog } = await openFeeEditor()
await fillFee(dialog, 'RECEIVABLE')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="如 箱、托、天、m³"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('费用保存失败', 'TEST_REJECTED', 409, null, 'fee-test-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findAllComponents({ name: 'ElInputNumber' })[0]!.props('modelValue')).toBe(80)
expect(wrapper.findComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'fee-test-request' })
})
})
@@ -243,6 +243,7 @@ async function openFeeDialog() {
}
}
async function saveFee() {
if (saving.value) return
if (!feeForm.itemCode || !feeForm.itemName.trim() || feeForm.amount <= 0 || !feeForm.businessDate) { ElMessage.warning('请填写费用项目、业务日期和大于 0 的金额'); return }
const supplier = supplierOptions.value.find((item) => String(item.id) === feeForm.supplierId)
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
@@ -351,17 +352,17 @@ onMounted(load)
<el-tab-pane name="logs"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>操作记录</span></template><section class="table-panel"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"></DataTable></section></el-tab-pane>
</el-tabs>
</section>
<AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<el-form class="fee-form density-form" label-position="top"><div class="fee-form-grid">
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
<el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item>
<el-form-item v-if="feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item>
<el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
<el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item>
</div></el-form>
<AppDialog class="admin-density-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<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="110px" :disabled="saving"><table class="density-table density-table--editable fee-editor-table"><tbody>
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item></td><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td><el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td><td><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td></tr>
</tbody></table></el-form>
</section>
</AppDialog>
</OverseasDetailShell>
</template>
@@ -375,12 +376,10 @@ onMounted(load)
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
.plan-panel { margin-bottom: var(--admin-space-5); }
.related-panel { margin-top: var(--admin-space-5); }
.fee-form { min-width: 0; overflow-x: hidden; }
.fee-form-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); }
.fee-form-full { grid-column: 1 / -1; }
.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }
.fee-editor-table { table-layout: fixed; }
.fee-editor-table td:not([colspan]) { width: 50%; }
@media (max-width: 1100px) { .plan-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields, .fee-form-grid { grid-template-columns: 1fr; } .fee-form-full, .detail-fields > div.wide { grid-column: auto; } }
@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields { grid-template-columns: 1fr; } .detail-fields > div.wide { grid-column: auto; } }
.detail-surface { min-width: 0; min-height: 0; margin-top: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
.info-panel, .table-panel { min-width: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
@@ -169,7 +169,7 @@ function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
return wrapper.findAll('button').find((item) => item.text().includes(text))
}
function cabinetDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '按柜生成入库计划预览')!
return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('按柜生成入库计划预览'))!
}
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
@@ -380,7 +380,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
}], page: 1, size: 10, total: 1, pages: 1 })
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('CD-TEST-1'), '废弃申请审核', expect.objectContaining({ distinguishCancelAndClose: true }))
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('CD-TEST-1'), '废弃申请审核 · CD-TEST-1', expect.objectContaining({ distinguishCancelAndClose: true }))
expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'APPROVE', undefined)
expect(button(wrapper, '审核')).toBeUndefined()
})
@@ -550,19 +550,12 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(document.body.textContent).toContain('计划入库仓')
expect(document.body.textContent).toContain('预计到仓时间')
expect(document.body.textContent).toContain('托盘类型')
const inboundCodeInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '订单冻结入仓码')
expect(inboundCodeInput?.props('disabled')).toBe(true)
const receivingWarehouseInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '当前柜接柜仓')
expect(receivingWarehouseInput?.props('disabled')).toBe(true)
expect(receivingWarehouseInput?.props('modelValue')).toBe('LAX01 · 洛杉矶接柜仓')
const expectedArrivalInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '当前柜预计到仓时间')
expect(expectedArrivalInput?.props('disabled')).toBe(true)
expect(expectedArrivalInput?.props('modelValue')).toBe('2026-09-01 08:00')
expect(document.body.textContent).toContain('同柜所有入库计划保持一致')
expect(document.body.textContent).toContain('接柜仓按柜统一')
const sourceFacts = wrapper.get('[aria-label="入库来源"]')
expect(sourceFacts.find('input').exists()).toBe(false)
expect(sourceFacts.text()).toContain('IN-LOCKED')
expect(sourceFacts.text()).toContain('LAX01 · 洛杉矶接柜仓')
expect(sourceFacts.text()).toContain('2026-09-01 08:00')
expect(wrapper.find('button[aria-label="查看入库来源说明"]').exists()).toBe(true)
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
@@ -637,9 +630,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const palletInput = wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' })
expect(palletInput.props('disabled')).toBe(true)
expect(palletInput.props('modelValue')).toBe(1)
expect(wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
expect(wrapper.get('.frozen-pallet-count').text()).toBe('1')
expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库')
;([...document.body.querySelectorAll('button')]
@@ -656,6 +648,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
expect(document.body.textContent).toContain('选择待分配柜')
await cabinetDialog(wrapper).get('button[aria-label="查看按柜生成说明"]').trigger('focus')
await new Promise(resolve => setTimeout(resolve, 200)); await flushPromises()
expect(document.body.textContent).toContain('确认后整柜只提交一次生成命令')
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
@@ -1189,6 +1183,112 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await flushPromises()
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
})
it('keeps manual-plan drafts through native fullscreen with plain frozen source facts', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('width')).toContain('720px')
expect(dialog.props('title')).toContain('LMO-260827-0001')
expect(dialog.get('[data-tone="source"]').find('input').exists()).toBe(false)
await dialog.get('textarea').setValue('全屏后保留')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('全屏后保留')
})
it('locks manual-plan form edits and preserves a failed draft and request context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createManualInboundPlan).mockReturnValue(new Promise((_, no) => { reject = no }))
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
await dialog.get('textarea').setValue('保留入库备注')
await dialog.findAll('button').find(item => item.text() === '保存并生成入库单')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('入库失败', 'TEST_REJECTED', 409, null, 'plan-test-request'))
await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留入库备注')
expect(dialog.props('modelValue')).toBe(true)
expect(document.body.textContent).toContain('plan-test-request')
})
it.each([false, true])('keeps hold/release flags, reason and zero version for detained=%s', async detained => {
const wrapper = await mountView()
const row = selectedRow({ detained, currentStatusCode: 'INBOUND_COMPLETED', version: 0 })
const vm = wrapper.vm as unknown as { openHold(row: object): void }
vm.openHold(row); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith(detained ? '解除暂扣 ·' : '暂扣订单 ·'))!
await dialog.get('textarea').setValue(' 原因 ')
dialog.findAllComponents({ name: 'ElRadioGroup' })[1]!.vm.$emit('update:modelValue', false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-'))
})
it('renders all eight node times and submits the complete set with zero version', async () => {
const wrapper = await mountView()
const row = selectedRow()
await (wrapper.vm as unknown as { openManualNodes(row: object): Promise<void> }).openManualNodes(row)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('修改节点 ·'))!
expect(dialog.findAll('.manual-nodes-editor tbody tr')).toHaveLength(8)
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
expect(dates).toHaveLength(8)
dates[0]!.vm.$emit('update:modelValue', '2026-10-20T08:00:00')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderManualNodes).toHaveBeenCalledWith(row.id, 0, [
{ nodeCode: 'DOMESTIC_CUSTOMS_COMPLETED', nodeAt: '2026-10-20T08:00:00' },
{ nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: null },
{ nodeCode: 'ESTIMATED_ARRIVAL', nodeAt: null }, { nodeCode: 'OVERSEAS_CUSTOMS_COMPLETED', nodeAt: null },
{ nodeCode: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', nodeAt: null }, { nodeCode: 'PICKUP_APPOINTED', nodeAt: null }, { nodeCode: 'UNLOADED', nodeAt: null },
], expect.stringContaining('last-mile-manual-nodes-'))
})
it('keeps every destination group in the main preview body through fullscreen', async () => {
const original = await getContainerInboundPlanPreview(bookingId)
const group = original.groups[0]!
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...original, totalOrderCount: 13, readyOrderCount: 13, pendingOrderCount: 13, pendingPalletCount: 13, sourcePalletCount: 13, supplementPalletCount: 0,
groups: Array.from({ length: 13 }, (_, index) => ({ ...group, destinationWarehouseCode: 'FBA-' + index, orderCount: 1, pendingOrderCount: 1, pendingPalletCount: 1, sourcePalletCount: 1, supplementPalletCount: 0,
orders: [{ ...group.orders[0]!, orderId: 'order-' + index, businessOrderNo: '业务单-' + index, sourcePalletCount: 1, targetPalletCount: 1, supplementPalletCount: 0 }] })),
})
const wrapper = await openPending()
await openContainerDialog(wrapper); await chooseContainer(wrapper)
const dialog = cabinetDialog(wrapper)
expect(dialog.findAll('[aria-label="目的地分组明细"]')).toHaveLength(13)
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
expect(dialog.text()).toContain('业务单-12')
expect(dialog.find('.container-preview__scroll').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
})
it('keeps transfer drafts locked during submission and editable after rejection', async () => {
let reject!: (reason: unknown) => void
vi.mocked(transferLastMileOrders).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('转仓 ·'))!
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', fbaWarehouse.id)
await dialog.get('textarea').setValue('保留转仓备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '确认转仓')!.trigger('click'); await flushPromises()
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转仓失败', 'TEST_REJECTED', 409, null, 'transfer-test-request')); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留转仓备注')
expect(dialog.props('modelValue')).toBe(true)
})
it('keeps rework selection and reason through fullscreen without issuing a command', async () => {
const wrapper = await mountView()
;(wrapper.vm as unknown as { reworkRelabel(row: object): void }).reworkRelabel(selectedRow({ relabelOrderNo: 'RL-TEST-1', relabelStatusCode: 'CUSTOMER_CONFIRM_PENDING' }))
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('换标退回重做 ·'))!
expect(dialog).toBeDefined()
await dialog.get('textarea').setValue('保留重做原因')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留重做原因')
expect(dialog.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('RL-TEST-1')
})
})
describe('tail-leg pallet merge toolbar', () => {
@@ -9,6 +9,7 @@ import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -77,7 +78,7 @@ async function submitPalletMerge(required: boolean) {
try {
await ElMessageBox.confirm(required
? `将勾选的 ${selection.length} 票订单标记为同一合托组?所选订单须属于同一客户、同一目的仓,PDA 将提示现场合托。`
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, label,
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, `${label} · ${selection.length} 票订单`,
{ customClass: 'admin-density-confirm', confirmButtonText: `确认${label}`, cancelButtonText: '返回', type: 'warning' })
} catch { return }
actionLoading.value = true
@@ -910,12 +911,12 @@ async function reviewDiscard(row: LastMileOrderListItem) {
let decision: 'APPROVE' | 'REJECT' = 'APPROVE'
let remark: string | undefined
try {
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, '废弃申请审核', {
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, `废弃申请审核 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true,
})
} catch (action) {
if (action !== 'cancel') throw action
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃申请', {
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回废弃申请 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', inputType: 'textarea',
inputValidator: (input) => Boolean(input?.trim()) && input.trim().length <= 1000 || '请填写 1 至 1000 字驳回原因',
confirmButtonText: '确认驳回', cancelButtonText: '取消',
@@ -938,14 +939,14 @@ async function reviewReturn(source: object) {
const returnNo = row.sourceOrderNo
if (!returnNo || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', `退货预报审核 · ${returnNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewReturnOrder(returnNo, row.customerId, 'APPROVE')
ElMessage.success('退货预报已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回退货预报', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回退货预报 · ${returnNo}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value)
ElMessage.success('退货预报已驳回')
await load()
@@ -957,14 +958,14 @@ async function reviewDirectRelabel(source: object) {
const row = source as LastMileOrderListItem
if (!row.id || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', '贴标单审核', { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', `贴标单审核 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewDirectRelabelOrder(row.id, row.customerId, 'APPROVE')
ElMessage.success('贴标单已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', '驳回贴标单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', `驳回贴标单 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
ElMessage.success('贴标单已驳回')
await load()
@@ -1023,7 +1024,7 @@ async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', `换标完成确认 · ${relabelNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成')
await load()
@@ -1146,68 +1147,42 @@ onMounted(() => { void load() })
</DataTable>
</ListPageLayout></AdminPage>
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
<AppDialog class="admin-density-dialog" v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
</LoadErrorState>
<el-form class="density-form" v-else label-position="top">
<el-form-item label="目的 FBA 仓" required>
<el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse">
<el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
</el-select>
</el-form-item>
<AppDialog class="admin-density-dialog" v-model="transferOpen" :title="`转仓 · ${selectedRows.length} 票订单`" fullscreenable confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions"><template #details><ApiErrorMeta :details="transferWarehouseError" /></template></LoadErrorState>
<section v-else 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="110px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="目的 FBA 仓" required><el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse"><el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item></td></tr>
</tbody></table></el-form>
<el-alert v-if="!transferWarehouseLoading && !transferWarehouseOptions.length" title="暂无可用 FBA 仓候选" type="info" :closable="false" show-icon />
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
</el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form class="density-form" v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
</el-select>
</el-form-item>
</el-form>
<el-alert v-if="containerPreviewError" class="container-convert-alert" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview">
<div class="container-preview__context">
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
<div class="container-preview__warehouse text-ellipsis" :title="[containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓'">{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</div>
</div>
<div v-if="!containerIsResourceRecovery" class="container-preview__metrics">
<div class="container-preview__metric"><span>头程已有托盘码</span><strong>{{ containerPreview.sourcePalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>海外仓补充托盘码</span><strong>{{ containerPreview.supplementPalletCount }}</strong></div>
<div class="container-preview__metric"><span>最终生成托盘</span><strong>{{ containerPreview.pendingPalletCount }}</strong></div>
<div class="container-preview__metric"><span>本次处理订单</span><strong>{{ containerPreview.pendingOrderCount }}</strong></div>
</div>
<div v-else class="container-preview__metrics">
<div class="container-preview__metric"><span>已保存计划托盘</span><strong>{{ containerPreview.totalPlannedPalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>资源确认</span><strong>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</strong></div>
</div>
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" class="container-convert-alert" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
<section v-if="containerConversionResult" class="container-conversion-result">
<header><strong>{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</strong><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
<div class="container-conversion-result__plans">
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
<p v-if="plan.pallets.length"><span v-for="pallet in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet" :title="pallet.palletNo || '托盘码暂未返回'">{{ pallet.palletNo || '—' }}</span></p>
<p v-else class="container-conversion-result__empty">暂无托盘码</p>
</article>
</div>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" :title="`按柜生成入库计划预览${containerPreview?.containerNo ? ' · ' + containerPreview.containerNo : ''}`" width="min(1240px, calc(100vw - 80px))" fullscreenable :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work" aria-label="选择待分配柜"><h3 class="density-dialog-section__title">选择待分配柜 <InlineHelp title="按柜生成说明">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</InlineHelp></h3>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()"><template #details><ApiErrorMeta :details="containerLoadError" /></template></LoadErrorState>
<el-form v-else class="density-form" label-position="left" label-width="110px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="待分配柜" required><el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview"><el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" /></el-select></el-form-item></td></tr></tbody></table></el-form>
</section>
<div class="container-preview__scroll">
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
<header class="container-preview__group-header">
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</strong>
<span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span>
<span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span>
</header>
<el-alert v-if="containerPreviewError" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="全柜预览汇总"><h3 class="density-dialog-section__title">全柜预览</h3>
<table class="density-table last-mile-dialog-facts"><tbody>
<tr><th scope="row">柜号</th><td><strong class="container-preview__identity" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</strong></td><th scope="row">海外接柜仓</th><td>{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</td></tr>
<template v-if="!containerIsResourceRecovery"><tr><th scope="row">头程已有托盘码</th><td>{{ containerPreview.sourcePalletCount }}</td><th scope="row">海外仓补充托盘码</th><td>{{ containerPreview.supplementPalletCount }}</td></tr><tr><th scope="row">最终生成托盘</th><td>{{ containerPreview.pendingPalletCount }}</td><th scope="row">本次处理订单</th><td>{{ containerPreview.pendingOrderCount }}</td></tr></template>
<tr v-else><th scope="row">已保存计划托盘</th><td>{{ containerPreview.totalPlannedPalletCount }}</td><th scope="row">资源确认</th><td>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</td></tr>
</tbody></table>
</section>
<el-alert v-if="containerPreview.blockedOrderCount > 0" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
<el-alert v-else-if="!containerConversionResult" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
<section v-if="containerConversionResult" class="density-dialog-section container-conversion-result" data-tone="summary" aria-label="生成结果">
<h3 class="density-dialog-section__title">{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</h3>
<p class="density-dialog-note"><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></p>
<table class="density-table"><thead><tr><th>计划号</th><th>状态</th><th>托盘码</th></tr></thead><tbody class="container-conversion-result__plans"><tr v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan"><td>{{ plan.planNo }}</td><td><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></td><td><template v-if="plan.pallets.length"><span v-for="(pallet, index) in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet">{{ index ? '、' : '' }}{{ pallet.palletNo || '—' }}</span></template><span v-else>暂无托盘码</span></td></tr></tbody></table>
</section>
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="density-dialog-section" data-tone="source" aria-label="目的地分组明细">
<h3 class="density-dialog-section__title">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</h3>
<p class="density-dialog-note"><span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span><span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span></p>
<el-table :data="group.orders" size="small" border>
<el-table-column label="入仓码" min-width="148" show-overflow-tooltip><template #default="{ row }">{{ row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="businessOrderNo" label="订单号" min-width="150" show-overflow-tooltip />
@@ -1265,67 +1240,55 @@ onMounted(() => { void load() })
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-form class="command-form density-form" label-position="top">
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。</small><el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">入库来源 <InlineHelp title="入库来源说明">取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
<table class="density-table last-mile-dialog-facts"><tbody>
<tr><th scope="row">预计到仓时间</th><td>{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
</tbody></table>
</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 last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" placeholder="请输入托数" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
<el-form class="command-form density-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
<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="100px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item></td></tr>
<tr><td><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></td></tr>
<tr><td><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="manualNodesOpen" title="修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<div class="manual-node-order"><span>尾程单号</span><strong>{{ actionTarget?.orderNo || '—' }}</strong></div>
<AppDialog class="admin-density-dialog" v-model="manualNodesOpen" :title="`修改节点 · ${actionTarget?.orderNo || '—'}`" width="min(700px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<div class="manual-node-table"><div class="manual-node-table__head"><span>节点</span><span>节点时间</span></div><div v-for="node in manualNodeDefinitions" :key="node.code" class="manual-node-table__row"><span>{{ node.label }}</span><el-date-picker v-model="manualNodeValues[node.code]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></div></div>
<section class="density-dialog-section" data-tone="work" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in manualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="reworkOpen" title="换标退回重做" width="min(620px, calc(100vw - 28px))" confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<el-form class="density-form" label-position="top">
<el-form-item label="换标单" required><el-select v-model="reworkForm.relabelNo" :disabled="reworkSaving" placeholder="请选择需要退回的换标单"><el-option v-for="application in reworkOptions" :key="application.relabelNo" :label="application.relabelNo" :value="application.relabelNo" /></el-select></el-form-item>
<el-form-item label="退回原因" required><el-input v-model="reworkForm.remark" :disabled="reworkSaving" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="填写退回原因,仓库将重新执行换标并上传照片" /></el-form-item>
</el-form>
<AppDialog class="admin-density-dialog" v-model="reworkOpen" :title="`换标退回重做 · ${reworkForm.relabelNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<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="100px" :disabled="reworkSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="换标单" required><el-select v-model="reworkForm.relabelNo" :disabled="reworkSaving" placeholder="请选择需要退回的换标单"><el-option v-for="application in reworkOptions" :key="application.relabelNo" :label="application.relabelNo" :value="application.relabelNo" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="退回原因" required><el-input v-model="reworkForm.remark" :disabled="reworkSaving" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="填写退回原因,仓库将重新执行换标并上传照片" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</AppDialog>
</template>
<style scoped>
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
.last-mile-dialog-facts th { width: var(--admin-label-width); }
.last-mile-command-table td:not([colspan]) { width: 50%; }
.manual-nodes-editor th { width: 180px; }
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
.last-mile-toolbar { min-width: 0; }
.row-actions { white-space: nowrap; }
.command-form { margin-top: var(--admin-space-5); }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.command-form :deep(.el-select), .command-form :deep(.el-date-editor), .command-form :deep(.el-input-number) { width: 100%; }
.manual-node-order { display: flex; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); }
.manual-node-table { max-height: 420px; margin-top: var(--admin-space-5); overflow: auto; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: minmax(0, 42fr) minmax(0, 58fr); align-items: center; gap: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); }
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: var(--admin-font-body); font-weight: 600; }
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
.container-convert-hint { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); line-height: 1.6; }
.container-convert-alert { margin-top: var(--admin-space-5); }
.container-preview { margin-top: var(--admin-space-5); }
.container-preview__context, .container-preview__group-header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.container-preview__identity { color: var(--cc-text); font-size: var(--admin-font-title); font-weight: 700; }
.container-preview__warehouse, .container-preview__group-header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-preview__metric span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-preview__metric--primary { border-color: var(--cc-primary); background: #f3f6ff; }
.container-preview__metric--primary strong { color: var(--cc-primary); }
.container-preview__metric--danger strong { color: var(--el-color-danger); }
.container-preview__scroll { max-height: 48vh; margin-top: var(--admin-space-5); padding-right: var(--admin-space-2); overflow-y: auto; }
.container-preview__group + .container-preview__group { margin-top: var(--admin-space-5); }
.container-preview__group-header { min-height: 42px; padding: 0 var(--admin-space-5); border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
.container-conversion-result { margin-top: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid #86efac; border-radius: var(--admin-radius); background: #f0fdf4; }
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); color: #166534; }
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
.container-conversion-result__plan { min-width: 0; padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #bbf7d0; border-radius: var(--admin-radius); background: #fff; }
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: var(--admin-space-2); margin: var(--admin-space-3) 0 0; }
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: var(--admin-radius); background: #dcfce7; color: #166534; font-size: var(--admin-font-caption); text-overflow: ellipsis; white-space: nowrap; }
.container-conversion-result__empty { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); font-size: var(--admin-font-body); }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1356,10 +1319,6 @@ onMounted(() => { void load() })
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--admin-font-body); }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.container-preview__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: var(--admin-space-5) 0; border: 1px solid var(--cc-border); }
.container-preview__metric { display: flex; min-height: var(--admin-row-height); padding: var(--admin-space-5) var(--admin-space-5); border: 0; border-radius: 0; background: var(--admin-surface); flex-direction: row; justify-content: flex-start; align-items: baseline; gap: var(--admin-space-3); border-right: 1px solid var(--cc-border); }
.container-preview__metric:last-child { border-right: 0; }
.container-preview__metric strong { margin-top: var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-body); line-height: 20px; margin: 0; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
</style>
@@ -0,0 +1,153 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { OverseasLocationDto } from '../api/locations'
import LocationsView from './LocationsView.vue'
const api = vi.hoisted(() => ({ warehouses: vi.fn(), list: vi.fn(), all: vi.fn(), create: vi.fn(), update: vi.fn(), status: vi.fn(), remove: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: api.warehouses }))
vi.mock('../api/locations', () => ({ listOverseasLocations: api.list, listAllOverseasLocations: api.all, createOverseasLocation: api.create, updateOverseasLocation: api.update, changeOverseasLocationStatus: api.status, deleteOverseasLocation: api.remove }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function location(overrides: Partial<OverseasLocationDto> = {}): OverseasLocationDto { return { id: '9007199254740993', warehouseId: '9007199254740994', locationCode: 'LOC-001', locationType: 'NORMAL', sharedOverflow: false, status: 'INACTIVE', maxLoadKg: 1000, occupiedPalletCount: 0, version: 0, remark: '原备注', ...overrides } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.warehouses.mockResolvedValue({ records: [{ id: '9007199254740994', warehouseName: '洛杉矶仓', warehouseCode: 'LAX' }] })
api.all.mockResolvedValue({ records: [location()] }); api.list.mockResolvedValue({ records: [location()] })
api.create.mockResolvedValue('9007199254740995'); api.update.mockResolvedValue(null); api.status.mockResolvedValue(null); api.remove.mockResolvedValue(null)
})
async function page() { const wrapper = mount(LocationsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function field(dialog: Pick<VueWrapper, 'findAllComponents'>, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
function row(wrapper: VueWrapper, code = 'LOC-001') { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(code)).at(-1)! }
async function open(action = '新增库位') { const wrapper = await page(); const button = action === '新增库位' ? wrapper.findAll('button').find(item => item.text() === action)! : row(wrapper).findAll('button').find(item => item.text() === action)!; await button.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function fill(dialog: VueWrapper) {
field(dialog, '海外仓').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '9007199254740994')
await field(dialog, '库位编码').get('input').setValue(' LOC-NEW ')
field(dialog, '最大承重(kg)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1234.567)
await field(dialog, '备注').get('input').setValue(' 测试备注 '); await flushPromises()
}
async function save(dialog: VueWrapper) { await dialog.get('.app-dialog__footer .el-button--primary').trigger('click'); await flushPromises() }
function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!) }
async function accept() { await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() }
describe('native overseas location dialogs', () => {
it('keeps the supported fields and exact creation payload through fullscreen', async () => {
const { dialog } = await open(); await fill(dialog)
expect(dialog.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).toEqual(['海外仓', '库位编码', '最大承重(kg)', '备注'])
expect(field(dialog, '最大承重(kg)').getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ min: 0.001, precision: 3 })
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((field(dialog, '库位编码').get('input').element as HTMLInputElement).value).toBe(' LOC-NEW ')
await save(dialog)
expect(api.create).toHaveBeenCalledExactlyOnceWith('9007199254740994', { locationCode: 'LOC-NEW', locationType: 'NORMAL', maxLoadKg: 1234.567, status: 'ACTIVE', remark: '测试备注' })
expect(api.update).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(false)
})
it('requires the warehouse, code and a finite positive maximum load', async () => {
const { dialog } = await open(); await save(dialog); expect(api.create).not.toHaveBeenCalled()
await fill(dialog)
const input = field(dialog, '最大承重(kg)').getComponent({ name: 'ElInputNumber' })
for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { input.vm.$emit('update:modelValue', value); dialog.vm.$emit('confirm'); await flushPromises() }
expect(api.create).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(true)
})
it('keeps an inactive location inactive when editing and preserves the original warehouse and version zero', async () => {
const { dialog } = await open('编辑')
expect(dialog.props('title')).toBe('编辑海外库位 · LOC-001')
expect(field(dialog, '海外仓').text()).toContain('洛杉矶仓(LAX)'); expect(field(dialog, '海外仓').findAll('input')).toHaveLength(0)
await field(dialog, '库位编码').get('input').setValue(' LOC-EDIT '); await save(dialog)
expect(api.update).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', { locationCode: 'LOC-EDIT', locationType: 'NORMAL', maxLoadKg: 1000, status: 'INACTIVE', remark: '原备注', version: 0 })
expect(api.create).not.toHaveBeenCalled(); expect(api.status).not.toHaveBeenCalled()
})
it('locks repeated saves and closing, retaining inputs and diagnostics after failure', async () => {
let reject!: (error: Error) => void
api.create.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await open(); await fill(dialog); await save(dialog); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '库位编码').get('input').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(dialog.get('.app-dialog__footer .el-button').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('保存未确认', 'A0503', 503, null, 'location-create-request')); await flushPromises()
expect(dialog.text()).toContain('location-create-request'); expect(dialog.findComponent(LoadErrorState).props('showRetry')).toBe(false)
expect((field(dialog, '库位编码').get('input').element as HTMLInputElement).value).toBe(' LOC-NEW ')
const originalPayload = api.create.mock.calls[0]![1]; await save(dialog); expect(api.create.mock.calls[1]![1]).toEqual(originalPayload)
})
it('shows candidate failure inside the dialog and locks the original reload', async () => {
api.warehouses.mockRejectedValueOnce(new ApiResponseError('候选失败', 'A0503', 503, null, 'location-warehouse-request'))
const { dialog } = await open(); expect(dialog.text()).toContain('location-warehouse-request'); expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
let resolve!: (value: { records: Array<{ id: string; warehouseName: string; warehouseCode: string }> }) => void
api.warehouses.mockReturnValueOnce(new Promise(done => { resolve = done }))
await dialog.findComponent(LoadErrorState).get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
resolve({ records: [{ id: '9007199254740994', warehouseName: '洛杉矶仓', warehouseCode: 'LAX' }] }); await flushPromises()
expect(api.warehouses).toHaveBeenCalledTimes(2); expect(dialog.findComponent(LoadErrorState).exists()).toBe(false)
await fill(dialog); await save(dialog); expect(api.create).toHaveBeenCalledOnce()
})
it.each([['ACTIVE', '停用', 'INACTIVE'], ['INACTIVE', '启用', 'ACTIVE']] as const)('confirms %s with its business code and sends the original status command', async (current, action, next) => {
api.all.mockResolvedValueOnce({ records: [location({ status: current })] })
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === action)!.trigger('click'); await flushPromises()
expect(confirmation().text()).toContain('LOC-001'); expect(confirmation().classes()).toContain('admin-density-confirm')
expect(api.status).not.toHaveBeenCalled(); await accept()
expect(api.status).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', { locationCode: 'LOC-001', locationType: 'NORMAL', maxLoadKg: 1000, status: next, remark: '原备注', version: 0 })
})
it('deletes only after the native confirmation and preserves the warehouse, long id and version', async () => {
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '删除')!.trigger('click'); await flushPromises()
expect(confirmation().text()).toContain('LOC-001'); expect(confirmation().classes()).toContain('admin-density-confirm')
expect(api.remove).not.toHaveBeenCalled(); await accept()
expect(api.remove).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', 0)
})
it('protects A0, virtual, shared overflow and occupied resources while retaining valid normal actions', async () => {
const records = [
location({ id: '1', locationCode: 'A0' }),
location({ id: '2', locationCode: 'SHARED-01', locationType: 'SHARED_OVERFLOW' }),
location({ id: '3', locationCode: 'VIRTUAL-01', locationType: 'VIRTUAL' }),
location({ id: '4', locationCode: 'OVERFLOW-FLAG', sharedOverflow: true }),
location({ id: '5', locationCode: 'BUSY-01', status: 'OCCUPIED', occupiedPalletCount: 1 }),
location({ id: '6', locationCode: 'BOUND-01', occupiedPalletCount: 1 }),
location({ id: '7', locationCode: 'IDLE-01', status: 'IDLE' }),
location(),
]
api.all.mockResolvedValueOnce({ records }); const wrapper = await page()
for (const code of ['A0', 'SHARED-01', 'VIRTUAL-01', 'OVERFLOW-FLAG', 'BUSY-01', 'IDLE-01']) expect(row(wrapper, code).findAll('button')).toHaveLength(0)
expect(row(wrapper, 'BOUND-01').findAll('button').map(button => button.text())).toEqual(['编辑', '启用'])
expect(row(wrapper).findAll('button').map(button => button.text())).toEqual(['编辑', '启用', '删除'])
})
it('locks competing actions until the status request finishes and shows diagnostics on failure', async () => {
let reject!: (error: Error) => void
api.status.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '启用')!.trigger('click'); await flushPromises(); await accept()
for (const button of row(wrapper).findAll('button')) expect(button.attributes('disabled')).toBeDefined()
await row(wrapper).findAll('button').find(item => item.text() === '删除')!.trigger('click'); expect(api.remove).not.toHaveBeenCalled()
reject(new ApiResponseError('状态保存失败', 'A0503', 503, null, 'location-status-request')); await flushPromises()
expect(wrapper.text()).toContain('location-status-request'); expect(api.status).toHaveBeenCalledOnce()
expect(row(wrapper).findAll('button').every(button => button.attributes('disabled') === undefined)).toBe(true)
})
it('cancels without deleting and keeps list-only and separate write permissions', async () => {
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '删除')!.trigger('click'); await flushPromises()
await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises(); expect(api.remove).not.toHaveBeenCalled()
can.mockImplementation(code => code === permission.locationList)
const readonly = await page(); expect(row(readonly).findAll('button')).toHaveLength(0)
expect(readonly.findAll('button').some(button => button.text() === '新增库位')).toBe(false)
can.mockImplementation(code => code !== permission.locationEdit && code !== permission.locationDelete)
const createOnly = await open(); await fill(createOnly.dialog)
expect(row(createOnly.wrapper).findAll('button')).toHaveLength(0)
can.mockReturnValue(false); createOnly.dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
})
it('keeps inactive filtering in the current server vocabulary and resets a cancelled form', async () => {
const wrapper = await page(); const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').find((item: { key: string }) => item.key === 'status')!.options).toContainEqual({ label: '已停用', value: 'INACTIVE' })
toolbar.vm.$emit('search', { warehouseId: '9007199254740994', locationCode: '', locationType: '', status: 'INACTIVE' }); await flushPromises()
expect(api.list).toHaveBeenLastCalledWith('9007199254740994', { page: 1, size: 200, keyword: undefined, locationType: '', status: 'INACTIVE' })
await wrapper.findAll('button').find(item => item.text() === '新增库位')!.trigger('click'); await flushPromises(); const dialog = wrapper.findComponent(AppDialog)
await fill(dialog); await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
await wrapper.findAll('button').find(item => item.text() === '新增库位')!.trigger('click'); await flushPromises()
expect((field(dialog, '库位编码').get('input').element as HTMLInputElement).value).toBe(''); expect(api.create).not.toHaveBeenCalled()
})
})
@@ -2,28 +2,35 @@
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Save } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listAllOverseasLocations, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const access = useAccessPreviewStore(); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const rows = ref<OverseasLocationDto[]>([])
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<{ message: string } | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ warehouseId: '', locationCode: '', locationType: '', status: '' }); const editorOpen = ref(false); const saving = ref(false); const editing = ref<OverseasLocationDto | null>(null)
const form = reactive({ warehouseId: '', locationCode: '', locationName: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
const form = reactive({ warehouseId: '', locationCode: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
const contextError = ref<ApiErrorDetails | null>(null)
const dialogError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const pendingAction = ref('')
const editorBusy = computed(() => saving.value || contextLoading.value)
const formLocked = computed(() => editorBusy.value || Boolean(contextError.value) || !access.can(editing.value ? permission.locationEdit : permission.locationCreate))
const queryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 260, options: warehouses.value.map(item => ({ value: String(item.id), label: `${item.warehouseName}(${item.warehouseCode})` })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
{ key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
{ key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
{ key: 'locationType', type: 'select', label: '类型', width: 150, options: [{ label: '普通库位', value: 'NORMAL' }, { label: 'A0共享溢出位', value: 'SHARED_OVERFLOW' }, { label: '虚拟收货位', value: 'VIRTUAL' }] },
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'DISABLED' }] },
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'INACTIVE' }] },
])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'locationCode', prop: 'locationCode', label: '库位编码', minWidth: 180 }, { key: 'warehouse', label: '海外仓', minWidth: 190, slotName: 'warehouse' }, { key: 'locationType', label: '类型', width: 130, slotName: 'locationType' }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'occupiedPalletCount', prop: 'occupiedPalletCount', label: '托盘数', width: 100, align: 'right' }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
const typeLabels: Record<OverseasLocationType, string> = { VIRTUAL: '虚拟收货位', NORMAL: '普通库位', SHARED_OVERFLOW: 'A0共享溢出位' }; const statusLabels: Record<OverseasLocationStatus, string> = { ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', INACTIVE: '已停用', DISABLED: '已停用' }
@@ -32,25 +39,103 @@ const warehouseNameById = computed(() => new Map(warehouses.value.map(item => [S
function isA0(row: OverseasLocationDto) { return row.sharedOverflow || row.locationType === 'SHARED_OVERFLOW' || row.locationCode.toUpperCase() === 'A0' }
function canEdit(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canStatus(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.status === 'DISABLED' && Number(row.occupiedPalletCount || 0) === 0 }
function actionError(error: unknown, fallback: string) { ElMessage.error(getApiErrorDetails(error, fallback).message) }
async function loadWarehouses() { contextLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records; await load() } catch (error) { actionError(error, '海外仓列表加载失败') } finally { contextLoading.value = false } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); if (!warehouses.value.length) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const selectedWarehouseId = String(filters.value.warehouseId || ''); const result = await (selectedWarehouseId ? listOverseasLocations(selectedWarehouseId, { page: 1, size: 200, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' }) : listAllOverseasLocations(warehouses.value.map(item => String(item.id)), { keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' })); const records = result.records.map(row => ({ ...row, warehouseName: row.warehouseName || warehouseNameById.value.get(String(row.warehouseId)) || null })); total.value = records.length; const start = (page.value - 1) * pageSize.value; rows.value = records.slice(start, start + pageSize.value) } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.locationType === 'NORMAL' && row.status === 'INACTIVE' && Number(row.occupiedPalletCount || 0) === 0 }
function actionError(error: unknown, fallback: string) { mutationError.value = getApiErrorDetails(error, fallback) }
async function loadWarehouses() {
if (contextLoading.value) return
contextLoading.value = true; contextError.value = null
try { warehouses.value = (await listReceivingOverseasWarehouses()).records; await load() }
catch (error) { contextError.value = getApiErrorDetails(error, '海外仓列表加载失败') }
finally { contextLoading.value = false }
}
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); if (!warehouses.value.length) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const selectedWarehouseId = String(filters.value.warehouseId || ''); const result = await (selectedWarehouseId ? listOverseasLocations(selectedWarehouseId, { page: 1, size: 200, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: String(filters.value.status || '') as OverseasLocationStatus | '' }) : listAllOverseasLocations(warehouses.value.map(item => String(item.id)), { keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: String(filters.value.status || '') as OverseasLocationStatus | '' })); const records = result.records.map(row => ({ ...row, warehouseName: row.warehouseName || warehouseNameById.value.get(String(row.warehouseId)) || null })); total.value = records.length; const start = (page.value - 1) * pageSize.value; rows.value = records.slice(start, start + pageSize.value) } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), locationCode: '', locationName: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
function openCreate() { resetForm(); editorOpen.value = true }
function openEdit(row: OverseasLocationDto) { if (!canEdit(row)) return; Object.assign(form, { warehouseId: row.warehouseId, locationCode: row.locationCode, locationName: row.locationName || '', locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'DISABLED' ? 'INACTIVE' : 'ACTIVE', remark: row.remark || '' }); editing.value = row; editorOpen.value = true }
async function save() { if (!form.warehouseId || !form.locationCode.trim() || form.maxWeightKg == null) { ElMessage.warning('请填写海外仓、库位编码和最大承重'); return }; saving.value = true; try { const payload = { locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }; if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version }); else await createOverseasLocation(form.warehouseId, payload); editorOpen.value = false; await load() } catch (error) { actionError(error, '保存库位失败') } finally { saving.value = false } }
async function toggleStatus(row: OverseasLocationDto) { if (!canStatus(row)) return; const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认'); await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version }); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') } }
async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; try { await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning' }); await deleteOverseasLocation(row.warehouseId, row.id, row.version); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') } }
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), locationCode: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
function openCreate() {
if (editorOpen.value || editorBusy.value || pendingAction.value || !access.can(permission.locationCreate)) return
resetForm(); dialogError.value = null; mutationError.value = null; editorOpen.value = true
}
function openEdit(row: OverseasLocationDto) {
if (editorOpen.value || editorBusy.value || pendingAction.value || !canEdit(row)) return
Object.assign(form, { warehouseId: row.warehouseId, locationCode: row.locationCode, locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE', remark: row.remark || '' })
editing.value = row; dialogError.value = null; mutationError.value = null; editorOpen.value = true
}
async function save() {
if (!editorOpen.value || formLocked.value || pendingAction.value || (editing.value ? !canEdit(editing.value) : !access.can(permission.locationCreate))) return
if (!form.warehouseId || !form.locationCode.trim() || form.maxWeightKg == null || !Number.isFinite(form.maxWeightKg) || form.maxWeightKg <= 0) { ElMessage.warning('请填写海外仓、库位编码和大于0的最大承重'); return }
saving.value = true; dialogError.value = null
try {
const payload = { locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }
if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version })
else await createOverseasLocation(form.warehouseId, payload)
editorOpen.value = false; ElMessage.success('库位已保存'); await load()
} catch (error) { dialogError.value = getApiErrorDetails(error, '保存库位失败') }
finally { saving.value = false }
}
async function toggleStatus(row: OverseasLocationDto) {
if (!canStatus(row) || pendingAction.value || editorOpen.value || editorBusy.value) return
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
pendingAction.value = String(row.id); mutationError.value = null
try {
await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认', { customClass: 'admin-density-confirm', type: next === 'ACTIVE' ? 'info' : 'warning' })
await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version })
ElMessage.success(next === 'ACTIVE' ? '库位已启用' : '库位已停用'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') }
finally { pendingAction.value = '' }
}
async function remove(row: OverseasLocationDto) {
if (!canDelete(row) || pendingAction.value || editorOpen.value || editorBusy.value) return
pendingAction.value = String(row.id); mutationError.value = null
try {
await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning', customClass: 'admin-density-confirm' })
await deleteOverseasLocation(row.warehouseId, row.id, row.version); ElMessage.success('库位已删除'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') }
finally { pendingAction.value = '' }
}
onMounted(() => { void loadWarehouses() })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库位管理"><section><DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库位管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template>
<template #notices><LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load" /></template>
<template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" @click="openEdit(row as OverseasLocationDto)">编辑</el-button><el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" @click="remove(row as OverseasLocationDto)">删除</el-button></div></template><template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template><template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template><template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template><template #pagination-left>共 {{ total }} 个库位</template></DataTable></section></ListPageLayout><AppDialog v-model="editorOpen" :title="editing ? '编辑海外库位' : '新增海外库位'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" :show-confirm="false"><el-form label-position="top" :disabled="saving"><div class="editor-grid"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" :disabled="Boolean(editing)" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item><el-form-item label="库位名称"><el-input v-model="form.locationName" maxlength="100" placeholder="请输入库位名称(选填)" /></el-form-item><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></div><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></el-form><template #footer><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存库位</el-button></template></AppDialog></AdminPage></template>
<style scoped>.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.editor-grid :deep(.el-select),.editor-grid :deep(.el-input-number){width:100%}@media(max-width:720px){.editor-grid{grid-template-columns:1fr}}</style>
<template>
<AdminPage class="admin-density">
<ListPageLayout aria-label="海外仓库位管理">
<section>
<DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError && !contextError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库位管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template>
<template #notices>
<LoadErrorState v-if="contextError" title="海外仓列表加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
<LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-if="mutationError" title="库位操作失败" :message="mutationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
</template>
<template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
<template #actions="{ row }">
<div class="density-actions row-actions admin-text-actions">
<el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="openEdit(row as OverseasLocationDto)">编辑</el-button>
<el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
<el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="remove(row as OverseasLocationDto)">删除</el-button>
</div>
</template>
<template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template>
<template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template>
<template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template>
<template #pagination-left>共 {{ total }} 个库位</template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editing ? `编辑海外库位 · ${editing.locationCode}` : '新增海外库位'" width="min(560px, calc(100vw - 28px))" fullscreenable :loading="contextLoading" :confirm-loading="editorBusy" :confirm-disabled="formLocked" confirm-text="保存库位" @confirm="save">
<LoadErrorState v-if="contextError" title="海外仓列表加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" title="保存库位失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="formLocked">
<section class="density-dialog-section" data-tone="work">
<table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="海外仓" required><span v-if="editing">{{ selectedWarehouse ? `${selectedWarehouse.warehouseName}(${selectedWarehouse.warehouseCode})` : '—' }}</span><el-select v-else v-model="form.warehouseId" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item></td></tr>
<tr><td><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></td></tr>
<tr><td><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</AppDialog>
</AdminPage>
</template>
@@ -0,0 +1,147 @@
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 OutboundsView from './OutboundsView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { OneClickOutboundCandidate } from '../api/outbounds'
const api = vi.hoisted(() => ({ list: vi.fn(), candidates: vi.fn(), create: vi.fn(), returns: vi.fn(), createReturn: vi.fn(), transfers: vi.fn(), bind: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), tenants: vi.fn(), warehouses: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.tenants }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: api.warehouses }))
vi.mock('../api/outbounds', () => ({ listOutbounds: api.list, listOneClickOutboundCandidates: api.candidates, createOneClickDropshipOutbound: api.create, listReturnOutboundCandidates: api.returns, createReturnOutbound: api.createReturn, listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bind, cancelOutbound: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const candidate = (index = 0): OneClickOutboundCandidate => ({ inventoryPositionId: '9007199254740993' + index, customerId: 'C1', customerName: '用于验证长文本省略的完整客户名称', orderNo: 'ORDER-' + index, inboundCode: 'IN-' + index, overseasWarehouseId: 'W1', palletNo: 'PALLET-' + index, skuCode: 'SKU-' + index, productName: '中文品名', availablePackages: index + 2, grossWeightKg: 20, volumeWeightKg: 30, volumeCbm: 0.2 })
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [], total: 0 })
api.candidates.mockResolvedValue([candidate()]); api.create.mockResolvedValue({ outboundNo: 'OUT-1' }); api.createReturn.mockResolvedValue({ outboundNo: 'OUT-2' }); api.bind.mockResolvedValue([])
api.tenants.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 })
api.warehouses.mockResolvedValue({ records: [{ id: 'W1', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, { id: 'FBA1', warehouseName: 'FBA目的仓', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' }] })
api.transfers.mockResolvedValue([]); api.returns.mockResolvedValue([{ returnNo: 'RT-1', cartonMark: 'RC-1', palletCode: 'P-1' }])
api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 6 }], total: 1 })
api.cartons.mockResolvedValue({ records: [{ cartonMark: 'RC-1', sourceReturnNo: 'RT-1', sourceUnitType: 'BOX', status: 'AVAILABLE' }], total: 1 })
api.carton.mockResolvedValue({ cartonMark: 'RC-1', items: [{ skuCode: 'SKU-A', quantity: 4 }, { skuCode: 'SKU-B', quantity: 2 }] })
})
async function render(open = true) {
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }); await flushPromises()
if (open) { await wrapper.findAll('button').find(button => button.text() === '新建出库单')!.trigger('click'); 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)! }
async function chooseMode(dialog: VueWrapper, value: string) { const control = field(dialog, '出库类型').getComponent({ name: 'ElRadioGroup' }); control.vm.$emit('update:modelValue', value); control.vm.$emit('change', value); await flushPromises() }
async function choose(dialog: VueWrapper, label: string, value: string) { const control = field(dialog, label).getComponent({ name: 'ElSelect' }); control.vm.$emit('update:modelValue', value); control.vm.$emit('change', value); await flushPromises() }
async function selectFirst(dialog: VueWrapper) { await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true); await flushPromises() }
describe('native outbound creation dialogs', () => {
it('keeps continuous scope controls, full candidate facts, paging and fullscreen input', async () => {
const { dialog } = await render()
expect(dialog.props('width')).toBe('min(1760px, calc(100vw - 32px))')
expect(dialog.get('.create-outbound-form').attributes('class')).toContain('density-dialog-sections')
expect(dialog.get('.dropship-search-row').findAll('.el-form-item')).toHaveLength(2)
const table = dialog.findComponent({ name: 'ElTable' })
expect(table.props('maxHeight')).toBeUndefined()
expect(table.findAllComponents({ name: 'ElTableColumn' }).find(column => column.props('prop') === 'customerName')!.props('showOverflowTooltip')).toBe(true)
expect(table.text()).toContain('可出毛重(kg)'); expect(table.text()).toContain('可出材积重(kg)'); expect(table.text()).toContain('可出体积(m³)')
await field(dialog, '备注').get('textarea').setValue('本次出库备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('本次出库备注')
await selectFirst(dialog); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { recipient: '一件代发订单直出', items: [{ inventoryPositionId: candidate().inventoryPositionId, packages: 2, sku: 'SKU-0', cartonMarks: [] }], remark: '本次出库备注', idempotencyKey: expect.stringMatching(/^web-dropship-/) })
})
it('locks editing, closing and submission while candidates are loading', async () => {
let resolve!: (value: OneClickOutboundCandidate[]) => void
api.candidates.mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await render()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '海外仓').getComponent({ name: 'ElSelect' }).props('disabled')).toBe(true)
expect(dialog.findComponent({ name: 'ElPagination' }).props('disabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
resolve([candidate()]); await flushPromises(); expect(dialog.props('confirmLoading')).toBe(false)
})
it('prevents duplicate creation and retains failed inputs, selection and diagnostics', async () => {
let reject!: (cause: Error) => void
api.create.mockReturnValue(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await render(); await selectFirst(dialog)
await field(dialog, '备注').get('textarea').setValue('失败后保留')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElPagination' }).props('disabled')).toBe(true)
reject(new ApiResponseError('创建失败', 'A0503', 503, null, 'outbound-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true); expect(dialog.text()).toContain('outbound-request')
expect(dialog.text()).toContain('已选择 1 条')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('失败后保留')
})
it('starts a new form after cancel without leaking recipient, remark or return mode', async () => {
const { wrapper, dialog } = await render(); await chooseMode(dialog, 'RETURN_OUTBOUND')
await field(dialog, '收件信息').get('input').setValue('上一单收件信息')
await field(dialog, '备注').get('textarea').setValue('上一单备注')
const mode = field(dialog, '箱托方式').getComponent({ name: 'ElRadioGroup' }); mode.vm.$emit('update:modelValue', 'PALLET'); mode.vm.$emit('change', 'PALLET'); await flushPromises()
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(field(dialog, '出库类型').getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('ONE_CLICK_DROPSHIP')
await chooseMode(dialog, 'RETURN_OUTBOUND')
expect((field(dialog, '收件信息').get('input').element as HTMLInputElement).value).toBe('')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('')
expect(field(dialog, '箱托方式').getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('BOX')
})
it('keeps whole multi-SKU boxes and original return scope without partial quantity inputs', async () => {
const { dialog } = await render(); await chooseMode(dialog, 'RETURN_OUTBOUND'); await choose(dialog, '客户', 'C1'); await choose(dialog, '退货单', 'RT-1')
await field(dialog, '收件信息').get('input').setValue('本次收件信息')
expect(dialog.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
const cards = dialog.findAll('.return-list-card'); expect(cards).toHaveLength(2)
expect(cards[1]!.text()).toContain('SKU-A × 4,SKU-B × 2')
for (const table of dialog.findAllComponents({ name: 'ElTable' })) expect(table.props('maxHeight')).toBeUndefined()
await cards[1]!.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createReturn).toHaveBeenCalledExactlyOnceWith('C1', { recipient: '本次收件信息', overseasWarehouseId: 'W1', returnNo: 'RT-1', cartonMarks: ['RC-1'], unitType: 'BOX', remark: undefined, idempotencyKey: expect.stringMatching(/^web-return-/) })
})
it('binds grouped private destinations while hiding internal customer identifiers', async () => {
const source = { customerId: '90071992547409930', transferNo: 'WT-1', overseasWarehouseId: 'W1', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false, packageCount: 3 }
api.transfers.mockResolvedValue([source, { ...source, packageCount: 4 }])
const { dialog } = await render(); await chooseMode(dialog, 'WAREHOUSE_TRANSFER_OUTBOUND')
expect(dialog.text()).toContain('客户名称待补充'); expect(dialog.text()).not.toContain(source.customerId)
const table = dialog.findComponent({ name: 'ElTable' }); expect(table.props('data')).toMatchObject([{ transferNo: 'WT-1', packageCount: 7 }])
const type = field(dialog, '目的地类型').getComponent({ name: 'ElRadioGroup' }); type.vm.$emit('update:modelValue', 'PRIVATE'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.bind).not.toHaveBeenCalled()
for (const [label, value] of [['私人仓地址', '完整地址'], ['邮编', '90210'], ['联系人', '收件人'], ['联系电话', '测试电话']]) await field(dialog, label!).get('input').setValue(value!)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.bind).toHaveBeenCalledExactlyOnceWith({ transfers: [{ customerId: source.customerId, transferNo: 'WT-1' }], destinationType: 'PRIVATE', destinationWarehouseId: undefined, destinationWarehouseCode: undefined, destinationWarehouseName: undefined, privateDestinationAddress: '完整地址', privateDestinationPostalCode: '90210', privateDestinationContactName: '收件人', privateDestinationContactPhone: '测试电话', idempotencyKey: expect.stringMatching(/^web-transfer-destination-/) })
expect(api.create).not.toHaveBeenCalled(); expect(api.createReturn).not.toHaveBeenCalled()
})
it('shows candidate diagnostics, blocks submission and retries the same query', async () => {
api.candidates.mockRejectedValueOnce(new ApiResponseError('候选暂不可用', 'A0503', 503, null, 'candidate-request')).mockResolvedValueOnce([candidate()])
const { dialog } = await render(); expect(dialog.text()).toContain('candidate-request'); expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.candidates).toHaveBeenLastCalledWith(undefined, { warehouseId: 'W1' })
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); expect(dialog.props('confirmDisabled')).toBe(false)
})
it('preserves scope and inputs when retrying a failed platform resource read', async () => {
api.tenants.mockRejectedValueOnce(new ApiResponseError('客户资料暂不可用', 'A0503', 503, null, 'resource-request'))
const { dialog } = await render(); expect(dialog.text()).toContain('resource-request')
await field(dialog, '备注').get('textarea').setValue('重读时保留'); await selectFirst(dialog)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); expect(dialog.text()).toContain('已选择 1 条')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('重读时保留')
})
it('keeps creation permission separate from list access', async () => {
can.mockImplementation(code => !code.endsWith(':create'))
const { wrapper, dialog } = await render(false)
expect(wrapper.findAll('button').some(button => button.text() === '新建出库单')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.candidates).not.toHaveBeenCalled(); expect(api.create).not.toHaveBeenCalled()
})
})
@@ -46,6 +46,8 @@ const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX')
const returnSkuPageSize = ref(5); const returnCartonPageSize = ref(5)
const returnCartonDetails = ref<Record<string, ReturnCartonDetail>>({})
const resourceErrors = ref<ApiErrorDetails[]>([]), candidateError = ref<ApiErrorDetails | null>(null), createError = ref<ApiErrorDetails | null>(null)
const createLocked = computed(() => saving.value || inventoryLoading.value || !access.can(permission.outboundCreate))
const customerOptions = computed(() => {
const customers = new Map<string, string>()
tenantOptions.value.forEach((item) => {
@@ -65,7 +67,7 @@ function transferCustomerName(row: WarehouseTransferOutboundCandidate) {
const snapshotName = row.customerName?.trim()
if (snapshotName) return snapshotName
const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId))
return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}`
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '客户名称待补充'
}
const selectedReturnStock = computed(() => returnStockRows.value.filter(item => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
@@ -115,7 +117,7 @@ async function loadCustomerOptions() {
}
}
async function loadInventoryOptions() {
inventoryLoading.value = true
inventoryLoading.value = true; resourceErrors.value = []
try {
const [tenants, warehouses, fbaWarehouses] = await Promise.allSettled([
loadCustomerOptions(),
@@ -123,25 +125,25 @@ async function loadInventoryOptions() {
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'FBA', status: 'ACTIVE' }),
])
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value
else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message)
else resourceErrors.value.push(getApiErrorDetails(tenants.reason, '客户列表加载失败'))
if (warehouses.status === 'fulfilled') {
warehouseOptionsSource.value = warehouses.value.records
if (!selectedWarehouseId.value) {
const lax = warehouses.value.records.find((item) => item.warehouseCode === 'US-LAX-01' || item.warehouseName?.includes('洛杉矶'))
selectedWarehouseId.value = String(lax?.id || warehouses.value.records[0]?.id || '')
}
} else ElMessage.error(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败').message)
} else resourceErrors.value.push(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败'))
if (fbaWarehouses.status === 'fulfilled') fbaWarehouseOptionsSource.value = fbaWarehouses.value.records
else ElMessage.error(getApiErrorDetails(fbaWarehouses.reason, 'FBA目的仓加载失败').message)
else resourceErrors.value.push(getApiErrorDetails(fbaWarehouses.reason, 'FBA目的仓加载失败'))
} finally { inventoryLoading.value = false }
}
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
async function openCreate() { if (createOpen.value || saving.value || inventoryLoading.value || !access.can(permission.outboundCreate)) return; createOpen.value = true; createError.value = null; candidateError.value = null; recipient.value = ''; remark.value = ''; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnUnitType.value = 'BOX'; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { candidateError.value = null; if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; candidateError.value = getApiErrorDetails(cause, '一件代发订单加载失败') } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function selectCustomer() { candidateError.value = null; selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { candidateError.value = null; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function loadTransferCandidates() {
inventoryLoading.value = true
candidateError.value = null; inventoryLoading.value = true
try {
const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
// The API returns pallet rows, but destination binding applies to the whole transfer.
@@ -162,7 +164,7 @@ async function loadTransferCandidates() {
} catch (cause) {
transferCandidates.value = []
selectedTransferPalletIds.value = []
ElMessage.error(getApiErrorDetails(cause, '无目的地仓储转运单加载失败').message)
candidateError.value = getApiErrorDetails(cause, '无目的地仓储转运单加载失败')
} finally { inventoryLoading.value = false }
}
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
@@ -180,7 +182,7 @@ function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
function transferHasDestination(row: WarehouseTransferOutboundCandidate) {
return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim())
}
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
async function loadReturnCandidates() { candidateError.value = null; if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { candidateError.value = getApiErrorDetails(cause, '客户库存加载失败') } finally { inventoryLoading.value = false } }
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
@@ -203,7 +205,19 @@ function toggleReturnPallet(palletCode: string, checked: boolean | string | numb
const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!)
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark))
}
async function retryCreateCandidates() {
if (!createOpen.value || saving.value || inventoryLoading.value) return
if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates()
else if (outboundType.value === 'RETURN_OUTBOUND') await loadReturnCandidates()
else await loadTransferCandidates()
}
async function retryCreateResources() {
if (!createOpen.value || saving.value || inventoryLoading.value) return
await loadInventoryOptions(); await retryCreateCandidates()
}
async function submit() {
if (!createOpen.value || createLocked.value || candidateError.value) return
createError.value = null
if (outboundType.value !== 'WAREHOUSE_TRANSFER_OUTBOUND' && !selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }
if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }
saving.value = true
@@ -237,7 +251,7 @@ async function submit() {
ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`)
}
createOpen.value = false; await load()
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false }
} catch (cause) { createError.value = getApiErrorDetails(cause, '出库操作未完成') } finally { saving.value = false }
}
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
@@ -277,46 +291,43 @@ onMounted(load)
<template #notices><template v-if="error"><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" :disabled="createOpen || saving || inventoryLoading" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button><el-button v-if="row.dispatchNo && access.can(permission.dispatchList)" class="density-action" data-tone="primary" size="small" @click="openDispatch(row as OutboundRow)">{{ row.status === 'CANCELLED' ? '处理原排车' : '查看排车' }}</el-button></div></template>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form density-form" label-position="top">
<el-form-item label="出库类型" required>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving || inventoryLoading" :confirm-disabled="createLocked || Boolean(candidateError)" @confirm="submit">
<div class="density-dialog-sections">
<LoadErrorState v-for="(details, index) in resourceErrors" :key="index" title="出库基础资料读取失败" :message="details.message" :loading="inventoryLoading || saving" @retry="retryCreateResources"><template #details><ApiErrorMeta :details="details" /></template></LoadErrorState>
<LoadErrorState v-if="candidateError" title="出库候选读取失败" :message="candidateError.message" :loading="inventoryLoading || saving" @retry="retryCreateCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState>
<LoadErrorState v-if="createError" title="出库操作未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
<el-alert v-if="!access.can(permission.outboundCreate)" title="当前账号没有新建出库权限。" type="warning" :closable="false" show-icon />
<el-form class="create-outbound-form density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="createLocked">
<section class="density-dialog-section" data-tone="work" aria-label="出库模式"><h3 class="density-dialog-section__title">出库类型</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType">
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
<el-radio-button label="RETURN_OUTBOUND">退货出库</el-radio-button>
<el-radio-button label="WAREHOUSE_TRANSFER_OUTBOUND">仓储转运出库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer">
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
<div class="dropship-search-row">
<el-form-item label="海外仓" required>
<el-select v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
</el-form-item></td></tr></tbody></table></section>
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
<section class="density-dialog-section" data-tone="work" aria-label="一件代发出库范围"><h3 class="density-dialog-section__title">出库范围</h3><table class="density-table density-table--editable outbound-editor-table dropship-search-row"><tbody><tr><td><el-form-item label="海外仓" required>
<el-select :disabled="createLocked" v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<el-form-item label="一件代发订单">
</el-form-item></td><td><el-form-item label="一件代发订单">
<div class="candidate-query">
<el-input v-model="dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="订单号 / 入库单号 / 客户 / 托盘 / SKU / 品名" @keyup.enter="searchDropshipCandidates" />
<el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button>
<el-button @click="resetDropshipCandidates">重置</el-button>
</div>
</el-form-item>
</div>
<section class="candidate-results" aria-label="已完成入库的一件代发订单">
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360">
<el-table-column label="选择" width="68" align="center">
<template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="inventoryLoading || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template>
<template #default="{ row }"><el-checkbox :aria-label="`选择 ${row.orderNo || '订单'} / ${row.palletNo || '托盘'} / ${row.skuCode || 'SKU'}`" :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
</el-form-item></td></tr></tbody></table></section>
<section class="candidate-results density-dialog-section" data-tone="source" aria-label="已完成入库的一件代发订单"><h3 class="density-dialog-section__title">已完成入库的订单</h3>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table">
<el-table-column label="选择" width="68" align="center" fixed="left">
<template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="createLocked || Boolean(candidateError) || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template>
<template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="`选择 ${row.orderNo || '订单'} / ${row.palletNo || '托盘'} / ${row.skuCode || 'SKU'}`" :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
</el-table-column>
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="orderNo" label="尾程订单号" min-width="170" show-overflow-tooltip />
@@ -329,96 +340,73 @@ onMounted(load)
<el-table-column prop="volumeWeightKg" label="可出材积重(kg)" width="135" align="right" />
<el-table-column prop="volumeCbm" label="可出体积(m³)" width="125" align="right" />
</el-table>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="density-dialog-note">暂无可直接出库的已完成入库订单</div>
<div class="candidate-pagination">
<span aria-live="polite" title="翻页保留选择">已选择 {{ selectedDropshipIds.length }} 条 · 第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
<el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
<el-pagination :disabled="createLocked" v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
</div>
<el-alert v-if="selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
<p v-else class="candidate-hint">按托盘与 SKU 选择,默认出库全部可用箱数;仅限同一客户、同一海外仓,翻页保留选择。</p>
<p v-else class="density-dialog-note">按托盘与 SKU 选择,默认出库全部可用箱数;仅限同一客户、同一海外仓,翻页保留选择。</p>
</section>
</template>
<template v-else-if="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND'">
<el-alert title="仅展示已完成入库、换标已完成或无需换标,并且尚未设置目的地的仓储转运单。绑定后将从此列表移除并进入智能排车候选。" type="info" :closable="false" />
<el-form-item label="无目的地转运单" required>
<el-table v-loading="inventoryLoading" :data="transferCandidates" border stripe class="dropship-candidate-table" max-height="380">
<el-table-column label="选择" width="68" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedTransferPalletIds.includes(transferSelectionKey(row as WarehouseTransferOutboundCandidate))" @change="toggleTransferPallet(row as WarehouseTransferOutboundCandidate, $event)" /></template></el-table-column>
</template>
<template v-else-if="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND'">
<el-alert title="仅展示已完成入库、换标已完成或无需换标,并且尚未设置目的地的仓储转运单。绑定后将从此列表移除并进入智能排车候选。" type="info" :closable="false" />
<section class="density-dialog-section" data-tone="source" aria-label="无目的地转运单"><h3 class="density-dialog-section__title">无目的地转运单</h3><el-table v-loading="inventoryLoading" :data="transferCandidates" border stripe class="dropship-candidate-table">
<el-table-column label="选择" width="68" align="center" fixed="left"><template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="'选择转运单 ' + row.transferNo" :model-value="selectedTransferPalletIds.includes(transferSelectionKey(row as WarehouseTransferOutboundCandidate))" @change="toggleTransferPallet(row as WarehouseTransferOutboundCandidate, $event)" /></template></el-table-column>
<el-table-column label="客户" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ transferCustomerName(row as WarehouseTransferOutboundCandidate) }}</template></el-table-column>
<el-table-column prop="transferNo" label="转运单号" min-width="190" show-overflow-tooltip />
<el-table-column prop="packageCount" label="入库箱数" width="100" align="right" />
<el-table-column prop="volumeCbm" label="总体积(m³)" width="120" align="right" />
<el-table-column label="换标" width="110"><template #default="{ row }">{{ row.needRelabel ? `已换标${row.relabelNo ? ` · ${row.relabelNo}` : ''}` : '原箱出库' }}</template></el-table-column>
<el-table-column label="主单目的去向" min-width="240" show-overflow-tooltip><template #default="{ row }"><StatusBadge :status="transferHasDestination(row as WarehouseTransferOutboundCandidate) ? 'READY' : 'PENDING'" :label="transferDestinationLabel(row as WarehouseTransferOutboundCandidate)" /></template></el-table-column>
</el-table>
<div v-if="!inventoryLoading && !transferCandidates.length" class="form-hint">暂无已入库且尚无目的地的仓储转运单</div>
</el-form-item>
<el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="destinationType === 'FBA'" label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<template v-else>
<el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item>
<el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item>
<el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item>
</el-table><p v-if="!inventoryLoading && !transferCandidates.length" class="density-dialog-note">暂无已入库且尚无目的地的仓储转运单</p></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 outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item></td></tr><tr v-if="destinationType === 'FBA'"><td colspan="2"><el-form-item label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><template v-else><tr><td><el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item></td><td><el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></template></tbody></table></section>
</template>
</template>
<template v-else>
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item>
<p class="candidate-hint">整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p>
<el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item>
<template v-else>
<el-alert title="整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="退货出库资料"><h3 class="density-dialog-section__title">退货出库资料</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td><el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer">
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item></td><td><el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="createLocked || !selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item></td><td><el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item></td></tr></tbody></table></section>
<section v-if="returnUnitType === 'PALLET' && returnPallets.length" class="density-dialog-section" data-tone="work" aria-label="整托选择"><h3 class="density-dialog-section__title">整托选择</h3><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :disabled="createLocked || Boolean(candidateError)" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></section>
<div class="return-lists">
<section class="return-list-card">
<div class="list-title"><span>SKU库存</span><small>按 SKU 查询</small></div>
<section class="return-list-card density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">SKU库存</h3>
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>SKU 库存</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table"><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>SKU 库存</span><el-pagination :disabled="createLocked" v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
</section>
<section class="return-list-card">
<div class="list-title"><span>包裹 / 箱号列表</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
<section class="return-list-card density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">包裹 / 箱号列表</h3><p class="density-dialog-note">支持多个箱号或 SKU,用英文逗号分隔。</p>
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column label="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="包裹 / 箱内 SKU 及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="'选择箱号 ' + row.cartonMark" :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column label="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="包裹 / 箱内 SKU 及数量" min-width="230" show-overflow-tooltip><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</span><el-pagination :disabled="createLocked" v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
</section>
</div>
</template>
<el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</template>
<section class="density-dialog-section" data-tone="work" aria-label="本次出库备注"><h3 class="density-dialog-section__title">备注</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" placeholder="请输入备注(选填)" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
</template>
<style scoped>
.create-outbound-form { padding: 0 var(--admin-space-1); }
.create-outbound-form :deep(.el-form-item__content > .el-input), .create-outbound-form :deep(.el-form-item__content > .el-select), .create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }
.form-hint { margin-top: var(--admin-space-2); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-list { display: grid; gap: var(--admin-space-3); max-height: 300px; overflow: auto; padding: var(--admin-space-3) 0; }
.candidate-list :deep(.el-checkbox) { margin-right: 0; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: var(--admin-space-5); align-items: center; width: min(700px, 100%); }
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: var(--admin-font-caption); }
.candidate-query { display: flex; width: 100%; gap: var(--admin-space-4); }
.candidate-query :deep(.el-input) { flex: 1; }
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
.dropship-search-row { display: grid; grid-template-columns: minmax(0, 280px) minmax(0, 640px); align-items: end; gap: var(--admin-space-5); }
.dropship-search-row :deep(.el-form-item) { min-width: 0; margin-bottom: var(--admin-space-3); }
.outbound-editor-table { table-layout: fixed; }
.outbound-editor-table td:not([colspan]) { width: 50%; }
.candidate-query { display: flex; width: 100%; min-width: 0; gap: var(--admin-space-3); }
.candidate-query > .el-input { flex: 1; min-width: 0; }
.candidate-query > .el-button { flex: 0 0 auto; }
.candidate-results { min-width: 0; }
.candidate-hint { margin: 0 0 var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.5; }
.dropship-candidate-table { width: 100%; }
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-pagination, .return-pagination { flex-wrap: wrap; }
.candidate-pagination, .return-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-4); flex-wrap: wrap; padding-top: var(--admin-space-4); }
.candidate-pagination > span, .return-pagination > span { flex-shrink: 0; white-space: nowrap; }
.candidate-pagination :deep(.el-pagination), .return-pagination :deep(.el-pagination) { min-width: 0; max-width: 100%; flex-wrap: wrap; gap: var(--admin-space-2); justify-content: flex-end; }
.candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; }
.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }
.candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
.return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.list-title span { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 650; }
.list-title small { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
.return-result-table { width: 100%; margin-top: var(--admin-space-5); background: var(--el-bg-color); }
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding-top: var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.return-pagination :deep(.el-pagination) { flex-wrap: wrap; justify-content: flex-end; }
.return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; }
.return-pallet-options { display: flex; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-4); }
.return-list-card { min-width: 0; margin: 0; }
.return-result-table { width: 100%; margin-top: var(--admin-space-4); }
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
</style>
@@ -0,0 +1,138 @@
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 { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletDto } from '../api/pallets'
import PalletsView from './PalletsView.vue'
const api = vi.hoisted(() => ({ warehouses: vi.fn(), fba: vi.fn(), list: vi.fn(), summary: vi.fn(), detail: vi.fn(), create: vi.fn(), update: vi.fn(), print: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: api.fba }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: api.warehouses, listPallets: api.list, getPalletSummary: api.summary, getPallet: api.detail, createPallet: api.create, updatePallet: api.update, getPalletLabelPdf: api.print }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
function pallet(): PalletDto { return { id: '9007199254740993', customerId: '0', warehouseId: '9007199254740994', palletCode: 'PLT-LAX-001', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: '原备注', version: 0 } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.warehouses.mockResolvedValue({ records: [{ id: '9007199254740994', warehouseName: '洛杉矶仓', warehouseCode: 'LAX' }] })
api.fba.mockResolvedValue([{ id: 'F1', warehouseName: 'ABE3仓', warehouseCode: 'ABE3' }])
api.list.mockResolvedValue({ records: [pallet()], total: 1 })
api.summary.mockResolvedValue({ totalCount: 1, idleCount: 1, occupiedCount: 0, scrappedCount: 0 })
api.detail.mockImplementation(async () => pallet()); api.create.mockResolvedValue('9007199254740995'); api.update.mockResolvedValue(null)
})
function field(dialog: Pick<VueWrapper, 'findAllComponents'>, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function page() { const wrapper = mount(PalletsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
async function open(action = '新增托盘') { const wrapper = await page(); await wrapper.findAll('button').filter(button => button.text() === action).at(-1)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function fill(dialog: VueWrapper) {
await field(dialog, '托盘编码').get('input').setValue(' PLT-NEW ')
for (const [label, value] of [['托盘类型', 'PLASTIC'], ['仓库', '9007199254740994'], ['所属 FBA 仓', 'ABE3'], ['状态', 'OCCUPIED']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2.3456)
field(dialog, '采购价格(USD)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.34)
await field(dialog, '备注').get('textarea').setValue(' 新备注 '); await flushPromises()
}
async function confirm(dialog: VueWrapper) { await dialog.get('.app-dialog__footer .el-button--primary').trigger('click'); await flushPromises() }
describe('native overseas pallet dialogs', () => {
it('retains every field, precision and exact creation payload through fullscreen', async () => {
const { dialog } = await open(); await fill(dialog)
expect(dialog.findAllComponents({ name: 'ElFormItem' })).toHaveLength(8)
expect(field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ min: 0.0001, precision: 4 })
expect(field(dialog, '采购价格(USD)').getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ min: 0, precision: 2 })
expect(field(dialog, '托盘类型').findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE'])
expect(field(dialog, '状态').findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['IDLE', 'OCCUPIED', 'SCRAPPED'])
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((field(dialog, '托盘编码').get('input').element as HTMLInputElement).value).toBe(' PLT-NEW ')
await confirm(dialog)
expect(api.create).toHaveBeenCalledExactlyOnceWith('9007199254740994', { palletCode: 'PLT-NEW', palletType: 'PLASTIC', fbaWarehouseCode: 'ABE3', capacityCbm: 2.3456, unitPrice: 12.34, status: 'OCCUPIED', locationId: null, remark: '新备注' })
expect(api.update).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(false)
})
it('keeps required fields and positive capacity validation before creating', async () => {
const { dialog } = await open(); await confirm(dialog); expect(api.create).not.toHaveBeenCalled()
await fill(dialog)
field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 0); await confirm(dialog)
expect(api.create).not.toHaveBeenCalled()
field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
field(dialog, '所属 FBA 仓').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', ''); await confirm(dialog)
expect(api.create).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(true)
})
it('locks duplicate creation and closing, then retains the original fields after failure', async () => {
let reject!: (error: Error) => void
api.create.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await open(); await fill(dialog)
await confirm(dialog); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '托盘编码').get('input').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(dialog.get('.app-dialog__footer .el-button').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('托盘保存未确认', 'A0503', 503, null, 'pallet-create-request')); await flushPromises()
expect(dialog.text()).toContain('pallet-create-request'); expect(dialog.findComponent(LoadErrorState).props('showRetry')).toBe(false)
expect((field(dialog, '托盘编码').get('input').element as HTMLInputElement).value).toBe(' PLT-NEW ')
const originalPayload = api.create.mock.calls[0]![1]
await confirm(dialog); expect(api.create.mock.calls[1]![1]).toEqual(originalPayload)
})
it('shows the edit business code while reading and retries the same warehouse and long id', async () => {
let reject!: (error: Error) => void
api.detail.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { wrapper, dialog } = await open('编辑')
expect(dialog.props('title')).toBe('编辑托盘 · PLT-LAX-001'); expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.get('[role="status"]').text()).toContain('正在读取'); expect(dialog.findAll('input')).toHaveLength(0)
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.update).not.toHaveBeenCalled()
reject(new ApiResponseError('读取失败', 'A0503', 503, null, 'pallet-read-request')); await flushPromises()
expect(dialog.text()).toContain('pallet-read-request'); expect(dialog.props('confirmDisabled')).toBe(true)
await dialog.findComponent(LoadErrorState).get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.detail.mock.calls).toEqual([['9007199254740994', '9007199254740993'], ['9007199254740994', '9007199254740993']])
expect(field(dialog, '仓库').text()).toContain('洛杉矶仓(LAX)'); expect(field(dialog, '仓库').findAll('input')).toHaveLength(0)
})
it('keeps version zero, immutable warehouse and editable facts after an update conflict', async () => {
api.update.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', 409, null, 'pallet-conflict-request'))
const { dialog } = await open('编辑')
await field(dialog, '托盘编码').get('input').setValue(' PLT-EDIT ')
await field(dialog, '备注').get('textarea').setValue(' 修改备注 '); await confirm(dialog)
expect(api.update).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', { palletCode: 'PLT-EDIT', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE', locationId: null, remark: '修改备注', version: 0 })
expect(dialog.text()).toContain('pallet-conflict-request'); expect(dialog.props('modelValue')).toBe(true)
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe(' 修改备注 ')
expect(api.detail).toHaveBeenCalledOnce(); expect(api.create).not.toHaveBeenCalled()
})
it('shows context failures inside creation and locks submission through the original candidate reload', async () => {
api.fba.mockRejectedValueOnce(new ApiResponseError('FBA仓读取失败', 'A0503', 503, null, 'pallet-fba-request'))
const { dialog } = await open()
expect(dialog.text()).toContain('pallet-fba-request'); expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
let resolve!: (value: Array<{ id: string; warehouseName: string; warehouseCode: string }>) => void
api.fba.mockReturnValueOnce(new Promise(done => { resolve = done }))
await dialog.findComponent(LoadErrorState).get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
resolve([{ id: 'F1', warehouseName: 'ABE3仓', warehouseCode: 'ABE3' }]); await flushPromises()
expect(api.fba).toHaveBeenLastCalledWith({ warehouseType: 'FBA', status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenCalledTimes(2)
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); await fill(dialog); await confirm(dialog); expect(api.create).toHaveBeenCalledOnce()
})
it('keeps list-only access read-only and checks current permission again before saving', async () => {
can.mockImplementation(code => code === permission.palletList)
const wrapper = await page()
expect(wrapper.findAll('button').some(button => ['新增托盘', '编辑'].includes(button.text()))).toBe(false)
expect(wrapper.findAll('button').some(button => button.text() === '查看')).toBe(true)
expect(wrapper.findAll('button').some(button => button.text() === '打印')).toBe(true)
can.mockReturnValue(true)
const other = await open(); await fill(other.dialog)
can.mockReturnValue(false); other.dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).not.toHaveBeenCalled(); expect(api.update).not.toHaveBeenCalled()
})
it('resets a closed create form and leaves independent create and edit permissions intact', async () => {
can.mockImplementation(code => code !== permission.palletEdit)
const { wrapper, dialog } = await open(); await fill(dialog)
expect(wrapper.findAll('button').some(button => button.text() === '编辑')).toBe(false)
await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '新增托盘')!.trigger('click'); await flushPromises()
expect((field(dialog, '托盘编码').get('input').element as HTMLInputElement).value).toBe('')
expect(field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(2)
expect(api.create).not.toHaveBeenCalled(); expect(api.detail).not.toHaveBeenCalled()
})
})
@@ -51,6 +51,15 @@ const columns: DataTableColumn[] = [
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const detailLoading = ref(false)
const editReady = ref(false)
const editingPallet = ref<Pick<PalletListItem, 'id' | 'warehouseId' | 'code'> | null>(null)
const dialogError = ref<ApiErrorDetails | null>(null)
const dialogErrorKind = ref<'read' | 'save'>('read')
const dialogBusy = computed(() => saving.value || detailLoading.value || contextLoading.value)
const dialogAllowed = computed(() => access.can(dialogMode.value === 'create' ? permission.palletCreate : permission.palletEdit))
const contextFailed = computed(() => Boolean(loadError.value && loadErrorKind.value === 'context'))
const formLocked = computed(() => dialogBusy.value || !dialogAllowed.value || contextFailed.value || (dialogMode.value === 'edit' && !editReady.value))
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 })
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
@@ -96,27 +105,40 @@ function retryLoad() {
}
function openCreate() {
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
if (dialogOpen.value || dialogBusy.value || !access.can(permission.palletCreate)) return
dialogMode.value = 'create'; editingPallet.value = null; editReady.value = false; dialogError.value = null
Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
}
async function openEdit(source: object) {
const row = source as PalletListItem; saving.value = true; mutationError.value = null
if (dialogOpen.value || dialogBusy.value || !access.can(permission.palletEdit)) return
const row = source as PalletListItem
editingPallet.value = { id: row.id, warehouseId: row.warehouseId, code: row.code }
dialogMode.value = 'edit'; editReady.value = false; dialogError.value = null; mutationError.value = null; dialogOpen.value = true
await loadEdit()
}
async function loadEdit() {
const row = editingPallet.value
if (!dialogOpen.value || dialogMode.value !== 'edit' || !row || dialogBusy.value || !dialogAllowed.value) return
detailLoading.value = true; editReady.value = false; dialogError.value = null; dialogErrorKind.value = 'read'
try {
const detail = await getPallet(row.warehouseId, row.id)
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); dialogOpen.value = true
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
finally { saving.value = false }
Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); editReady.value = true
} catch (error) { dialogError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
finally { detailLoading.value = false }
}
async function save() {
if (!dialogOpen.value || formLocked.value || !access.can(dialogMode.value === 'create' ? permission.palletCreate : permission.palletEdit)) return
if (!form.warehouseId || !form.code.trim() || !form.type || !form.fbaWarehouseCode || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写仓库、托盘编码、类型、容积、采购价格、所属 FBA 仓和状态'); return }
saving.value = true; mutationError.value = null
saving.value = true; dialogError.value = null; dialogErrorKind.value = 'save'
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
try {
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, { ...payload, version: form.version })
else await createPallet(form.warehouseId, payload)
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
} catch (error) { dialogError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
finally { saving.value = false }
}
@@ -154,30 +176,35 @@ onMounted(initialize)
<template #notices><template v-if="loadError"><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" @click="openEdit(row)">编辑</el-button></div></template>
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" :disabled="dialogOpen || dialogBusy" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" :disabled="dialogOpen || dialogBusy" @click="openEdit(row)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<el-form class="density-form" label-position="top"><el-row :gutter="16">
<el-col :span="12"><el-form-item label="托盘编码" required><el-input v-model="form.code" maxlength="128" placeholder="请输入托盘编码" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type" placeholder="请选择托盘类型"><el-option v-for="value in ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(value)" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入容积" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" placeholder="请输入采购价格" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouseId" filterable :disabled="dialogMode === 'edit'" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouseCode" filterable placeholder="请选择所属 FBA 仓"><el-option v-for="item in fbaWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="item.warehouseCode" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status" placeholder="请选择托盘状态"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></el-col>
</el-row></el-form>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : `编辑托盘 · ${text(editingPallet?.code)}`" width="min(760px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="detailLoading || contextLoading" :confirm-loading="dialogBusy" :confirm-disabled="formLocked" @confirm="save">
<LoadErrorState v-if="contextFailed && loadError" title="托盘候选加载失败" :message="loadError.message" :loading="contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" :title="dialogErrorKind === 'read' ? '托盘详情加载失败' : '托盘保存失败'" :message="dialogError.message" :show-retry="dialogErrorKind === 'read'" :loading="detailLoading" @retry="loadEdit"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<p v-if="detailLoading" class="density-dialog-note" role="status">正在读取托盘资料…</p>
<el-form v-if="dialogMode === 'create' || editReady" class="density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="formLocked">
<section class="density-dialog-section" data-tone="work" aria-label="托盘基础资料">
<h3 class="density-dialog-section__title">托盘资料</h3>
<table class="density-table density-table--editable pallet-editor-table"><tbody>
<tr><td><el-form-item label="托盘编码" required><el-input v-model="form.code" maxlength="128" placeholder="请输入托盘编码" /></el-form-item></td><td><el-form-item label="托盘类型" required><el-select v-model="form.type" placeholder="请选择托盘类型"><el-option v-for="value in ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(value)" :value="value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入容积" /></el-form-item></td><td><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" placeholder="请输入采购价格" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item v-if="dialogMode === 'create'" label="仓库" required><el-select v-model="form.warehouseId" filterable placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item><el-form-item v-else label="仓库"><span>{{ warehouseName(form.warehouseId) }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouseCode" filterable placeholder="请选择所属 FBA 仓"><el-option v-for="item in fbaWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="item.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="状态" required><el-select v-model="form.status" placeholder="请选择托盘状态"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.summary-grid span { color:var(--cc-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number) { width: 100%; }
.pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; }
.summary-grid { display: flex; gap: 0; flex-wrap: wrap; border: 1px solid var(--cc-border); background: var(--admin-surface); }
.summary-grid article { display: flex; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 0; border-radius: 0; background:#fff; flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--cc-border); }
@@ -0,0 +1,167 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import RelabelOrdersView from './RelabelOrdersView.vue'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { RelabelDetailDto } from '../api/relabelOrders'
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), create: vi.fn(), status: vi.fn(), customers: vi.fn(), warehouses: vi.fn(), pdf: vi.fn(), upload: vi.fn(), documents: vi.fn(), complete: vi.fn(), task: vi.fn(), rework: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('../api/relabelOrders', () => ({ listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function detail(): RelabelDetailDto { return { application: { id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', status: 'PENDING_REVIEW', chargeAmount: 20, totalPackageCount: 1, totalFileId: '9007199254740993', totalFileName: '总换标.pdf' }, items: [{ id: '9007199254740994', skuCode: 'SKU-A', productNameZh: '商品甲', shipmentNo: 'SHIP-1', labelRequirement: '换SKU标', relabelFileId: '9007199254740995', relabelFileName: '明细换标.pdf', destinationFbaWarehouseCode: null, destinationWarehouseId: '9007199254740999', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }], total: 1 })
api.detail.mockImplementation(async () => detail()); api.create.mockImplementation(async () => detail()); api.status.mockResolvedValue(null)
api.customers.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 })
api.warehouses.mockResolvedValue({ records: [{ id: 'W1', warehouseCode: 'W-1', warehouseName: '海外收货仓', status: 'ACTIVE' }] })
api.pdf.mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); api.upload.mockImplementation(async () => detail())
api.complete.mockResolvedValue(null); api.task.mockResolvedValue({ version: 0 }); api.rework.mockResolvedValue(null)
URL.createObjectURL = vi.fn().mockReturnValue('blob:relabel-pdf'); URL.revokeObjectURL = vi.fn()
})
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function openForm(action = '新增换标单') { const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function fill(dialog: VueWrapper) {
for (const [label, value] of [['关联订单', 'CO-1'], ['商品SKU', 'SKU-1'], ['中文品名', '商品一'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1'], ['新SKU', 'NEW-1']]) await field(dialog, label!).get('input').setValue(value!)
for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1'], ['贴标要求', '换SKU标']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(dialog, '箱数').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
field(dialog, '换标费用(CNY)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
field(dialog, '截止时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00'); await flushPromises()
}
async function openDetail() { const wrapper = mount(RelabelOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function chooseFile(input: ReturnType<VueWrapper['get']>, file: File) { Object.defineProperty(input.element, 'files', { value: [file], configurable: true }); await input.trigger('change'); await flushPromises() }
describe('native relabel operation dialogs', () => {
it('locks candidate loading and retains all fields through fullscreen creation', async () => {
let resolve!: (value: { records: Array<{ id: string; tenantName: string; status: string }>; pages: number }) => void
api.customers.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { dialog } = await openForm()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(field(dialog, '关联订单').get('input').attributes('disabled')).toBeDefined()
resolve({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 }); await flushPromises()
expect(dialog.get('[aria-label="换标单生成方式"]').findAll('input')).toHaveLength(0)
await fill(dialog); await field(dialog, '备注').get('textarea').setValue(' 本次备注 ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), items: [{ skuCode: 'SKU-1', productNameZh: '商品一', totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: 'SHIP-1', newSkuCode: 'NEW-1', labelRequirement: '换SKU标' }] })
})
it('prevents duplicate creation and retains inputs and the original creation key after failure', async () => {
let reject!: (cause: Error) => void
api.create.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
const { dialog } = await openForm(); await fill(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '商品SKU').get('input').attributes('disabled')).toBeDefined()
const key = api.create.mock.calls[0]![1].idempotencyKey
reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'relabel-create-request')); await flushPromises()
expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '商品SKU').get('input').element as HTMLInputElement).value).toBe('SKU-1')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![1].idempotencyKey).toBe(key)
})
it('shows every original SKU as plain facts and requires a rejection reason', async () => {
const { dialog } = await openForm('审核')
expect(dialog.props('title')).toBe('审核换标单 · RL-1')
const facts = dialog.get('[aria-label="换标审核原始资料"]')
expect(facts.findAll('input, textarea, select')).toHaveLength(0)
expect(facts.text()).toContain('SKU-A、SKU-B'); expect(facts.text()).toContain('NEW-A × 10 件;NEW-B × 5 件')
const action = field(dialog, '审核动作').getComponent({ name: 'ElSelect' })
expect(action.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['PENDING_PROCESSING', 'REJECTED'])
action.vm.$emit('update:modelValue', 'REJECTED'); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.status).not.toHaveBeenCalled()
await field(dialog, '备注').get('textarea').setValue('驳回原因'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'REJECTED', '驳回原因')
})
it('shows independent resource failures and reloads each original candidate source', async () => {
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'customer-request'))
api.warehouses.mockRejectedValueOnce(new ApiResponseError('仓库失败', 'A0503', 503, null, 'warehouse-request'))
const { dialog } = await openForm(); expect(dialog.text()).toContain('customer-request'); expect(dialog.text()).toContain('warehouse-request')
expect(dialog.props('confirmDisabled')).toBe(true); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
dialog.findAllComponents(LoadErrorState).find(item => item.props('title') === '客户候选读取失败')!.vm.$emit('retry'); await flushPromises()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenLastCalledWith()
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false)
})
it.each(['作废', '确认换标完成', '退回重新贴标'] as const)('keeps the original %s confirmation, business reference and command', async action => {
const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] }
api.detail.mockResolvedValueOnce(review)
const { wrapper } = await openDetail()
await wrapper.findAll('button').find(button => button.text().includes(action))!.trigger('click'); await flushPromises()
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)
expect(element).toBeDefined(); const box = new DOMWrapper(element!)
expect(box.text()).toContain('RL-1'); expect(element!.classList.contains('admin-density-confirm')).toBe(true)
if (action === '退回重新贴标') await box.get('textarea').setValue(' 需要重新贴标 ')
await box.get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
if (action === '作废') expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'CANCELLED', '平台作废换标单')
else if (action === '确认换标完成') expect(api.complete).toHaveBeenCalledExactlyOnceWith('RL-1')
else expect(api.rework).toHaveBeenCalledExactlyOnceWith('RL-1', 0, '需要重新贴标', expect.any(String))
})
it('locks the competing review action until completion confirmation finishes', async () => {
const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] }
api.detail.mockResolvedValueOnce(review)
let resolve!: (value: null) => void
api.complete.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = await openDetail()
await wrapper.findAll('button').find(button => button.text().includes('确认换标完成'))!.trigger('click'); await flushPromises()
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!
await new DOMWrapper(element).get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
const rework = wrapper.findAll('button').find(button => button.text().includes('退回重新贴标'))!
expect(rework.attributes('disabled')).toBeDefined(); await rework.trigger('click'); await flushPromises()
expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).not.toHaveBeenCalled()
resolve(null); await flushPromises()
})
it('keeps readonly file previews available while hiding upload and internal warehouse identifiers', async () => {
can.mockImplementation(code => code.endsWith(':list'))
const { wrapper, dialog } = await openDetail()
expect(wrapper.findAll('input[type="file"]')).toHaveLength(0); expect(wrapper.text()).not.toContain('9007199254740999')
expect(wrapper.get('.relabel-mark-list table th').text()).toBe('操作(明细PDF)')
await wrapper.findAll('button').find(button => button.text().includes('直接预览'))!.trigger('click'); await flushPromises()
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined)
expect(dialog.props('fullscreenable')).toBe(true); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
dialog.vm.$emit('update:modelValue', false); await flushPromises(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:relabel-pdf')
await wrapper.get('.relabel-mark-list').findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740995', 'ITEM', '9007199254740994')
})
it('discards closed preview responses and retries failed reads with the same file scope', async () => {
let resolve!: (value: Blob) => void
api.pdf.mockReturnValueOnce(new Promise(done => { resolve = done })).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'pdf-request'))
const { wrapper, dialog } = await openDetail(); const button = wrapper.findAll('button').find(item => item.text().includes('直接预览'))!
await button.trigger('click'); await flushPromises(); expect(dialog.props('loading')).toBe(true); expect(dialog.find('iframe').exists()).toBe(false)
dialog.vm.$emit('update:modelValue', false); await flushPromises(); resolve(new Blob(['late'], { type: 'application/pdf' })); await flushPromises()
expect(URL.createObjectURL).not.toHaveBeenCalled()
await button.trigger('click'); await flushPromises(); expect(dialog.text()).toContain('pdf-request')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf')
})
it('uploads total and item PDFs through their original scope and blocks duplicate uploads', async () => {
const withoutItemFile = detail(); withoutItemFile.items[0]!.relabelFileId = null; withoutItemFile.items[0]!.relabelFileName = null
api.detail.mockResolvedValueOnce(withoutItemFile)
let resolve!: (value: RelabelDetailDto) => void
api.upload.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(withoutItemFile)
const { wrapper } = await openDetail(); const file = new File(['pdf'], 'new.pdf', { type: 'application/pdf' })
const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]')
await chooseFile(input, file); expect(input.attributes('disabled')).toBeDefined()
await input.trigger('change'); await flushPromises(); expect(api.upload).toHaveBeenCalledExactlyOnceWith('RL-1', file, 'TOTAL', undefined)
resolve(withoutItemFile); await flushPromises()
await chooseFile(wrapper.get('.relabel-mark-list input[type="file"]'), file)
expect(api.upload).toHaveBeenLastCalledWith('RL-1', file, 'ITEM', '9007199254740994')
})
it('preserves PDF file checks and shows inline upload diagnostics without changing the current details', async () => {
api.upload.mockRejectedValueOnce(new ApiResponseError('上传失败', 'A0503', 503, null, 'upload-request'))
const { wrapper } = await openDetail(); const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]')
await chooseFile(input, new File(['png'], 'bad.png', { type: 'image/png' })); expect(api.upload).not.toHaveBeenCalled()
const tooBig = new File(['pdf'], 'large.pdf', { type: 'application/pdf' }); Object.defineProperty(tooBig, 'size', { value: 50 * 1024 * 1024 + 1 })
await chooseFile(input, tooBig); expect(api.upload).not.toHaveBeenCalled()
await chooseFile(input, new File(['pdf'], 'new.pdf', { type: 'application/pdf' }))
expect(wrapper.text()).toContain('upload-request'); expect(wrapper.text()).toContain('总换标.pdf'); expect(input.attributes('disabled')).toBeUndefined()
})
})
@@ -17,7 +17,8 @@ describe('RelabelOrderDetailView', () => {
it('shows the review state and customer-facing return order and FBA warehouse codes', () => {
expect(source).toContain("PENDING_REVIEW: '待审核'")
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
expect(source).toContain('text(item.destinationFbaWarehouseCode)')
expect(source).not.toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
expect(source).toContain('items.flatMap(relabelSkuCodes)')
expect(source).toContain('v-for="(sku, index) in item.skuItems"')
expect(source).toContain('{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件')
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, onBeforeUnmount } from 'vue'
import { computed, onActivated, onMounted, ref, onBeforeUnmount, watch } from 'vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,6 +14,7 @@ import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, up
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
@@ -31,28 +32,28 @@ async function voidOrder() {
if (voiding.value || confirming.value || returning.value || !canVoid.value || !access.can(permission.relabelOrderStatus)) return
voiding.value = true
try {
await ElMessageBox.confirm('确认作废当前换标单?作废后停止换标作业,保留原单和操作记录。', '作废换标单', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认作废?作废后停止换标作业,保留原单和操作记录。`, '作废换标单', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await changeRelabelOrderStatus(orderId.value, 'CANCELLED', '平台作废换标单')
ElMessage.success('换标单已作废'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标单作废失败,请重试').message) }
finally { voiding.value = false }
}
async function confirmCompletion() {
if (confirming.value || voiding.value) return
if (confirming.value || returning.value || voiding.value) return
confirming.value = true
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认仓库上传的换标结果无误并继续后续流程吗?`, '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(orderId.value)
ElMessage.success('已确认换标完成,订单已进入后续流程'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败,请刷新后重试').message) }
finally { confirming.value = false }
}
async function returnForRework() {
if (returning.value || voiding.value) return
if (returning.value || confirming.value || voiding.value) return
returning.value = true
try {
const task = await getRelabelReviewTask(orderId.value)
const { value } = await ElMessageBox.prompt('填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。', '退回重新贴标', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
const { value } = await ElMessageBox.prompt(`换标单 ${detail.value?.application.relabelNo || orderId.value}:填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。`, '退回重新贴标', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
await returnRelabelForRework(orderId.value, task.version, value.trim(), crypto.randomUUID())
ElMessage.success('已退回 PDA 重新贴标'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '退回失败,请刷新后重试').message) }
@@ -63,6 +64,16 @@ const uploading = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
const previewOpen = ref(false)
const previewLoading = ref(false), previewError = ref<ApiErrorDetails | null>(null), uploadError = ref<ApiErrorDetails | null>(null)
const previewRequest = ref<{ no: string; fileId: string; name: string; scope: 'TOTAL' | 'ITEM'; itemId?: string } | null>(null)
let previewGeneration = 0
function resetPreview() {
previewGeneration++; previewLoading.value = false; previewError.value = null; previewRequest.value = null
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
watch(previewOpen, visible => { if (!visible) resetPreview() })
watch(orderId, () => { previewOpen.value = false; resetPreview() })
const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
@@ -115,21 +126,30 @@ async function load() {
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
onActivated(() => { if (!loading.value) void load() })
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
onBeforeUnmount(resetPreview)
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
if (!file || !detail.value || uploading.value || !access.can(permission.relabelOrderCreate)) return
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { ElMessage.warning('只支持PDF文件'); return }
if (file.size > 50 * 1024 * 1024) { ElMessage.warning('PDF文件不能超过50MB'); return }
uploading.value = true
try { detail.value = await uploadRelabelPdf(orderId.value, file, scope, itemId); ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF上传失败').message) }
uploading.value = true; uploadError.value = null
const no = orderId.value
try { const result = await uploadRelabelPdf(no, file, scope, itemId); if (no === orderId.value) { detail.value = result; ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') } }
catch (error) { if (no === orderId.value) uploadError.value = getApiErrorDetails(error, 'PDF上传失败') }
finally { uploading.value = false }
}
async function previewPdf(fileId: string | null | undefined, name: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
if (!fileId) return
try { const blob = await downloadRelabelPdf(orderId.value, fileId, scope, itemId); if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = name; previewOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF预览加载失败').message) }
const sequence = ++previewGeneration, no = orderId.value
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''; previewTitle.value = name; previewRequest.value = { no, fileId, name, scope, itemId }
previewOpen.value = true; previewLoading.value = true; previewError.value = null
try {
const blob = await downloadRelabelPdf(no, fileId, scope, itemId)
if (sequence !== previewGeneration || no !== orderId.value || !previewOpen.value) return
previewUrl.value = URL.createObjectURL(blob)
} catch (error) { if (sequence === previewGeneration && no === orderId.value && previewOpen.value) previewError.value = getApiErrorDetails(error, 'PDF预览加载失败') }
finally { if (sequence === previewGeneration) previewLoading.value = false }
}
async function previewCompletionPhoto(fileId: string) {
try { const blob = await downloadRelabelCompletionPhoto(orderId.value, fileId); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
@@ -142,16 +162,17 @@ async function previewCompletionPhoto(fileId: string) {
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="access.can(permission.relabelOrderStatus) && canVoid" class="relabel-document-panel relabel-review-actions"><el-button type="danger" :loading="voiding" :disabled="confirming || returning" @click="voidOrder">作废</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" :disabled="returning || voiding" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" :disabled="confirming || voiding" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<LoadErrorState v-if="uploadError" title="换标PDF操作失败" :message="uploadError.message" :show-retry="false"><template #details><ApiErrorMeta :details="uploadError" /></template></LoadErrorState>
<RelabelDocumentsPanel v-if="detail" :relabel-no="detail.application.relabelNo" :detail="detail" /><section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label v-if="access.can(permission.relabelOrderCreate)" class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
<div class="relabel-mark-list__scroll">
<table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<thead><tr><th class="relabel-pdf-actions">操作(明细PDF)</th><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-for="item in detail?.items || []" :key="item.id"><td><span v-if="item.relabelFileName" class="document-actions density-actions"><LineClampText :content="item.relabelFileName" /><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else-if="access.can(permission.relabelOrderCreate)" class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label><span v-else>—</span></td><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode) }}</td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
@@ -159,7 +180,7 @@ async function previewCompletionPhoto(fileId: string) {
</section>
</template>
</OverseasReadOnlyDetail>
<AppDialog class="admin-density-dialog" v-if="previewUrl" v-model="previewOpen" :title="previewTitle" width="min(900px, calc(100vw - 28px))" :show-footer="false"><div class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="(previewRequest?.no || orderId) + ' · ' + previewTitle" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-footer="false"><div class="density-dialog-sections"><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取换标PDF…</p><LoadErrorState v-if="previewError" title="换标PDF预览失败" :message="previewError.message" :loading="previewLoading" @retry="previewRequest && previewPdf(previewRequest.fileId, previewRequest.name, previewRequest.scope, previewRequest.itemId)"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div v-if="previewUrl" class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></div></AppDialog>
</template>
<style scoped>
@@ -175,8 +196,10 @@ table { min-width: 1120px; }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-5); border:1px solid var(--cc-border); border-radius:var(--admin-radius); }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:var(--admin-space-2); color:var(--el-color-primary); cursor:pointer; }
.file-picker input, .inline-file-picker input { display:none; }
.pdf-preview { overflow:hidden; border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:#f5f7fa; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; background:#fff; }
.pdf-preview { overflow: hidden; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; }
.relabel-pdf-actions { width: 220px; }
.document-actions > .line-clamp-text { max-width: 160px; }
.empty-cell { height: var(--admin-empty-height); color: var(--cc-muted); text-align: center; }
.document-row strong { min-width: 0; overflow-wrap: anywhere; }
@@ -50,9 +50,11 @@ describe('RelabelOrdersView', () => {
expect(wrapper.text()).toContain('SKU-A,SKU-B')
await wrapper.findAll('button').find(button => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-A、SKU-B')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-A × 10 件;NEW-B × 5 件')
expect(formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
const facts = wrapper.get('[aria-label="换标审核原始资料"]')
expect(facts.findAll('input, textarea')).toHaveLength(0)
expect(facts.text()).toContain('SKU-A、SKU-B')
expect(facts.text()).toContain('NEW-A × 10 件;NEW-B × 5 件')
expect(facts.findAll('tr').find(row => row.text().startsWith('箱数'))!.get('td').text()).toBe('1')
})
it('renders product names and labeling requirements supplied by the list projection', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-1', productNameZh: '商品甲、商品乙', labelRequirement: '贴标要求111', status: 'PENDING_PROCESSING' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -79,7 +81,7 @@ describe('RelabelOrdersView', () => {
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
await flushPromises()
const customer = select(wrapper, '请选择 ACTIVE 客户')
const customer = select(wrapper, '请选择客户')
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
await flushPromises()
@@ -96,10 +98,10 @@ describe('RelabelOrdersView', () => {
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
await flushPromises()
const customer = select(wrapper, '请选择 ACTIVE 客户')
const customer = select(wrapper, '请选择客户')
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
await flushPromises()
const warehouse = select(wrapper, '请选择 ACTIVE+RECEIVING 海外仓')
const warehouse = select(wrapper, '请选择公共海外收货仓')
warehouse.vm.$emit('update:modelValue', 'WH-1')
formItem(wrapper, '关联订单').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'CO-1')
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
@@ -193,10 +195,11 @@ describe('RelabelOrdersView', () => {
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-NEW')
expect(formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('中文商品')
expect(formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SHIP-9')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-SKU-9')
const facts = wrapper.get('[aria-label="换标审核原始资料"]')
expect(facts.text()).toContain('SKU-NEW')
expect(facts.text()).toContain('中文商品')
expect(facts.text()).toContain('SHIP-9')
expect(facts.text()).toContain('NEW-SKU-9')
})
it('passes the relabel order number search to the backend', async () => {
@@ -61,6 +61,8 @@ const detailLoading = ref(false)
const editSourceStatus = ref('')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createIdempotencyKey = ref('')
const dialogError = ref<ApiErrorDetails | null>(null), customersError = ref<ApiErrorDetails | null>(null), warehousesError = ref<ApiErrorDetails | null>(null)
const formLocked = computed(() => submitting.value || detailLoading.value || dialogMode.value === 'create' && (customersLoading.value || warehousesLoading.value) || !access.can(dialogMode.value === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus))
const selectedRows = ref<RelabelOrderListItem[]>([])
const allowedStatusOptions = computed(() => {
if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED']
@@ -78,6 +80,8 @@ function openPackingPlan() {
}
async function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelOrderListItem | undefined
if (dialogOpen.value || submitting.value || detailLoading.value || !access.can(mode === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus) || mode === 'edit' && (!row || !canAudit(row))) return
dialogError.value = null; customersError.value = null; warehousesError.value = null
dialogMode.value = mode
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
editSourceStatus.value = ''
@@ -116,7 +120,7 @@ async function openForm(mode: 'create' | 'edit', source?: object) {
}
async function loadCustomers() {
customersLoading.value = true
customersError.value = null; customersLoading.value = true
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
@@ -128,11 +132,12 @@ async function loadCustomers() {
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) { customers.value = []; ElMessage.error(getApiErrorDetails(error, '客户列表加载失败,请重试').message) }
} catch (error) { customers.value = []; customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
finally { customersLoading.value = false }
}
async function loadWarehouses() {
warehousesError.value = null
form.warehouseId = ''
warehouses.value = []
warehousesLoading.value = true
@@ -140,14 +145,15 @@ async function loadWarehouses() {
const records = (await listRelabelWarehouseOptions()).records.filter((item) => item.status === 'ACTIVE')
warehouses.value = records
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '公共海外收货仓加载失败,请重试').message)
warehousesError.value = getApiErrorDetails(error, '公共海外收货仓加载失败,请重试')
} finally {
warehousesLoading.value = false
}
}
async function submitForm() {
if (submitting.value) return
if (!dialogOpen.value || formLocked.value || dialogMode.value === 'create' && (customersError.value || warehousesError.value)) return
dialogError.value = null
try {
if (dialogMode.value === 'create') {
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请选择客户和公共海外收货仓'); return }
@@ -168,7 +174,7 @@ async function submitForm() {
dialogOpen.value = false
createIdempotencyKey.value = ''
await load()
} catch (error) { ElMessage.error(getApiErrorDetails(error, '操作失败,请重试').message) }
} catch (error) { dialogError.value = getApiErrorDetails(error, '操作失败,请重试') }
finally { submitting.value = false }
}
@@ -255,42 +261,34 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading" :confirm-disabled="detailLoading" @confirm="submitForm">
<el-form class="density-form" label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择 ACTIVE+RECEIVING 海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></el-col>
</el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :disabled="dialogMode === 'edit'" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-col>
</el-row></el-form>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单 · ' + (form.orderNo || '—')" width="min(760px, calc(100vw - 28px))" fullscreenable :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading || dialogMode === 'create' && (customersLoading || warehousesLoading)" :confirm-disabled="formLocked || dialogMode === 'create' && Boolean(customersError || warehousesError)" @confirm="submitForm">
<div class="density-dialog-sections">
<LoadErrorState v-if="dialogMode === 'create' && customersError" compact title="客户候选读取失败" :message="customersError.message" :loading="customersLoading || submitting" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogMode === 'create' && warehousesError" compact title="公共海外收货仓读取失败" :message="warehousesError.message" :loading="warehousesLoading || submitting" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehousesError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" compact title="换标操作未完成" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<el-form class="density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="formLocked">
<template v-if="dialogMode === 'create'">
<section class="density-dialog-section" data-tone="summary" aria-label="换标单生成方式"><table class="density-table"><tbody><tr><th scope="row">换标单号</th><td>系统自动生成</td></tr></tbody></table></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 relabel-editor-table"><tbody><tr><td><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></td><td><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择公共海外收货仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></td></tr><tr><td><el-form-item label="商品SKU" required><el-input v-model="form.sku" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入商品SKU" /></el-form-item></td><td><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></td></tr><tr><td><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></td><td><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></tbody></table></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 relabel-editor-table"><tbody><tr><td><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></td><td><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" placeholder="请输入货件号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入新SKU" /></el-form-item></td></tr></tbody></table></section>
</template>
<template v-else>
<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 colspan="3">{{ form.relatedOrderNo || '—' }}</td></tr><tr><th scope="row">商品SKU</th><td colspan="3">{{ form.sku || '—' }}</td></tr><tr><th scope="row">中文品名</th><td colspan="3">{{ form.productName || '—' }}</td></tr>
<tr><th scope="row">箱数</th><td>{{ form.cartons ?? '—' }}</td><th scope="row">换标费用(CNY)</th><td>{{ form.relabelCost ?? '—' }}</td></tr>
<tr><th scope="row">贴标要求</th><td colspan="3">{{ form.labelRequirement || '—' }}</td></tr><tr><th scope="row">货件号</th><td colspan="3">{{ form.shipmentNo || '—' }}</td></tr><tr><th scope="row">新SKU</th><td colspan="3">{{ form.newSkuCode || '—' }}</td></tr>
</tbody></table></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 relabel-editor-table"><tbody><tr><td colspan="2"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></td></tr></tbody></table></section>
</template>
<section class="density-dialog-section" data-tone="work" aria-label="换标备注与原因"><h3 class="density-dialog-section__title">{{ dialogMode === 'edit' ? '审核备注' : '备注' }}</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr></tbody></table><p v-if="dialogMode === 'edit'" class="density-dialog-note">审核驳回时必须填写原因。</p></section>
</el-form>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
h3 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
h3:not(:first-child) { margin-top: var(--admin-space-1); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
.packing-plan-dialog { display: grid; gap: 14px; }
.packing-plan-summary { display: flex; gap: 24px; color: var(--cc-text-secondary); font-size: var(--admin-font-body); }
.packing-mode { margin-bottom: 2px; }
.packing-box { border: 1px solid var(--cc-border); border-radius: 6px; padding: 14px; display: grid; gap: 10px; }
.packing-box-head, .packing-item, .packing-dimensions { display: flex; align-items: center; gap: 10px; }
.packing-box-head { justify-content: space-between; }
.packing-item .el-input { flex: 1; }
.packing-item .el-input-number { width: 160px; }
.packing-dimensions .el-input-number { width: 150px; }
.relabel-editor-table { table-layout: fixed; }
.relabel-editor-table td:not([colspan]) { width: 50%; }
@media (max-width: 720px) { .relabel-editor-table, .relabel-editor-table tbody { display: block; }.relabel-editor-table tr { display: grid; grid-template-columns: 1fr; }.relabel-editor-table td:not([colspan]) { width: auto; } }
</style>
@@ -0,0 +1,179 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import type { ReturnCartonDetail, ReturnCartonLabel } from '../api/returnInventory'
import type { ReturnPackingPlanDto } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
const api = vi.hoisted(() => ({ customers: vi.fn(), warehouses: vi.fn(), stock: vi.fn(), cartons: vi.fn(), movements: vi.fn(), sources: vi.fn(), carton: vi.fn(), label: vi.fn(), plans: vi.fn(), create: vi.fn(), review: vi.fn(), cancel: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
const route = vi.hoisted(() => ({ name: 'overseas-return-inventory', query: {} as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => route }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers, listSharedReceivingOverseasWarehouseOptions: api.warehouses }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, listReturnInventoryMovements: api.movements, listReturnPackingSources: api.sources, getReturnCarton: api.carton, getReturnCartonLabel: api.label }))
vi.mock('../api/relabelOrders', () => ({ listReturnPackingPlans: api.plans, createReturnPackingPlan: api.create, reviewReturnPackingPlan: api.review, cancelReturnPackingPlan: api.cancel }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
function carton(overrides: Partial<ReturnCartonDetail> = {}): ReturnCartonDetail { return { customerId: 'C1', overseasWarehouseId: 'W1', cartonMark: 'BOX-A', status: 'AVAILABLE', skuTypes: 1, totalQuantity: 5, createdAt: '2026-10-20T09:00:00', sourceReturnNo: 'RETURN-1', fbaCartonMark: 'FBA-A', destinationWarehouseCode: 'ABE3', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', relabelledQuantity: 3, relabelReservedQuantity: 1, length: 30, width: 20, height: 10, weight: 2, items: [{ skuCode: 'SKU-A', productNameZh: '完整中文品名'.repeat(10), quantity: 5 }], ...overrides } }
function label(mark = 'BOX-A'): ReturnCartonLabel { return { ...carton({ cartonMark: mark }), printableHtml: '<html><body>服务端原标签</body></html>' } }
function plan(overrides: Partial<ReturnPackingPlanDto> = {}): ReturnPackingPlanDto { return { planNo: 'PLAN-001', customerId: 'C1', overseasWarehouseId: 'W1', returnNos: ['RETURN-1'], mode: 'SKU', status: 'PENDING_REVIEW', boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 2 }] }], idempotencyKey: 'saved-plan', sources: [{ cartonMark: 'BOX-A', sourceReturnNo: 'RETURN-1', participation: 'PARTIAL', originalItems: [{ skuCode: 'SKU-A', quantity: 5 }], items: [{ skuCode: 'SKU-A', quantity: 2 }] }], ...overrides } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true); route.name = 'overseas-return-inventory'; Object.keys(route.query).forEach(key => delete route.query[key])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.customers.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户一', status: 'ACTIVE' }], pages: 1 })
api.warehouses.mockResolvedValue([{ id: 'W1', warehouseName: '海外仓一' }])
api.stock.mockResolvedValue({ records: [], total: 0 }); api.movements.mockResolvedValue({ records: [], total: 0 })
api.cartons.mockResolvedValue({ records: [carton()], total: 1 }); api.carton.mockImplementation(async () => carton())
api.label.mockImplementation(async mark => label(mark))
api.sources.mockResolvedValue({ records: [{ cartonMark: 'BOX-A', sourceReturnNo: 'RETURN-1', sourceUnitType: 'BOX', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 4 }] }], total: 1 })
api.plans.mockResolvedValue({ records: [plan()], total: 1 }); api.create.mockResolvedValue(plan()); api.review.mockResolvedValue(plan()); api.cancel.mockResolvedValue(plan())
})
async function page(mode: 'inventory' | 'cartons' = 'inventory') { route.name = mode === 'inventory' ? 'overseas-return-inventory' : 'overseas-return-cartons'; const wrapper = mount(ReturnInventoryManagementView, { props: { mode }, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function row(wrapper: VueWrapper, businessNo: string) { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(businessNo)).at(-1)! }
function dialog(wrapper: VueWrapper, prefix: string) { return wrapper.findAllComponents(AppDialog).find(item => String(item.props('title')).startsWith(prefix) && item.props('modelValue'))! }
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, name: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === name)! }
async function openCreate() { const wrapper = await page(); await wrapper.findAll('button').find(item => item.text() === '创建合箱计划')!.trigger('click'); await flushPromises(); return { wrapper, form: dialog(wrapper, '创建退货合箱计划') } }
async function chooseScope(form: VueWrapper) { for (const [name, value] of [['客户', 'C1'], ['海外仓', 'W1']]) { const select = field(form, name!).getComponent({ name: 'ElSelect' }); if (select.props('modelValue') === value) continue; select.vm.$emit('update:modelValue', value); select.vm.$emit('change', value); await flushPromises() } }
async function selectBox(form: VueWrapper) { form.get('.packing-box-list-row').getComponent({ name: 'ElCheckboxGroup' }).vm.$emit('update:modelValue', ['BOX-A']); await flushPromises() }
async function openPlan() { const wrapper = await page(); const tabs = wrapper.findComponent({ name: 'ElTabs' }); tabs.vm.$emit('update:modelValue', 'plans'); tabs.vm.$emit('tab-change', 'plans'); await flushPromises(); return wrapper }
async function clickPlan(wrapper: VueWrapper, action = '详情') { await row(wrapper, 'PLAN-001').findAll('button').find(item => item.text() === action)!.trigger('click'); await flushPromises() }
function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!) }
function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: string) { return wrapper.findAllComponents(LoadErrorState).find(item => item.props('title') === title)! }
describe('return inventory operation dialogs', () => {
it('creates the original whole-box payload through the native fullscreen form', async () => {
const { form } = await openCreate(); await chooseScope(form); await selectBox(form)
await field(form, '合箱备注(可选)').get('textarea').setValue(' 合箱备注 ')
await form.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(form.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
form.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith({ returnNos: ['RETURN-1'], mode: 'BOX', boxes: [{ items: [] }], sourceCartonMarks: ['BOX-A'], sourceDeductions: undefined, remark: '合箱备注', idempotencyKey: expect.any(String) })
expect(api.sources).toHaveBeenLastCalledWith({ page: 1, size: 200, customerId: 'C1', overseasWarehouseId: 'W1' })
})
it('blocks invalid partial quantities and retains each physical carton and available maximum', async () => {
const { form } = await openCreate(); await chooseScope(form)
const mode = form.findComponent({ name: 'ElRadioGroup' }); mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
const source = form.get('.packing-sku-row'); source.getComponent({ name: 'ElCheckbox' }).vm.$emit('change', true); await flushPromises()
const count = source.getComponent({ name: 'ElInputNumber' }); expect(count.props()).toMatchObject({ min: 1, max: 4, precision: 0 })
for (const value of [1.5, 5]) { count.vm.$emit('update:modelValue', value); form.vm.$emit('confirm'); await flushPromises() }
expect(api.create).not.toHaveBeenCalled()
count.vm.$emit('update:modelValue', 2); await flushPromises(); form.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledWith(expect.objectContaining({ mode: 'SKU', boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 2 }] }], sourceCartonMarks: ['BOX-A'], sourceDeductions: [{ cartonMark: 'BOX-A', skuCode: 'SKU-A', quantity: 2 }] }))
})
it('locks creation and closing, keeping the same fields and idempotency key after failure', async () => {
let reject!: (error: Error) => void
api.create.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { form } = await openCreate(); await chooseScope(form); await selectBox(form)
await field(form, '合箱备注(可选)').get('textarea').setValue('失败后保留')
form.vm.$emit('confirm'); form.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(form.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(field(form, '合箱备注(可选)').get('textarea').attributes('disabled')).toBeDefined()
const original = api.create.mock.calls[0]![0]
reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'return-packing-request')); await flushPromises()
expect(form.text()).toContain('return-packing-request'); expect((field(form, '合箱备注(可选)').get('textarea').element as HTMLTextAreaElement).value).toBe('失败后保留')
form.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![0]).toEqual(original)
})
it('shows source diagnostics and retries the original customer and warehouse range', async () => {
api.sources.mockRejectedValueOnce(new ApiResponseError('来源失败', 'A0503', 503, null, 'return-source-request'))
const { form } = await openCreate(); await chooseScope(form)
expect(form.text()).toContain('return-source-request'); expect(form.props('confirmDisabled')).toBe(true)
form.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
await errorState(form, '合箱来源加载失败').get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.sources).toHaveBeenLastCalledWith({ page: 1, size: 200, customerId: 'C1', overseasWarehouseId: 'W1' })
expect(form.find('.packing-box-list-row').exists()).toBe(true)
})
it('stops source pagination when a loading form is closed and ignores the late records', async () => {
let resolve!: (value: { records: Array<{ cartonMark: string; sourceReturnNo: string; items: Array<{ skuCode: string; quantity: number }> }>; total: number }) => void
api.sources.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, form } = await openCreate(); await chooseScope(form)
expect(form.props('confirmDisabled')).toBe(true)
await form.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
resolve({ records: [{ cartonMark: 'LATE-BOX', sourceReturnNo: 'RETURN-LATE', items: [{ skuCode: 'LATE-SKU', quantity: 1 }] }], total: 201 }); await flushPromises()
expect(api.sources).toHaveBeenCalledOnce()
await wrapper.findAll('button').find(item => item.text() === '创建合箱计划')!.trigger('click'); await flushPromises()
expect(dialog(wrapper, '创建退货合箱计划').text()).not.toContain('LATE-BOX')
})
it('keeps completed source, movement and result snapshots, with current-label actions first', async () => {
api.plans.mockResolvedValueOnce({ records: [plan({ status: 'COMPLETED', completedBy: '仓库操作员', completedAt: '2026-10-20T10:00:00', transfers: [{ returnNo: 'RETURN-1', sourceCartonMark: 'BOX-A', targetCartonMark: 'BOX-B', skuCode: 'SKU-A', quantity: 2, createdAt: '2026-10-20T10:00:00' }], completionCartons: [label('BOX-B'), label('OLD-BOX')], resultCartons: [carton({ cartonMark: 'BOX-B' }), carton({ cartonMark: 'OLD-BOX', status: 'MERGED', totalQuantity: 0 })] })], total: 1 })
const wrapper = await openPlan(); await clickPlan(wrapper); const view = dialog(wrapper, '合箱计划详情')
expect(view.get('[aria-label="计划来源箱明细"]').text()).toContain('取出来源'); expect(view.get('[aria-label="计划来源箱明细"]').text()).toContain('预计剩余')
expect(view.get('[aria-label="实际合箱流向"]').text()).toContain('BOX-B')
const results = view.get('[aria-label="合箱结果"]'); expect(results.text()).toContain('完成时快照'); expect(results.text()).toContain('30 × 20 × 10 cm / 2 kg')
expect(results.findAllComponents({ name: 'ElTableColumn' })[0]!.props('label')).toBe('操作')
const oldBox = results.findAll('.el-table__body tr').filter(item => item.text().includes('OLD-BOX')).at(-1)!
expect(oldBox.get('button').attributes('disabled')).toBeDefined(); await oldBox.get('button').trigger('click'); expect(api.label).not.toHaveBeenCalled()
await view.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(view.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true); expect(api.carton).not.toHaveBeenCalled()
})
it('keeps original carton facts, relabel counts and full SKU/merge contents in the native fullscreen detail', async () => {
api.carton.mockResolvedValueOnce(carton({ mergeHistory: [{ planNo: 'PLAN-001', sourceCartonMark: 'BOX-B', targetCartonMark: 'BOX-A', returnNo: 'RETURN-1', skuCode: 'SKU-A', quantity: 2, createdAt: '2026-10-20T10:00:00' }] }))
const wrapper = await page('cartons'); await row(wrapper, 'BOX-A').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises()
const view = dialog(wrapper, '退货箱号详情'); expect(view.props('title')).toBe('退货箱号详情 · BOX-A')
const facts = view.get('[aria-label="退货箱号资料"]')
expect(facts.findAll('th')).toHaveLength(11); expect(facts.findAll('input, select')).toHaveLength(0)
for (const value of ['客户一', '海外仓一', 'FBA-A', 'ABE3', 'RL-1', '实际完成 3 件', '处理中占用 1 件', '30×20×10 cm', '2 kg']) expect(facts.text()).toContain(value)
expect(view.findAllComponents(LineClampText).map(item => item.props('content'))).toContain('完整中文品名'.repeat(10))
expect(view.get('[aria-label="退货合箱明细"]').text()).toContain('合入')
await view.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises(); expect(view.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
})
it('locks carton reading and retries the exact business mark after failure', async () => {
let reject!: (error: Error) => void
api.carton.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const wrapper = await page('cartons'); await row(wrapper, 'BOX-A').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises()
const view = dialog(wrapper, '退货箱号详情'); expect(view.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
reject(new ApiResponseError('箱号读取失败', 'A0503', 503, null, 'return-carton-request')); await flushPromises()
expect(view.text()).toContain('return-carton-request'); await errorState(view, '箱号详情加载失败').get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.carton.mock.calls).toEqual([['BOX-A'], ['BOX-A']]); expect(view.text()).toContain('FBA-A')
})
it('keeps HTML printing on the original server label and isolates the popup opener', async () => {
const popup = { opener: {}, document: { open: vi.fn(), write: vi.fn(), close: vi.fn() }, focus: vi.fn(), print: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
const wrapper = await page('cartons'); await row(wrapper, 'BOX-A').findAll('button').find(item => item.text() === '箱唛')!.trigger('click'); await flushPromises()
expect(api.label).toHaveBeenCalledExactlyOnceWith('BOX-A'); expect(window.open).toHaveBeenCalledWith('', '_blank')
expect(popup.opener).toBeNull(); expect(popup.document.write).toHaveBeenCalledWith(label().printableHtml); expect(popup.document.open).toHaveBeenCalledOnce(); expect(popup.document.close).toHaveBeenCalledOnce(); expect(popup.focus).toHaveBeenCalledOnce(); expect(popup.print).toHaveBeenCalledOnce()
})
it('keeps review direct and cancellation native, with the plan number and mutual action lock', async () => {
const wrapper = await openPlan(); await clickPlan(wrapper, '审核'); expect(api.review).toHaveBeenCalledExactlyOnceWith('PLAN-001')
let resolve!: (value: ReturnPackingPlanDto) => void
api.cancel.mockReturnValueOnce(new Promise(done => { resolve = done }))
await clickPlan(wrapper, '取消'); expect(confirmation().text()).toContain('PLAN-001'); expect(confirmation().classes()).toContain('admin-density-confirm')
await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
expect(api.cancel).toHaveBeenCalledExactlyOnceWith('PLAN-001'); expect(row(wrapper, 'PLAN-001').findAll('button').filter(item => ['审核', '取消'].includes(item.text())).every(item => item.attributes('disabled') !== undefined)).toBe(true)
resolve(plan()); await flushPromises()
})
it('preserves plan context and diagnostics after review failure and never cancels after dismissal', async () => {
api.review.mockRejectedValueOnce(new ApiResponseError('审核失败', 'A0503', 503, null, 'packing-review-request'))
const wrapper = await openPlan(); await clickPlan(wrapper, '审核'); expect(wrapper.text()).toContain('packing-review-request')
await clickPlan(wrapper, '取消'); await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises()
expect(api.cancel).not.toHaveBeenCalled(); expect(row(wrapper, 'PLAN-001').text()).toContain('PLAN-001')
})
it('keeps inventory-list users readonly and requires carton access for creation and labels', async () => {
can.mockImplementation(code => code === permission.returnInventoryList)
api.plans.mockResolvedValueOnce({ records: [plan({ status: 'COMPLETED', completionCartons: [label()], resultCartons: [carton()] })], total: 1 })
const readonly = await openPlan(); expect(readonly.findAll('button').some(item => item.text() === '创建合箱计划')).toBe(false)
expect(row(readonly, 'PLAN-001').findAll('button').map(item => item.text())).toEqual(['详情'])
await clickPlan(readonly); expect(dialog(readonly, '合箱计划详情').exists()).toBe(true)
expect(dialog(readonly, '合箱计划详情').findAll('button').some(item => item.text() === '打印当前箱唛')).toBe(false)
can.mockImplementation(code => code === permission.returnInventoryList || code === 'lastleg:inventory:manage')
const manageOnly = await openPlan(); expect(manageOnly.findAll('button').some(item => item.text() === '创建合箱计划')).toBe(false)
expect(row(manageOnly, 'PLAN-001').findAll('button').map(item => item.text())).toEqual(['详情', '审核', '取消'])
expect(api.label).not.toHaveBeenCalled(); expect(api.create).not.toHaveBeenCalled()
})
it('shows both candidate failures and preserves an entered remark across the original option reload', async () => {
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'packing-customer-request'))
api.warehouses.mockRejectedValueOnce(new ApiResponseError('仓库失败', 'A0503', 503, null, 'packing-warehouse-request'))
const { form } = await openCreate(); expect(form.text()).toContain('packing-customer-request'); expect(form.text()).toContain('packing-warehouse-request')
await field(form, '合箱备注(可选)').get('textarea').setValue('候选失败后保留')
form.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
await errorState(form, '客户候选加载失败').get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenCalledTimes(2)
expect((field(form, '合箱备注(可选)').get('textarea').element as HTMLTextAreaElement).value).toBe('候选失败后保留')
})
})
@@ -10,6 +10,7 @@ import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
import { listPlatformTenants } from '@/modules/iam'
const routeQuery = vi.hoisted(() => ({} as Record<string, string>))
vi.mock('vue-router', () => ({ useRoute: () => ({ name: 'overseas-return-inventory', query: routeQuery }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'customer-1', tenantName: '测试客户', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 }),
@@ -154,8 +155,10 @@ describe('ReturnInventoryManagementView shared list', () => {
it('shows source carton, return ownership and moved quantities in carton merge history', async () => {
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, items: [{ skuCode: 'SKU-TEST', quantity: 8 }], mergeHistory: [{planNo:'PLAN-MERGED',sourceCartonMark:'SOURCE-BOX',targetCartonMark:carton.cartonMark,returnNo:'RETURN-SOURCE',skuCode:'SKU-TEST',quantity:2,createdAt:'2026-10-08T14:00:00'}] })
const wrapper = await mountView('cartons')
await wrapper.findAll('button').find(button => button.text() === '详情')!.trigger('click')
const businessRow = wrapper.findAll('.el-table__body tr').filter(row => row.text().includes(carton.cartonMark)).at(-1)!
await businessRow.findAll('button').find(button => button.text() === '详情')!.trigger('click')
await flushPromises()
expect(getReturnCarton).toHaveBeenCalledWith(carton.cartonMark)
expect(document.body.textContent).toContain('合箱明细')
expect(document.body.textContent).toContain('SOURCE-BOX')
expect(document.body.textContent).toContain('RETURN-SOURCE')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { returnRelabelStatusLabel } from "../utils/returnRelabelProgress";
import "@/styles/adminDensity.css";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import dayjs from "dayjs";
import { useRoute } from "vue-router";
import { Search, Plus } from "@lucide/vue";
@@ -16,6 +16,12 @@ import DataTable, {
} from "@/shared/components/DataTable.vue";
import AdminPage from "@/shared/components/AdminPage.vue";
import AppDialog from "@/shared/components/AppDialog.vue";
import ApiErrorMeta from "@/shared/components/ApiErrorMeta.vue";
import LoadErrorState from "@/shared/components/LoadErrorState.vue";
import LineClampText from "@/shared/components/LineClampText.vue";
import { getApiErrorDetails, type ApiErrorDetails } from "@/shared/api/http";
import { useAccessPreviewStore } from "@/core/access/accessPreview";
import { overseasWarehousePermissions as permission } from "../permissions";
import {
getReturnCarton,
getReturnCartonLabel,
@@ -38,6 +44,25 @@ import {
} from "../api/relabelOrders";
const props = defineProps<{ mode: "inventory" | "cartons" }>();
const route = useRoute();
const access = useAccessPreviewStore();
const canManagePacking = computed(() => access.can(permission.returnInventoryList) && access.can("lastleg:inventory:manage"));
const canCreatePacking = computed(() => canManagePacking.value && access.can(permission.returnCartonsList));
const listError = ref<ApiErrorDetails | null>(null);
const customerError = ref<ApiErrorDetails | null>(null);
const warehouseError = ref<ApiErrorDetails | null>(null);
const actionError = ref<ApiErrorDetails | null>(null);
const packingSourceError = ref<ApiErrorDetails | null>(null);
const packingSubmitError = ref<ApiErrorDetails | null>(null);
const cartonError = ref<ApiErrorDetails | null>(null);
const cartonLoading = ref(false);
const selectedCartonMark = ref("");
const printingMark = ref("");
const planActionNo = ref("");
const packingRequestKey = ref("");
let listLoadSequence = 0;
let cartonLoadSequence = 0;
function newPackingRequestKey() { return globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`; }
const loading = ref(false),
total = ref(0),
detailOpen = ref(false);
@@ -115,15 +140,19 @@ function params() {
};
}
async function load() {
const sequence = ++listLoadSequence;
listError.value = null;
loading.value = true;
try {
if (props.mode === "inventory") {
if (inventoryTab.value === "stock") {
const page = await listReturnStock(params());
if (sequence !== listLoadSequence) return;
stockRows.value = page.records;
total.value = page.total;
} else if (inventoryTab.value === "movements") {
const page = await listReturnInventoryMovements(params());
if (sequence !== listLoadSequence) return;
movementRows.value = page.records;
total.value = page.total;
} else {
@@ -135,17 +164,24 @@ async function load() {
...(query.overseasWarehouseId ? { overseasWarehouseId: query.overseasWarehouseId } : {}),
...(query.skuCode.trim() ? { skuCode: query.skuCode.trim() } : {}),
});
if (sequence !== listLoadSequence) return;
planRows.value = page.records;
planTotal.value = page.total;
total.value = page.total;
}
} else {
const page = await listReturnCartons(params());
cartonRows.value = page.records;
if (sequence !== listLoadSequence) return;
cartonRows.value = page.records;
total.value = page.total;
}
} catch (error) {
if (sequence !== listLoadSequence) return;
stockRows.value = []; movementRows.value = []; planRows.value = []; cartonRows.value = [];
total.value = 0; planTotal.value = 0;
listError.value = getApiErrorDetails(error, "退货数据加载失败,请重试");
} finally {
loading.value = false;
if (sequence === listLoadSequence) loading.value = false;
}
}
function search() {
@@ -157,21 +193,25 @@ function changeInventoryTab() {
void load();
}
async function reviewPlan(plan: Record<string, unknown>) {
const no = String(plan.planNo || "");
if (!no || plan.status !== "PENDING_REVIEW" || !canManagePacking.value || planActionNo.value || packingSaving.value) return;
planActionNo.value = no; actionError.value = null;
try {
await reviewReturnPackingPlan(String(plan.planNo));
ElMessage.success("合箱计划审核通过,已推送 PDA");
await load();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "合箱计划审核失败",
);
}
await reviewReturnPackingPlan(no);
ElMessage.success("合箱计划审核通过,已推送 PDA"); await load();
} catch (error) { actionError.value = getApiErrorDetails(error, "合箱计划审核失败"); }
finally { planActionNo.value = ""; }
}
async function cancelPlan(plan: Record<string, unknown>) {
const no = String(plan.planNo || "");
if (!no || !["PENDING_REVIEW", "READY"].includes(String(plan.status)) || !canManagePacking.value || planActionNo.value || packingSaving.value) return;
planActionNo.value = no; actionError.value = null;
try {
await ElMessageBox.confirm('取消后释放本计划锁定的来源库存,是否继续?', '取消合箱计划', { type: 'warning', customClass: 'admin-density-confirm' });
await cancelReturnPackingPlan(String(plan.planNo)); ElMessage.success('计划已取消,库存已释放'); await load();
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '取消失败'); }
await ElMessageBox.confirm(`确认取消合箱计划“${no}”?取消后释放本计划锁定的来源库存,是否继续?`, "取消合箱计划", { type: "warning", customClass: "admin-density-confirm" });
if (!canManagePacking.value) return;
await cancelReturnPackingPlan(no); ElMessage.success("计划已取消,库存已释放"); await load();
} catch (error) { if (error !== "cancel" && error !== "close") actionError.value = getApiErrorDetails(error, "取消合箱计划失败"); }
finally { planActionNo.value = ""; }
}
function openPlanDetail(row: Record<string, unknown>) {
planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo);
@@ -231,44 +271,35 @@ async function loadCustomerOptions() {
}
}
async function loadOptions() {
optionsLoading.value = true;
if (optionsLoading.value) return;
optionsLoading.value = true; customerError.value = null; warehouseError.value = null;
try {
const [customerResult, warehouseResult] = await Promise.allSettled([
loadCustomerOptions(),
listSharedReceivingOverseasWarehouseOptions(),
]);
if (customerResult.status === "fulfilled")
customers.value = customerResult.value;
else customers.value = [];
if (warehouseResult.status === "fulfilled")
warehouses.value = warehouseResult.value;
else warehouses.value = [];
if (
customerResult.status === "rejected" ||
warehouseResult.status === "rejected"
) {
const failed = customerResult.status === "rejected" ? "客户" : "海外仓";
ElMessage.warning(`${failed}筛选数据加载失败,请检查服务或数据库迁移`);
}
} catch (error) {
ElMessage.warning(
error instanceof Error
? `筛选名称加载失败:${error.message}`
: "筛选名称加载失败",
);
} finally {
optionsLoading.value = false;
}
const [customerResult, warehouseResult] = await Promise.allSettled([loadCustomerOptions(), listSharedReceivingOverseasWarehouseOptions()]);
if (customerResult.status === "fulfilled") customers.value = customerResult.value;
else { customers.value = []; customerError.value = getApiErrorDetails(customerResult.reason, "客户候选加载失败"); }
if (warehouseResult.status === "fulfilled") warehouses.value = warehouseResult.value;
else { warehouses.value = []; warehouseError.value = getApiErrorDetails(warehouseResult.reason, "海外仓候选加载失败"); }
} finally { optionsLoading.value = false; }
}
async function openCarton(mark: string) {
if (!mark || !access.can(permission.returnCartonsList) || detailOpen.value || cartonLoading.value || packingSaving.value || planActionNo.value) return;
selectedCartonMark.value = mark; detail.value = undefined; cartonError.value = null; detailOpen.value = true;
await loadCarton();
}
async function loadCarton() {
const mark = selectedCartonMark.value;
if (!mark || !detailOpen.value || cartonLoading.value || !access.can(permission.returnCartonsList)) return;
const sequence = ++cartonLoadSequence;
cartonLoading.value = true; cartonError.value = null; detail.value = undefined;
try {
detail.value = await getReturnCarton(mark);
detailOpen.value = true;
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : "箱号详情加载失败");
}
const result = await getReturnCarton(mark);
if (sequence === cartonLoadSequence && detailOpen.value) detail.value = result;
} catch (error) { if (sequence === cartonLoadSequence && detailOpen.value) cartonError.value = getApiErrorDetails(error, "箱号详情加载失败"); }
finally { if (sequence === cartonLoadSequence) cartonLoading.value = false; }
}
async function printLabel(mark: string) {
if (!access.can(permission.returnCartonsList) || printingMark.value) return;
printingMark.value = mark; actionError.value = null;
try {
const label = await getReturnCartonLabel(mark);
const popup = window.open("", "_blank");
@@ -280,10 +311,12 @@ async function printLabel(mark: string) {
popup.focus();
popup.print();
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : "箱唛加载失败");
}
actionError.value = getApiErrorDetails(e, "箱唛加载失败");
} finally { printingMark.value = ""; }
}
function openPackingPlan() {
if (!canCreatePacking.value || packingSaving.value || packingDialogOpen.value || planActionNo.value) return;
packingRequestKey.value = newPackingRequestKey(); packingSubmitError.value = null;
packingForm.customerId = query.customerId;
packingForm.overseasWarehouseId = query.overseasWarehouseId || warehouses.value[0]?.id || "";
packingForm.returnNos = [];
@@ -299,6 +332,8 @@ const packingSources = ref<ReturnPackingSource[]>([]);
const packingSourceLoading = ref(false);
let packingLoadSequence = 0;
async function loadPackingSources() {
if (!canCreatePacking.value || packingSaving.value) return;
packingSourceError.value = null;
const sequence = ++packingLoadSequence;
packingSkuRows.value = []; packingBoxRows.value = []; packingSources.value = [];
packingReturnOptions.value = []; packingForm.returnNos = []; packingForm.returnNosInput = ""; packingForm.skuSearchInput = ""; packingForm.skuQuantities = {}; packingForm.sourceCartonMarks = [];
@@ -318,7 +353,7 @@ async function loadPackingSources() {
packingSources.value = sources;
updatePackingScope();
} catch (error) {
if (sequence === packingLoadSequence) ElMessage.error(error instanceof Error ? error.message : '客户退货库存加载失败,请重试');
if (sequence === packingLoadSequence) packingSourceError.value = getApiErrorDetails(error, "客户退货库存加载失败,请重试");
} finally { if (sequence === packingLoadSequence) packingSourceLoading.value = false; }
}
function sourceReturnNos(source: ReturnPackingSource) { return (source.sourceReturnNo || '').split(',').filter(Boolean); }
@@ -351,6 +386,9 @@ function changePackingMode() {
updatePackingScope();
}
async function submitPackingPlan() {
if (!packingDialogOpen.value || packingSaving.value || optionsLoading.value || packingSourceLoading.value || customerError.value || warehouseError.value || packingSourceError.value || !canCreatePacking.value) return;
const invalidQuantity = packingSkuRows.value.some(row => packingForm.skuQuantities[row.key] !== undefined && (!Number.isInteger(packingForm.skuQuantities[row.key]) || packingForm.skuQuantities[row.key]! <= 0 || packingForm.skuQuantities[row.key]! > row.availableQuantity));
if (packingForm.mode === "SKU" && invalidQuantity) { ElMessage.warning("合箱数量必须为不超过可用量的正整数"); return; }
const sourceDeductions = packingSkuRows.value.filter(row => Number(packingForm.skuQuantities[row.key]) > 0)
.map(row => ({ cartonMark: row.cartonMark, skuCode: row.skuCode, quantity: Number(packingForm.skuQuantities[row.key]) }));
const selectedMarks = packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [...new Set(sourceDeductions.map(row => row.cartonMark))];
@@ -374,7 +412,7 @@ async function submitPackingPlan() {
);
return;
}
packingSaving.value = true;
packingSaving.value = true; packingSubmitError.value = null;
try {
await createReturnPackingPlan({
returnNos,
@@ -383,15 +421,12 @@ async function submitPackingPlan() {
sourceCartonMarks: selectedMarks,
sourceDeductions: packingForm.mode === 'SKU' ? sourceDeductions : undefined,
remark: packingForm.remark.trim() || undefined,
idempotencyKey:
globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`,
idempotencyKey: packingRequestKey.value,
});
ElMessage.success("合箱计划已创建,待审核后由 PDA 填写箱规并作业");
packingDialogOpen.value = false; inventoryTab.value = "plans"; query.page = 1; await load();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "合箱计划创建失败",
);
packingSubmitError.value = getApiErrorDetails(error, "合箱计划创建失败");
} finally {
packingSaving.value = false;
}
@@ -406,7 +441,8 @@ watch(() => [route.name, route.query.packing, route.query.customerId, route.quer
query.customerId = value('customerId');
query.overseasWarehouseId = value('overseasWarehouseId');
void load();
if (value('packing') === '1') {
if (value('packing') === '1' && canCreatePacking.value) {
packingRequestKey.value = newPackingRequestKey(); packingSubmitError.value = null;
packingForm.customerId = query.customerId;
packingForm.overseasWarehouseId = query.overseasWarehouseId;
packingForm.mode = 'BOX';
@@ -415,6 +451,15 @@ watch(() => [route.name, route.query.packing, route.query.customerId, route.quer
packingForm.returnNosInput = value('returnNos');
}
}, { immediate: true });
watch(packingDialogOpen, open => {
if (open) return;
packingLoadSequence++; packingSourceLoading.value = false; packingRequestKey.value = "";
});
watch(detailOpen, open => {
if (open) return;
cartonLoadSequence++; cartonLoading.value = false; selectedCartonMark.value = ""; detail.value = undefined; cartonError.value = null;
});
onBeforeUnmount(() => { listLoadSequence++; cartonLoadSequence++; packingLoadSequence++; });
onMounted(() => { void loadOptions(); });
const stockColumns: DataTableColumn[] = [
{ key: "stock-0", label: "客户", minWidth: 180, slotName: "stock-0" },
@@ -557,7 +602,6 @@ const tableColumns = computed(() =>
: planColumns,
);
</script>
<template>
<AdminPage as="div" class="business-page return-inventory-page admin-density">
<DataTable
@@ -569,6 +613,7 @@ const tableColumns = computed(() =>
:data="tableRows"
:columns="tableColumns"
:loading="loading"
:results-visible="!listError"
:total="total"
:page-sizes="[20, 50, 100]"
:preference-key="`overseas-return-${mode === 'inventory' ? inventoryTab : 'cartons'}`"
@@ -585,12 +630,19 @@ const tableColumns = computed(() =>
>
<template #toolbar-actions
><el-button
v-if="mode === 'inventory'"
v-if="mode === 'inventory' && canCreatePacking"
:disabled="packingSaving || Boolean(planActionNo)"
type="warning"
@click="openPackingPlan"
><Plus :size="15" />创建合箱计划</el-button
></template
>
<template #notices>
<LoadErrorState v-if="listError" title="退货数据加载失败" :message="listError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="actionError" title="退货操作失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</template>
<template #filters
><el-tabs
class="density-element-tabs density-status-tabs"
@@ -713,22 +765,25 @@ const tableColumns = computed(() =>
@click="openPlanDetail(row)"
>详情</el-button
><el-button
v-if="row.status === 'PENDING_REVIEW'"
v-if="canManagePacking && row.status === 'PENDING_REVIEW'"
class="density-action"
data-tone="warning"
:disabled="Boolean(planActionNo) || packingSaving"
@click="reviewPlan(row)"
>审核</el-button><el-button v-if="['PENDING_REVIEW','READY'].includes(row.status)" class="density-action" data-tone="danger" @click="cancelPlan(row)">取消</el-button>
>审核</el-button><el-button v-if="canManagePacking && ['PENDING_REVIEW','READY'].includes(row.status)" :disabled="Boolean(planActionNo) || packingSaving" class="density-action" data-tone="danger" @click="cancelPlan(row)">取消</el-button>
</div></template
><template #carton-0="{ row }"
><div class="row-actions density-actions admin-text-actions">
<el-button
class="density-action"
data-tone="neutral"
v-if="access.can(permission.returnCartonsList)" :disabled="cartonLoading"
@click="openCarton(row.cartonMark)"
>详情</el-button
><el-button
class="density-action"
data-tone="neutral"
v-if="access.can(permission.returnCartonsList)" :loading="printingMark === row.cartonMark" :disabled="Boolean(printingMark)"
@click="printLabel(row.cartonMark)"
>箱唛</el-button
>
@@ -748,230 +803,145 @@ const tableColumns = computed(() =>
row.length ? `${row.length}×${row.width}×${row.height} cm` : "—"
}}</template>
</DataTable>
<AppDialog
class="packing-dialog admin-density-dialog"
v-model="packingDialogOpen"
title="创建退货合箱计划"
width="min(1380px, calc(100vw - 32px))"
confirm-text="提交审核"
:confirm-loading="packingSaving"
@confirm="submitPackingPlan"
><el-form v-loading="packingSourceLoading" label-position="top" class="packing-form"
><el-form-item label="客户"
><el-select
v-model="packingForm.customerId"
filterable
placeholder="请选择客户"
style="width: 100%"
@change="loadPackingSources"
><el-option
v-for="item in customerOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" style="width:100%" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item><el-form-item label="合箱方式"
><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"
><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button
><el-radio-button label="SKU"
>部分包裹/箱合箱</el-radio-button
></el-radio-group
></el-form-item
><el-form-item label="来源退货单筛选(可选,后台自动追溯)"
><el-input
v-model="packingForm.returnNosInput"
clearable
placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"
style="width: 100%"
><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item
><el-form-item v-if="packingForm.mode === 'SKU'" label="来源包裹/箱和合箱数量"
><div class="packing-sku-list-wrap">
<el-input
v-model="packingForm.skuSearchInput"
clearable
placeholder="请输入 SKU,多个 SKU 请用英文逗号分隔"
class="packing-sku-search"
><template #suffix><span v-if="packingForm.skuSearchInput.trim()" class="return-nos-count">按 SKU 筛选</span></template></el-input>
<div class="packing-sku-list">
<div class="packing-sku-list-head"><span>选择 / SKU</span><span>可用数量</span><span>合箱数量</span></div>
<div
v-for="item in visiblePackingSkuRows"
:key="item.key"
class="packing-sku-row"
>
<el-checkbox
:model-value="
packingForm.skuQuantities[item.key] !== undefined
"
@change="togglePackingSku(item.key, Boolean($event))"
>{{ item.cartonMark }} · {{ item.skuCode }}</el-checkbox
><span>可用 {{ item.availableQuantity }}</span
><el-input-number
v-if="packingForm.skuQuantities[item.key] !== undefined"
v-model="packingForm.skuQuantities[item.key]"
:min="1"
:max="item.availableQuantity"
:controls="false"
placeholder="数量"
/>
</div>
<el-empty
v-if="!visiblePackingSkuRows.length"
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'"
/></div></div></el-form-item
><el-form-item v-if="packingForm.mode === 'BOX'" label="箱维度明细"
><div class="packing-sku-list-wrap">
<el-input v-model="packingForm.skuSearchInput" clearable placeholder="按箱内 SKU 查找,多个 SKU 请用英文逗号分隔" class="packing-sku-search" />
<div class="packing-box-list">
<div class="packing-box-list-head"><span>选择</span><span>箱唛</span><span>来源退货单</span><span>类型</span><span>SKU 明细</span></div>
<div v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="`选择箱唛 ${box.cartonMark}`"><span /></el-checkbox></el-checkbox-group>
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
</div>
<el-empty v-if="!visiblePackingBoxRows.length" :description="packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" />
</div></div></el-form-item
><el-alert
type="info"
:closable="false"
title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" /><el-form-item
label="合箱备注(可选)"
><el-input
v-model="packingForm.remark"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
resize="vertical"
placeholder="请输入合箱说明,最多 1000 字" /></el-form-item></el-form
></AppDialog>
<AppDialog
class="admin-density-dialog"
v-model="planDetailOpen"
title="合箱计划详情"
width="min(1400px, calc(100vw - 56px))"
:show-footer="false"
><template v-if="planDetail"
><el-descriptions :column="2" border
><el-descriptions-item label="计划号">{{
planDetail.planNo
}}</el-descriptions-item
><el-descriptions-item label="客户">{{
customerName(planDetail.customerId)
}}</el-descriptions-item
><el-descriptions-item label="合箱方式">{{
planDetail.mode === "SKU" ? "部分包裹/箱合箱" : "整包裹/箱合箱"
}}</el-descriptions-item
><el-descriptions-item label="状态">{{
planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核'
}}</el-descriptions-item
><el-descriptions-item label="来源退货单" :span="2">{{
planDetail.returnNos?.join("、") || "—"
}}</el-descriptions-item
><el-descriptions-item label="备注" :span="2">{{
planDetail.remark || "—"
}}</el-descriptions-item></el-descriptions
>
<h3>计划来源箱明细</h3>
<el-table :data="planSourceRows" class="detail-items" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210" />
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
<template v-if="planDetail.status === 'COMPLETED'">
<h3>实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" class="detail-items" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<AppDialog class="admin-density-dialog" v-model="packingDialogOpen" title="创建退货合箱计划" width="min(1380px, calc(100vw - 32px))" fullscreenable confirm-text="提交审核" :confirm-loading="packingSaving" :confirm-disabled="optionsLoading || packingSourceLoading || Boolean(customerError || warehouseError || packingSourceError) || !canCreatePacking" @confirm="submitPackingPlan">
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="packingSubmitError" title="合箱计划创建失败" :message="packingSubmitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="packingSubmitError" /></template></LoadErrorState>
<el-form class="packing-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="packingSaving || !canCreatePacking">
<section class="density-dialog-section" data-tone="work" aria-label="合箱基础资料">
<table class="density-table density-table--editable packing-basic-table"><tbody>
<tr><td><el-form-item label="客户"><el-select v-model="packingForm.customerId" filterable :loading="optionsLoading" :disabled="optionsLoading || Boolean(customerError)" placeholder="请选择客户" @change="loadPackingSources"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td>
<td><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" :loading="optionsLoading" :disabled="optionsLoading || Boolean(warehouseError)" placeholder="请选择海外仓" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="合箱方式"><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button><el-radio-button label="SKU">部分包裹/箱合箱</el-radio-button></el-radio-group></el-form-item></td>
<td><el-form-item label="退货单筛选"><el-input v-model="packingForm.returnNosInput" clearable placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count density-dialog-note">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="合箱来源选择">
<h3 class="density-dialog-section__title">{{ packingForm.mode === 'SKU' ? '来源包裹/箱和合箱数量' : '箱维度明细' }}</h3>
<p v-if="packingSourceLoading" class="density-dialog-note" role="status">正在读取可用来源库存…</p>
<LoadErrorState v-if="packingSourceError" title="合箱来源加载失败" :message="packingSourceError.message" :loading="packingSourceLoading" @retry="loadPackingSources"><template #details><ApiErrorMeta :details="packingSourceError" /></template></LoadErrorState>
<el-input v-model="packingForm.skuSearchInput" clearable :placeholder="packingForm.mode === 'SKU' ? '请输入 SKU,多个 SKU 请用英文逗号分隔' : '按箱内 SKU 查找,多个 SKU 请用英文逗号分隔'" class="packing-sku-search"><template #suffix><span v-if="packingForm.skuSearchInput.trim()" class="return-nos-count density-dialog-note">按 SKU 筛选</span></template></el-input>
<table v-if="packingForm.mode === 'SKU'" class="density-table density-table--editable packing-source-table">
<colgroup><col /><col class="packing-available" /><col class="packing-quantity" /></colgroup>
<thead><tr><th>选择 / 箱唛 · SKU</th><th>可用数量</th><th>合箱数量</th></tr></thead><tbody>
<tr v-for="item in visiblePackingSkuRows" :key="item.key" class="packing-sku-row">
<td><el-checkbox :model-value="packingForm.skuQuantities[item.key] !== undefined" @change="togglePackingSku(item.key, Boolean($event))"><LineClampText :content="item.cartonMark + ' · ' + item.skuCode" /></el-checkbox></td>
<td>可用 {{ item.availableQuantity }}</td>
<td><el-input-number v-if="packingForm.skuQuantities[item.key] !== undefined" v-model="packingForm.skuQuantities[item.key]" :min="1" :max="item.availableQuantity" :precision="0" :controls="false" placeholder="数量" /></td>
</tr>
<tr v-if="!visiblePackingSkuRows.length && !packingSourceLoading && !packingSourceError"><td colspan="3"><el-empty :description="!packingForm.customerId || !packingForm.overseasWarehouseId ? '请选择客户和海外仓后读取可用来源' : packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'" /></td></tr>
</tbody>
</table>
<table v-else class="density-table packing-source-table">
<colgroup><col class="packing-selection" /><col class="packing-mark" /><col class="packing-return" /><col class="packing-unit" /><col /></colgroup>
<thead><tr><th>选择</th><th>箱唛</th><th>来源退货单</th><th>类型</th><th>SKU 明细</th></tr></thead><tbody>
<tr v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<td><el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="'选择箱唛 ' + box.cartonMark"><span /></el-checkbox></el-checkbox-group></td>
<td><LineClampText :content="box.cartonMark" /></td><td><LineClampText :content="box.returnNo" /></td>
<td>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</td>
<td><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span></td>
</tr>
<tr v-if="!visiblePackingBoxRows.length && !packingSourceLoading && !packingSourceError"><td colspan="5"><el-empty :description="!packingForm.customerId || !packingForm.overseasWarehouseId ? '请选择客户和海外仓后读取可用来源' : packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" /></td></tr>
</tbody>
</table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="合箱备注">
<el-alert type="info" :closable="false" title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" />
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="合箱备注(可选)"><el-input v-model="packingForm.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit resize="vertical" placeholder="请输入合箱说明,最多 1000 字" /></el-form-item></td></tr></tbody></table>
</section>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="planDetailOpen" :title="'合箱计划详情 · ' + (planDetail?.planNo || '—')" width="min(1400px, calc(100vw - 56px))" fullscreenable :show-footer="false">
<div v-if="planDetail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="合箱计划资料">
<table class="density-table return-detail-facts"><tbody>
<tr><th scope="row">计划号</th><td><LineClampText :content="planDetail.planNo" /></td><th scope="row">客户</th><td><LineClampText :content="customerName(planDetail.customerId)" /></td></tr>
<tr><th scope="row">合箱方式</th><td>{{ planDetail.mode === 'SKU' ? '部分包裹/箱合箱' : '整包裹/箱合箱' }}</td><th scope="row">状态</th><td>{{ planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核' }}</td></tr>
<tr><th scope="row">来源退货单</th><td colspan="3"><LineClampText :content="planDetail.returnNos?.join('、')" :lines="2" /></td></tr>
<tr><th scope="row">备注</th><td colspan="3"><LineClampText :content="planDetail.remark" :lines="2" /></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="计划来源箱明细">
<h3 class="density-dialog-section__title">计划来源箱明细</h3>
<el-table :data="planSourceRows" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210"><template #default="{ row }"><LineClampText :content="row.sourceReturnNo" /></template></el-table-column>
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.cartonMark" /></template></el-table-column>
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
</section>
<section v-if="planDetail.status === 'COMPLETED'" class="density-dialog-section" data-tone="source" aria-label="实际合箱流向">
<h3 class="density-dialog-section__title">实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210"><template #default="{ row }"><LineClampText :content="row.returnNo" /></template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.sourceCartonMark" /></template></el-table-column>
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230"><template #default="{ row }"><LineClampText :content="row.targetCartonMark" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="180"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="本次合入" width="100" />
</el-table>
<h3>合箱完成时结果({{ planDetail.completedBy || '操作员未留存' }})</h3>
<p>结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
</template>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows" class="detail-items">
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
<el-table-column label="操作" width="130"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" :disabled="!planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></template></el-table-column>
</el-table>
<el-table v-else :data="plannedItemRows" class="detail-items" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
</el-table></template
></AppDialog
><AppDialog
class="admin-density-dialog"
v-model="detailOpen"
title="退货箱号详情"
width="min(1080px, calc(100vw - 56px))"
:show-footer="false"
><template v-if="detail"
><el-descriptions class="density-descriptions" :column="2" border
><el-descriptions-item label="箱号">{{
detail.cartonMark
}}</el-descriptions-item
><el-descriptions-item label="客户">{{
customerName(detail.customerId)
}}</el-descriptions-item
><el-descriptions-item label="海外仓">{{
warehouseName(detail.overseasWarehouseId)
}}</el-descriptions-item
><el-descriptions-item label="来源退货单">{{
detail.sourceReturnNo || "—"
}}</el-descriptions-item
><el-descriptions-item label="FBA箱唛">{{
detail.fbaCartonMark || "—"
}}</el-descriptions-item
><el-descriptions-item label="目的仓">{{
detail.destinationWarehouseCode ||
detail.destinationWarehouseName ||
"—"
}}</el-descriptions-item
><el-descriptions-item label="换标单号">{{
detail.relabelNo || "—"
}}</el-descriptions-item
><el-descriptions-item label="换标状态">{{
returnRelabelStatusLabel(detail.relabelStatus)
}}|实际完成 {{ detail.relabelledQuantity ?? "—" }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? "—" }} 件</el-descriptions-item
><el-descriptions-item label="状态">{{
statusLabel(detail.status)
}}</el-descriptions-item
><el-descriptions-item label="箱规">{{
detail.length
? `${detail.length}×${detail.width}×${detail.height} cm`
: "—"
}}</el-descriptions-item
><el-descriptions-item label="重量">{{
detail.weight ? `${detail.weight} kg` : "—"
}}</el-descriptions-item></el-descriptions
><el-table :data="detail.items" class="detail-items"
><el-table-column prop="skuCode" label="SKU" /><el-table-column
prop="productNameZh"
label="中文品名" /><el-table-column
prop="quantity"
label="数量"
align="right" /></el-table>
<h3>合箱明细</h3>
<el-table :data="detail.mergeHistory || []" class="detail-items" empty-text="暂无合箱记录">
<el-table-column prop="createdAt" label="合箱时间" min-width="170" />
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }">{{ row.planNo && row.planNo !== 'null' ? row.planNo : '现场合箱 / 历史未关联' }}</template></el-table-column>
<el-table-column label="流向" width="80"><template #default="{ row }">{{ row.targetCartonMark === detail.cartonMark ? '合入' : '合出' }}</template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
<el-table-column prop="returnNo" label="来源退货单" min-width="240" />
<el-table-column prop="skuCode" label="SKU" min-width="170" />
<el-table-column prop="quantity" label="本次合箱数量" width="110" align="right" />
</el-table>
</template
></AppDialog>
</section>
<section class="density-dialog-section" data-tone="summary" aria-label="合箱结果">
<h3 class="density-dialog-section__title">{{ planDetail.status === 'COMPLETED' ? '合箱完成时结果(' + (planDetail.completedBy || '操作员未留存') + ')' : '计划箱子明细' }}</h3>
<p v-if="planDetail.status === 'COMPLETED'" class="density-dialog-note">结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows">
<el-table-column v-if="access.can(permission.returnCartonsList)" label="操作" width="130"><template #default="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :loading="printingMark === row.cartonMark" :disabled="Boolean(printingMark) || !planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></div></template></el-table-column>
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250"><template #default="{ row }"><LineClampText :content="row.cartonMark" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="300"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
</el-table>
<el-table v-else :data="plannedItemRows" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" />
<el-table-column prop="skuCode" label="SKU" min-width="300"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="right" />
</el-table>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="'退货箱号详情 · ' + (selectedCartonMark || '—')" width="min(1080px, calc(100vw - 56px))" fullscreenable :show-footer="false" :loading="cartonLoading" :confirm-loading="cartonLoading">
<p v-if="cartonLoading" class="density-dialog-note" role="status">正在读取退货箱号详情…</p>
<LoadErrorState v-if="cartonError" title="箱号详情加载失败" :message="cartonError.message" :loading="cartonLoading" @retry="loadCarton"><template #details><ApiErrorMeta :details="cartonError" /></template></LoadErrorState>
<div v-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="退货箱号资料">
<table class="density-table return-detail-facts"><tbody>
<tr><th scope="row">箱号</th><td><LineClampText :content="detail.cartonMark" /></td><th scope="row">状态</th><td>{{ statusLabel(detail.status) }}</td></tr>
<tr><th scope="row">客户</th><td><LineClampText :content="customerName(detail.customerId)" /></td><th scope="row">海外仓</th><td><LineClampText :content="warehouseName(detail.overseasWarehouseId)" /></td></tr>
<tr><th scope="row">来源退货单</th><td><LineClampText :content="detail.sourceReturnNo" /></td><th scope="row">FBA箱唛</th><td><LineClampText :content="detail.fbaCartonMark" /></td></tr>
<tr><th scope="row">目的仓</th><td><LineClampText :content="detail.destinationWarehouseCode || detail.destinationWarehouseName" /></td><th scope="row">换标单号</th><td><LineClampText :content="detail.relabelNo" /></td></tr>
<tr><th scope="row">换标状态</th><td colspan="3">{{ returnRelabelStatusLabel(detail.relabelStatus) }}|实际完成 {{ detail.relabelledQuantity ?? '—' }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? '—' }} 件</td></tr>
<tr><th scope="row">箱规</th><td>{{ detail.length ? detail.length + '×' + detail.width + '×' + detail.height + ' cm' : '—' }}</td><th scope="row">重量</th><td>{{ detail.weight ? detail.weight + ' kg' : '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="退货箱内SKU">
<h3 class="density-dialog-section__title">箱内SKU明细</h3>
<el-table :data="detail.items">
<el-table-column prop="skuCode" label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="productNameZh" label="中文品名"><template #default="{ row }"><LineClampText :content="row.productNameZh" /></template></el-table-column>
<el-table-column prop="quantity" label="数量" align="right" />
</el-table>
</section>
<section class="density-dialog-section" data-tone="source" aria-label="退货合箱明细">
<h3 class="density-dialog-section__title">合箱明细</h3>
<el-table :data="detail.mergeHistory || []" empty-text="暂无合箱记录">
<el-table-column prop="createdAt" label="合箱时间" min-width="170" />
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }"><LineClampText :content="row.planNo" /></template></el-table-column>
<el-table-column label="流向" width="80"><template #default="{ row }">{{ row.targetCartonMark === detail.cartonMark ? '合入' : '合出' }}</template></el-table-column>
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.sourceCartonMark" /></template></el-table-column>
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.targetCartonMark" /></template></el-table-column>
<el-table-column prop="returnNo" label="来源退货单" min-width="240"><template #default="{ row }"><LineClampText :content="row.returnNo" /></template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="170"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
<el-table-column prop="quantity" label="本次合箱数量" width="110" align="right" />
</el-table>
</section>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
@@ -996,135 +966,25 @@ const tableColumns = computed(() =>
justify-content: flex-end;
margin-top: var(--admin-space-5);
}
.detail-items {
margin-top: var(--admin-space-5);
}
.packing-form {
padding: 2px 4px;
}
.packing-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.packing-mode {
display: flex;
width: 100%;
}
.packing-mode :deep(.el-radio-button) {
flex: 1;
}
.packing-mode :deep(.el-radio-button__inner) {
width: 100%;
padding: 9px 14px;
}
.packing-sku-list-wrap { width: 100%; }
.packing-sku-search { margin-bottom: 8px; }
.packing-sku-list {
display: grid;
gap: 0;
width: 100%;
max-height: 260px;
overflow: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
}
.packing-sku-list-head,
.packing-sku-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 90px 92px;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 7px 10px;
border-bottom: 1px solid var(--cc-border);
}
.packing-sku-list-head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface-subtle, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.packing-sku-row:last-of-type { border-bottom: 0; }
.packing-sku-row > span {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
white-space: nowrap;
}
.packing-sku-row :deep(.el-input-number) {
width: 68px;
}
.packing-sku-row :deep(.el-input-number .el-input__wrapper) {
padding: 0 7px;
}
.packing-sku-row :deep(.el-input__inner) {
font-size: var(--admin-font-caption);
text-align: center;
}
.packing-box-list {
display: grid;
gap: 0;
width: 100%;
max-height: 260px;
overflow: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
}
.packing-box-list-head,
.packing-box-list-row {
display: grid;
grid-template-columns: 72px minmax(180px, 1.15fr) minmax(150px, 1fr) 90px minmax(220px, 2fr);
align-items: center;
gap: 8px;
padding: 8px 10px;
}
.packing-box-list-row > strong,
.packing-box-list-row > span { overflow-wrap: anywhere; }
.packing-box-list-head {
position: sticky;
top: 0;
z-index: 1;
color: var(--cc-muted);
font-size: var(--admin-font-caption);
background: var(--cc-surface-subtle, #f8fafc);
border-bottom: 1px solid var(--cc-border);
}
.packing-box-list-row {
min-height: 42px;
border-bottom: 1px solid var(--cc-border);
}
.packing-box-list-row:last-of-type {
border-bottom: 0;
}
.packing-box-list-row > span,
.packing-box-items {
color: var(--cc-muted);
font-size: var(--admin-font-caption);
}
.packing-box-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.return-nos-count { color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; }
.packing-form :deep(.el-alert) {
margin-top: 2px;
padding: 8px 12px;
}
.packing-form :deep(.el-alert__title) {
font-size: var(--admin-font-caption);
}
@media (max-width: 700px) {
.packing-box-list { overflow-x: auto; }
.packing-box-list-head,
.packing-box-list-row { min-width: 760px; }
}
:deep(.packing-dialog.el-dialog) {
resize: both;
overflow: auto;
min-width: 720px;
min-height: 520px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
}
:deep(.packing-dialog .el-dialog__body) {
min-width: 0;
}
.packing-form :deep(.el-textarea__inner) {
width: 100%;
line-height: 1.6;
}
.packing-basic-table { table-layout: fixed; }
.packing-basic-table td { width: 50%; }
.packing-mode { display: flex; width: 100%; }
.packing-mode :deep(.el-radio-button) { flex: 1; }
.packing-mode :deep(.el-radio-button__inner) { width: 100%; }
.packing-source-table { table-layout: fixed; }
.packing-source-table th { position: sticky; top: 0; z-index: 1; }
.packing-available { width: 100px; }
.packing-quantity { width: 130px; }
.packing-selection { width: 56px; }
.packing-mark { width: 220px; }
.packing-return { width: 200px; }
.packing-unit { width: 96px; }
.packing-sku-search { margin-bottom: var(--admin-space-3); }
.packing-sku-row .el-checkbox { max-width: 100%; }
.packing-sku-row :deep(.el-checkbox__label) { min-width: 0; max-width: 100%; }
.packing-box-items { display: flex; flex-wrap: wrap; gap: 6px; }
.return-nos-count { white-space: nowrap; }
.return-detail-facts { table-layout: fixed; }
.return-detail-facts th { width: 110px; }
</style>
@@ -98,7 +98,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
if (fileName !== 'RelabelOrderDetailView.vue') expect(value).not.toContain('AppDialog')
}
const relabel = source('RelabelOrderDetailView.vue')
expect(relabel).toContain('<AppDialog class="admin-density-dialog" v-if="previewUrl"')
expect(relabel).toContain('<AppDialog class="admin-density-dialog" v-model="previewOpen"')
expect(relabel).toContain('<iframe :src="previewUrl"')
})
@@ -128,7 +128,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('<OverseasDetailShell')
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDrawer')
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain('title="新增费用"')
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain(':title="`新增费用 · ${referenceValue}`"')
else if (fileName === 'InboundOrderDetailView.vue') expect(value).toContain('移库')
else expect(value).not.toContain('AppDialog')
}
@@ -160,7 +160,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain('createLastMileOrderFee')
expect(tailLeg).toContain('<span>费用明细</span>')
expect(tailLeg).toContain('<span>操作记录</span>')
expect(tailLeg).toContain('title="新增费用"')
expect(tailLeg).toContain(':title="`新增费用 · ${referenceValue}`"')
expect(tailLeg).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable compact />')
const inbound = source('InboundOrderDetailView.vue')
@@ -184,7 +184,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
const value = source('PalletsView.vue')
expect(value).toContain('<AppDialog')
expect(value).toContain('760px')
expect(value).toContain("dialogMode === 'create' ? '新增托盘' : '编辑托盘'")
expect(value).toContain("dialogMode === 'create' ? '新增托盘' : `编辑托盘 · ${text(editingPallet?.code)}`")
expect(value).toContain('createPallet')
expect(value).toContain('listAllPlatformWarehouses({ warehouseType: \'FBA\', status: \'ACTIVE\' })')
expect(value).toContain('placeholder="请选择海外仓"')
@@ -258,7 +258,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
const dialog = source('../components/SmartDispatchDialog.vue')
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
expect(dialog).toContain('cargo: cargo.map(row => row.selection)')
// A filtered candidate must still submit its complete server-owned selection group.
expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)')
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
expect(dialog).toContain('同仓可混装多个客户与目的仓')
expect(dialog).not.toContain('一次排车只能装同一客户')
expect(dialog).not.toContain('ensureDraftToken')
@@ -278,7 +280,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(transactions).toContain('业务订单号')
expect(transactions).toContain('入库单号')
expect(transactions).toContain('出库单号')
expect(transactions).toContain('不提供出库单跳转或库存变更操作')
expect(transactions).not.toContain('本页面仅用于查询库存流水')
expect(transactions).not.toContain('当前流水 DTO')
expect(transactions).toContain("router.push({ name: 'overseas-inventory' })")
expect(transactions).not.toContain('保存')
@@ -79,6 +79,6 @@ describe('overseas warehouse mounted real API pages', () => {
expect(wrapper.text()).toContain('详情缺少客户范围')
expect(wrapper.text()).not.toContain('9007199254740995')
})
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.text()).toContain('不提供出库单跳转'); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.find('.read-only-boundary').exists()).toBe(false); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
it('renders complete business references with single-line ellipsis and titles', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.text()).toContain('货柜节点'); expect(wrapper.text()).toContain('已清关、可提柜'); expect(wrapper.text()).not.toContain('清关状态'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001'); const businessValues = wrapper.findAll('.detail-field-value--business'); expect(businessValues.map((item) => item.attributes('title'))).toEqual(expect.arrayContaining(['NYEXX2608280033', 'TSTU0000061'])); expect(businessValues.every((item) => item.text().length > 0)).toBe(true) })
})