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
@@ -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) })
})