fix(overseas): harden receiving warehouse change

This commit is contained in:
2026-09-07 20:25:14 +08:00
parent 36278517dd
commit 86ba48c415
2 changed files with 70 additions and 16 deletions
@@ -1,9 +1,12 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import {
@@ -61,6 +64,10 @@ const receivingWarehouse = {
...fbaWarehouse,
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
}
const currentReceivingWarehouse = {
...receivingWarehouse,
id: summary.overseasWarehouseId, warehouseCode: summary.overseasWarehouseCode, warehouseName: summary.overseasWarehouseName,
}
function selectedRow(overrides: Record<string, unknown> = {}) {
return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides }
}
@@ -309,24 +316,55 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('shows the receiving warehouse and changes it from ACTIVE OVERSEAS master data with versioning', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([receivingWarehouse] as never)
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([currentReceivingWarehouse, receivingWarehouse] as never)
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('海外接柜仓')
expect(wrapper.text()).toContain('LAX01 · 洛杉矶接柜仓')
expect(wrapper.get('.container-workbench-card__warehouse strong').attributes('title')).toBe('LAX01 · 洛杉矶接柜仓')
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
const receivingDialog = wrapper.findAllComponents(AppDialog).find((item) => item.props('title') === '修改海外接柜仓')!
expect(receivingDialog.props('width')).toBe('min(600px, calc(100vw - 28px))')
expect(listAllPlatformWarehouses).toHaveBeenLastCalledWith({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')!
expect(warehouseSelect.props('modelValue')).toBe('')
expect(wrapper.text()).not.toContain('9007199254740995')
const warehouseOptions = wrapper.findAllComponents({ name: 'ElOption' })
expect(warehouseOptions.find((item) => item.props('value') === summary.overseasWarehouseId)?.props('disabled')).toBe(true)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
warehouseSelect.vm.$emit('update:modelValue', summary.overseasWarehouseId)
await wrapper.vm.$nextTick()
expect(confirm.disabled).toBe(true)
expect(wrapper.text()).toContain('新的海外接柜仓必须与当前仓不同')
warehouseSelect.vm.$emit('update:modelValue', '9007199254740996')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(changeContainerReceivingWarehouse).toHaveBeenCalledWith(bookingId, {
overseasWarehouseId: '9007199254740996', expectedVersion: 7, remark: undefined,
}, expect.stringMatching(/^container-receiving-warehouse-/))
expect(listContainerPlanProgress).toHaveBeenCalledTimes(2)
})
it('keeps the receiving-warehouse confirmation disabled while candidates are loading', async () => {
let resolveCandidates!: (value: typeof receivingWarehouse[]) => void
vi.mocked(listAllPlatformWarehouses).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }) as never)
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
await button(wrapper, '修改接柜仓')!.trigger('click')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
resolveCandidates([receivingWarehouse])
await flushPromises()
expect(confirm.disabled).toBe(true)
})
it('does not expose a receiving-warehouse edit when the server locks the container', async () => {
@@ -390,6 +428,14 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps dense cabinet fields and actions on one line with ellipsis at 1920px', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue'), 'utf8')
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }')
expect(source).toContain("{ key: 'actions', label: '操作', width: 110, fixed: 'left'")
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
@@ -90,6 +90,10 @@ const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
&& row.warehouseId !== receivingWarehouseTarget.value?.overseasWarehouseId
)))
interface TransferWarehouseOption {
warehouseId: string
@@ -304,7 +308,7 @@ async function loadReceivingWarehouseOptions() {
function openReceivingWarehouseChange(container: ContainerPlanProgressSummaryDto) {
if (!container.canChangeReceivingWarehouse) return
receivingWarehouseTarget.value = container
Object.assign(receivingWarehouseForm, { warehouseId: container.overseasWarehouseId || '', remark: '' })
Object.assign(receivingWarehouseForm, { warehouseId: '', remark: '' })
receivingWarehouseIdempotencyKey = newReceivingWarehouseIdempotencyKey()
receivingWarehouseOptions.value = []
receivingWarehouseError.value = null
@@ -315,13 +319,14 @@ function openReceivingWarehouseChange(container: ContainerPlanProgressSummaryDto
}
async function submitReceivingWarehouseChange() {
const target = receivingWarehouseTarget.value
const selected = receivingWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === receivingWarehouseForm.warehouseId)
const selected = selectedReceivingWarehouse.value
if (!target || !selected) {
ElMessage.warning('请选择有效的海外接柜仓')
ElMessage.warning('请选择与当前仓不同的有效海外接柜仓')
return
}
actionLoading.value = true
actionError.value = null
receivingWarehouseActionError.value = null
try {
await changeContainerReceivingWarehouse(target.sourceContainerBookingId, {
overseasWarehouseId: selected.warehouseId,
@@ -508,11 +513,11 @@ onMounted(() => { void load() })
</header>
<div v-if="containerRows.length" class="container-workbench__grid">
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
<span class="container-workbench-card__top"><strong>{{ container.containerNo || '柜号待补充' }}</strong></span>
<span class="container-workbench-card__top"><strong :title="container.containerNo || '柜号待补充'">{{ container.containerNo || '柜号待补充' }}</strong></span>
<span class="container-workbench-card__warehouse">
<small>海外接柜仓</small><strong>{{ receivingWarehouseLabel(container) }}</strong>
<small v-if="containerSnapshotBlockReason(container)" class="is-locked">{{ containerSnapshotBlockReason(container) }}</small>
<small v-else-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked">{{ container.receivingWarehouseLockReason }}</small>
<small>海外接柜仓</small><strong :title="receivingWarehouseLabel(container)">{{ receivingWarehouseLabel(container) }}</strong>
<small v-if="containerSnapshotBlockReason(container)" class="is-locked" :title="containerSnapshotBlockReason(container)">{{ containerSnapshotBlockReason(container) }}</small>
<small v-else-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked" :title="container.receivingWarehouseLockReason">{{ container.receivingWarehouseLockReason }}</small>
</span>
<span class="container-workbench-card__arrival"><small>计划到仓</small><strong>{{ formatDateTime(container.plannedArrivalAt) }}</strong></span>
<span class="container-workbench-card__pending">
@@ -576,7 +581,7 @@ onMounted(() => { void load() })
<template v-else-if="containerDetail">
<div class="container-detail-summary">
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
<span>海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
<span :title="`海外接柜仓:${receivingWarehouseLabel(containerDetail.summary)}`">海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
@@ -588,7 +593,7 @@ onMounted(() => { void load() })
</DataTable>
</template>
</AppDialog>
<AppDialog v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!receivingWarehouseForm.warehouseId || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
<AppDialog v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedReceivingWarehouse || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
</LoadErrorState>
@@ -596,9 +601,10 @@ onMounted(() => { void load() })
<el-form-item label="当前海外接柜仓"><el-input :model-value="receivingWarehouseLabel(receivingWarehouseTarget)" readonly /></el-form-item>
<el-form-item label="新的海外接柜仓" required>
<el-select v-model="receivingWarehouseForm.warehouseId" filterable clearable placeholder="请选择 ACTIVE 海外接柜仓">
<el-option v-for="warehouse in receivingWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
<el-option v-for="warehouse in receivingWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" :disabled="warehouse.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" />
</el-select>
</el-form-item>
<el-alert v-if="receivingWarehouseForm.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" title="新的海外接柜仓必须与当前仓不同" type="warning" :closable="false" show-icon />
<el-alert v-if="!receivingWarehouseLoading && !receivingWarehouseOptions.length" title="暂无可用的 ACTIVE 海外接柜仓" type="info" :closable="false" show-icon />
<el-form-item label="备注"><el-input v-model="receivingWarehouseForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入修改备注(选填)" /></el-form-item>
<LoadErrorState v-if="receivingWarehouseActionError" title="海外接柜仓修改失败" :message="receivingWarehouseActionError.message" compact>
@@ -613,6 +619,7 @@ onMounted(() => { void load() })
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench { padding: 18px; }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.container-workbench__header > div { display: grid; gap: 4px; }
@@ -622,21 +629,22 @@ onMounted(() => { void load() })
.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: 0 6px 18px rgb(15 23 42 / 8%); }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.container-workbench-card__top strong { min-width: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 16px; }
.container-workbench-card__top strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: 13px; }
.container-workbench-card__arrival { display: grid; gap: 4px; }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: 15px; }
.container-workbench-card__warehouse { display: grid; gap: 4px; }
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__warehouse strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; }
.container-workbench-card__warehouse .is-locked { color: #c2410c; }
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__warehouse .is-locked { min-width: 0; overflow: hidden; color: #c2410c; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: 28px; line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: 13px; }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__facts .is-warning { color: #c2410c; }
.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }
.container-workbench-card__actions :deep(.el-button + .el-button) { margin-left: 0; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>