解决海外仓前端提交冲突

This commit is contained in:
2026-08-28 14:41:40 +08:00
13 changed files with 122 additions and 71 deletions
@@ -11,14 +11,14 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { listCustomerWarehouses, type CustomerWarehouseDto } from '../api/pallets'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
import { overseasWarehousePermissions } from '../permissions'
import type { InventoryListItem } from '../types'
const router = useRouter()
const access = useAccessPreviewStore()
const warehouses = ref<CustomerWarehouseDto[]>([])
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
const rows = ref<InventoryListItem[]>([])
const summary = ref<InventoryDashboardDto | null>(null)
const loading = ref(false)
@@ -59,7 +59,7 @@ const columns: DataTableColumn[] = [
async function loadWarehouses() {
if (!access.can(overseasWarehousePermissions.warehouseList)) return
contextLoading.value = true; loadError.value = null
try { warehouses.value = (await listCustomerWarehouses()).records }
try { warehouses.value = (await listReceivingOverseasWarehouses()).records }
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
finally { contextLoading.value = false }
}
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getPallet, listCustomerWarehouses, type PalletDto } from '../api/pallets'
import { getPallet, listReceivingOverseasWarehouses, type PalletDto } from '../api/pallets'
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
@@ -30,7 +30,7 @@ async function load() {
if (!warehouseId.value || palletId.value === '—') { loadError.value = getApiErrorDetails(new Error('托盘详情缺少仓库参数')); return }
loading.value = true; loadError.value = null
try {
const [pallet, warehouses] = await Promise.all([getPallet(warehouseId.value, palletId.value), listCustomerWarehouses()])
const [pallet, warehouses] = await Promise.all([getPallet(warehouseId.value, palletId.value), listReceivingOverseasWarehouses()])
detail.value = pallet
const warehouse = warehouses.records.find((item) => String(item.id) === warehouseId.value)
warehouseName.value = warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : warehouseId.value
@@ -4,13 +4,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '../api/pallets'
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
import PalletsView from './PalletsView.vue'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), listCustomerWarehouses: vi.fn(), listPallets: vi.fn(), updatePallet: vi.fn() }))
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
@@ -21,9 +21,9 @@ describe('PalletsView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listCustomerWarehouses).mockReset().mockResolvedValue({ records: [{ id: '200', customerId: '0', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([{ id: '300', warehouseCode: 'ONT8', warehouseName: 'ONT8', warehouseType: 'FBA', status: 'ACTIVE' } as never])
const pallet = { id: '9007199254740993', customerId: '0', warehouseId: '200', locationId: null, palletCode: 'PLT-1', palletType: 'WOOD', fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: null, version: 7 }
vi.mocked(listReceivingOverseasWarehouses).mockReset().mockResolvedValue({ records: [{ id: '200', customerId: '0', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([{ id: '300', warehouseCode: 'ABE3', warehouseName: 'ABE3', warehouseType: 'FBA', status: 'ACTIVE' } as never])
const pallet = { id: '9007199254740993', customerId: '0', warehouseId: '200', locationId: null, palletCode: 'PLT-1', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: null, version: 7 }
vi.mocked(listPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
vi.mocked(createPallet).mockReset()
@@ -44,9 +44,31 @@ describe('PalletsView', () => {
expect(getPallet).toHaveBeenCalledWith('200', '9007199254740993')
expect(updatePallet).toHaveBeenCalledWith('200', '9007199254740993', expect.objectContaining({
palletCode: 'PLT-1', fbaWarehouseCode: 'ONT8', version: 7,
palletCode: 'PLT-1', fbaWarehouseCode: 'ABE3', version: 7,
}))
expect(dialog.props('modelValue')).toBe(true)
expect(createPallet).not.toHaveBeenCalled()
})
it('creates an IDLE ABE3 pallet under the selected Los Angeles warehouse id', async () => {
vi.mocked(createPallet).mockResolvedValueOnce('9007199254740994')
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增托盘'))!.trigger('click')
await flushPromises()
const warehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择海外仓')!
const fbaWarehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择所属 FBA 仓')!
const palletCode = wrapper.findAllComponents({ name: 'ElInput' }).find((item) => item.props('placeholder') === '请输入托盘编码')!
warehouse.vm.$emit('update:modelValue', '200')
fbaWarehouse.vm.$emit('update:modelValue', 'ABE3')
palletCode.vm.$emit('update:modelValue', 'PLT-LAX-ABE3')
await flushPromises()
wrapper.findComponent(AppDialog).vm.$emit('confirm')
await flushPromises()
expect(createPallet).toHaveBeenCalledWith('200', expect.objectContaining({
palletCode: 'PLT-LAX-ABE3', fbaWarehouseCode: 'ABE3', status: 'IDLE',
}))
})
})
@@ -14,13 +14,13 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet, type CustomerWarehouseDto, type PalletDto } from '../api/pallets'
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletListItem } from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const warehouses = ref<CustomerWarehouseDto[]>([])
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
const fbaWarehouses = ref<PlatformWarehouseResource[]>([])
const rows = ref<PalletListItem[]>([])
const loading = ref(false)
@@ -63,7 +63,7 @@ async function loadWarehouses() {
contextLoading.value = true; loadError.value = null; loadErrorKind.value = 'context'
try {
const [warehousePage, platformFbaWarehouses] = await Promise.all([
listCustomerWarehouses(),
listReceivingOverseasWarehouses(),
listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' }),
])
warehouses.value = warehousePage.records
@@ -23,8 +23,6 @@ vi.mock('../api/relabelOrders', () => ({
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done }); return { promise, resolve } }
function formItem(wrapper: VueWrapper<any>, label: string) {
const item = wrapper.findAllComponents({ name: 'ElFormItem' }).find((candidate) => candidate.props('label') === label)
if (!item) throw new Error(`Missing form item: ${label}`)
@@ -51,10 +49,8 @@ describe('RelabelOrdersView', () => {
vi.mocked(changeRelabelOrderStatus).mockReset()
})
it('keeps only the latest selected customer warehouse response', async () => {
const first = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
const second = deferred<Awaited<ReturnType<typeof listRelabelWarehouseOptions>>>()
vi.mocked(listRelabelWarehouseOptions).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
it('loads shared receiving warehouse options independently from the selected customer', async () => {
vi.mocked(listRelabelWarehouseOptions).mockResolvedValue({ records: [{ id: 'WH-B', customerId: '0', warehouseCode: 'B', warehouseName: 'B仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
@@ -63,17 +59,10 @@ describe('RelabelOrdersView', () => {
const customer = select(wrapper, '请选择 ACTIVE 客户')
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
await flushPromises()
customer.vm.$emit('update:modelValue', '10002'); customer.vm.$emit('change', '10002')
await flushPromises()
second.resolve({ records: [{ id: 'WH-B', customerId: '10002', warehouseCode: 'B', warehouseName: 'B仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
first.resolve({ records: [{ id: 'WH-A', customerId: '10001', warehouseCode: 'A', warehouseName: 'A仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
expect(listRelabelWarehouseOptions).toHaveBeenCalledOnce()
expect(labels).toContain('B仓 · B')
expect(labels).not.toContain('A仓 · A')
})
it('reuses one idempotency key after a failed create retry', async () => {
@@ -87,7 +76,7 @@ describe('RelabelOrdersView', () => {
const customer = select(wrapper, '请选择 ACTIVE 客户')
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
await flushPromises()
const warehouse = select(wrapper, '请先选择客户,再选择其海外仓')
const warehouse = select(wrapper, '请选择 ACTIVE+RECEIVING 海外仓')
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')
@@ -27,7 +27,6 @@ const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const warehouses = ref<RelabelWarehouseOptionDto[]>([])
const warehousesLoading = ref(false)
const warehouseRequestSequence = ref(0)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -75,6 +74,7 @@ function openForm(mode: 'create' | 'edit', source?: object) {
createIdempotencyKey.value = globalThis.crypto?.randomUUID?.() || `admin-relabel-${Date.now()}`
warehouses.value = []
void loadCustomers()
void loadWarehouses()
}
dialogOpen.value = true
}
@@ -96,19 +96,17 @@ async function loadCustomers() {
finally { customersLoading.value = false }
}
async function handleCustomerChange(customerId: string) {
const requestSequence = ++warehouseRequestSequence.value
async function loadWarehouses() {
form.warehouseId = ''
warehouses.value = []
if (!customerId) { warehousesLoading.value = false; return }
warehousesLoading.value = true
try {
const records = (await listRelabelWarehouseOptions(customerId)).records.filter((item) => item.status === 'ACTIVE')
if (requestSequence === warehouseRequestSequence.value && customerId === form.customerId) warehouses.value = records
const records = (await listRelabelWarehouseOptions()).records.filter((item) => item.status === 'ACTIVE')
warehouses.value = records
} catch (error) {
if (requestSequence === warehouseRequestSequence.value) ElMessage.error(getApiErrorDetails(error, '海外仓列表加载失败,请重试').message)
ElMessage.error(getApiErrorDetails(error, '公共海外收货仓加载失败,请重试').message)
} finally {
if (requestSequence === warehouseRequestSequence.value) warehousesLoading.value = false
warehousesLoading.value = false
}
}
@@ -116,7 +114,7 @@ async function submitForm() {
if (submitting.value) return
try {
if (dialogMode.value === 'create') {
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请先选择客户和该客户的海外仓'); return }
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请选择客户和公共海外收货仓'); return }
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
ElMessage.warning('请完整填写换标单必填信息')
return
@@ -188,8 +186,8 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<el-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 客户" @change="handleCustomerChange"><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" :disabled="!form.customerId" 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 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" :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" /></el-form-item></el-col>