feat(overseas): select stock transfer sources and follow relabel
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WarehouseTransferFulfillmentPanel from './WarehouseTransferFulfillmentPanel.vue'
|
||||
import { allocateWarehouseTransfer, getWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
import { listWarehouseTransferSources } from '../api/warehouseTransferSources'
|
||||
|
||||
const { push, can } = vi.hoisted(() => ({ push: vi.fn(), can: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), allocateWarehouseTransfer: vi.fn() }))
|
||||
vi.mock('../api/warehouseTransferSources', () => ({ listWarehouseTransferSources: vi.fn() }))
|
||||
const dialogStub = defineComponent({ props: { modelValue: Boolean, confirmDisabled: Boolean, confirmText: String }, emits: ['confirm', 'update:modelValue'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', { class: 'allocation-dialog' }, [slots.default?.(), h('button', { class: 'confirm-allocation', disabled: props.confirmDisabled, onClick: () => emit('confirm') }, props.confirmText)]) : null } })
|
||||
enableAutoUnmount(afterEach)
|
||||
function fixture(transferNo = 'WT-1', values: Partial<WarehouseTransferDetailDto> = {}): WarehouseTransferDetailDto {
|
||||
return { id: 'internal-transfer-id', transferNo, packageCount: 2, needRelabel: true, status: 'APPROVED', items: [], cartons: [], pallets: [], relabelBindings: [], ...values }
|
||||
}
|
||||
function render(customerId = 'customer-1', transferNo = 'WT-1') {
|
||||
return mount(WarehouseTransferFulfillmentPanel, { props: { customerId, transferNo }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub } } })
|
||||
}
|
||||
describe('WarehouseTransferFulfillmentPanel', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture()) })
|
||||
it('requires manage and inventory read permissions before offering allocation', async () => {
|
||||
can.mockImplementation(code => code !== 'lastleg:warehouse-transfer:manage')
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('选择库存来源 / 分配')
|
||||
expect(listWarehouseTransferSources).not.toHaveBeenCalled()
|
||||
})
|
||||
it('confirms exact quantities and preserves original body/key through unknown failure, close and reopen', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ issues: [], candidates: [{ inventoryPositionId: 'POSITION', palletId: 'PALLET', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-1', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }, { inventoryPositionId: 'HOLD', palletId: 'PALLET-HOLD', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-HOLD', warehouseLocationCode: 'LOC-1', availablePackages: 0, canSelect: false, blockReason: '当前无可分配箱数' }] })
|
||||
vi.mocked(allocateWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(fixture('WT-1', { status: 'WAIT_RELABEL', relabelNo: 'RL-NEW', relabelStatus: 'PENDING_REVIEW' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
const available = wrapper.find('.allocation-dialog').get('label[aria-label="选择 DP-1 OLD"] input')
|
||||
const held = wrapper.find('.allocation-dialog').get('label[aria-label="选择 DP-HOLD OLD"] input')
|
||||
expect(held.attributes('disabled')).toBeDefined()
|
||||
await available.setValue(true); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeUndefined()
|
||||
const quantity = wrapper.get('input[aria-label="DP-1 分配箱数"]')
|
||||
expect(quantity.attributes('max')).toBe('3')
|
||||
await quantity.setValue('2'); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
await quantity.setValue('1'); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const [originalCustomer, originalTransferNo, originalPayload] = vi.mocked(allocateWarehouseTransfer).mock.calls[0]!
|
||||
const original = [originalCustomer, originalTransferNo, { idempotencyKey: originalPayload.idempotencyKey, allocations: originalPayload.allocations.map(row => ({ ...row })) }]
|
||||
expect(original).toEqual(['customer-1', 'WT-1', { idempotencyKey: expect.any(String), allocations: [{ inventoryPositionId: 'POSITION', palletId: 'PALLET', packageCount: 1 }] }])
|
||||
expect(wrapper.text()).toContain('已保留本次选择,重试使用相同内容')
|
||||
expect(wrapper.text()).not.toContain('幂等键')
|
||||
wrapper.getComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '重试本次库存分配')!.trigger('click'); await flushPromises()
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(allocateWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
expect(wrapper.text()).toContain('待换标'); expect(wrapper.text()).toContain('RL-NEW')
|
||||
})
|
||||
it('keeps ambiguous sources and source query errors explicit and cannot submit them', async () => {
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValueOnce({ candidates: [], issues: ['原票库存代表不匹配,待核查'] }).mockRejectedValueOnce(new Error('query lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('待核查'); expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
wrapper.getComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源库存加载失败,请重试')
|
||||
expect(allocateWarehouseTransfer).not.toHaveBeenCalled()
|
||||
})
|
||||
it('recovers a definite inventory conflict through an explicit fresh choice and submits a new command', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
|
||||
const row = { inventoryPositionId: 'OLD-POSITION', palletId: 'OLD-PALLET', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-OLD', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValueOnce({ issues: [], candidates: [row] }).mockResolvedValueOnce({ issues: [], candidates: [{ ...row, inventoryPositionId: 'NEW-POSITION', palletId: 'NEW-PALLET', palletNo: 'DP-NEW', availablePackages: 1 }] })
|
||||
vi.mocked(allocateWarehouseTransfer).mockRejectedValueOnce(new ApiResponseError('库存可用箱数已变化', 'A0409', 200, undefined, 'conflict-request', 'conflict-trace')).mockResolvedValueOnce(fixture('WT-1', { status: 'WAIT_RELABEL', relabelNo: 'RL-RECOVERED' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('label[aria-label="选择 DP-OLD OLD"] input').setValue(true); await flushPromises()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const oldPayload = vi.mocked(allocateWarehouseTransfer).mock.calls[0]![2]
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('重新选择来源')
|
||||
wrapper.getComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '查看分配问题')!.trigger('click'); await flushPromises()
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('button').find(x => x.text() === '重新选择来源')!.trigger('click'); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.get('[aria-label="上次分配诊断"]').text()).toContain('库存可用箱数已变化')
|
||||
expect(wrapper.get('[aria-label="上次分配诊断"]').text()).toContain('conflict-request')
|
||||
expect(wrapper.find('label[aria-label="选择 DP-OLD OLD"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('label[aria-label="选择 DP-NEW OLD"] input').setValue(true); await flushPromises()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const newPayload = vi.mocked(allocateWarehouseTransfer).mock.calls[1]![2]
|
||||
expect(newPayload.idempotencyKey).not.toBe(oldPayload.idempotencyKey)
|
||||
expect(newPayload.allocations).toEqual([{ inventoryPositionId: 'NEW-POSITION', palletId: 'NEW-PALLET', packageCount: 1 }])
|
||||
expect(oldPayload.allocations).toEqual([{ inventoryPositionId: 'OLD-POSITION', palletId: 'OLD-PALLET', packageCount: 1 }])
|
||||
expect(wrapper.text()).toContain('RL-RECOVERED')
|
||||
expect(wrapper.text()).not.toContain('幂等键')
|
||||
})
|
||||
it.each([
|
||||
new ApiResponseError('服务响应异常', 'A0409', 503),
|
||||
new ApiResponseError('身份响应异常', 'A0409', 401),
|
||||
new ApiResponseError('分配响应身份不一致', 'TRANSFER_IDENTITY_MISMATCH', 200),
|
||||
])('keeps uncertain %s outcomes locked to the original command', async cause => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ issues: [], candidates: [{ inventoryPositionId: 'POSITION', palletId: 'PALLET', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-1', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }] })
|
||||
vi.mocked(allocateWarehouseTransfer).mockRejectedValueOnce(cause).mockResolvedValueOnce(fixture('WT-1', { status: 'WAIT_RELABEL', relabelNo: 'RL-RETRIED' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('label[aria-label="选择 DP-1 OLD"] input').setValue(true); await flushPromises()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(allocateWarehouseTransfer).mock.calls[0]![2]
|
||||
expect(wrapper.text()).not.toContain('重新选择来源')
|
||||
expect(wrapper.get('label[aria-label="选择 DP-1 OLD"] input').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(allocateWarehouseTransfer).mock.calls[1]![2]).toEqual(original)
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('reads only the exact source once and blocks missing relabel prerequisites', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-1')
|
||||
expect(wrapper.text()).toContain('尚未关联换标单')
|
||||
expect(wrapper.text()).toContain('不得提前出库')
|
||||
expect(wrapper.text()).not.toContain('internal-transfer-id')
|
||||
expect(wrapper.findAll('button').some(x => /出库|跳过|确认换标完成|分配库存/.test(x.text()))).toBe(false)
|
||||
})
|
||||
it('links the business relabel number and keeps customer confirmation pending', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { status: 'WAIT_RELABEL', relabelApplicationId: 'internal-relabel-id', relabelNo: 'RL-1', relabelStatus: 'CUSTOMER_CONFIRM_PENDING' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('待客户确认'); expect(wrapper.text()).toContain('不得提前出库')
|
||||
expect(wrapper.text()).not.toContain('internal-relabel-id')
|
||||
await wrapper.findAll('button').find(x => x.text() === '查看本票换标单')!.trigger('click')
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-relabel-order-detail', params: { orderId: 'RL-1' } })
|
||||
})
|
||||
it('requires relabel read permission and preserves later inventory gates', async () => {
|
||||
can.mockReturnValue(false)
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { relabelNo: 'RL-1', relabelStatus: 'READY_FOR_OUTBOUND' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('后续出库仍须'); expect(wrapper.text()).not.toContain('查看本票换标单')
|
||||
})
|
||||
it('never displays MIXED or internal allocation identities as actual cargo', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { allocations: [{ inventoryPositionId: 'internal-position', inboundOrderId: 'internal-inbound', inboundCode: 'IN-1', palletId: 'internal-pallet', palletNo: 'PALLET-1', packageCount: 2, status: 'RESERVED', skuCode: 'MIXED' }] }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('PALLET-1')
|
||||
for (const value of ['MIXED', 'internal-position', 'internal-inbound', 'internal-pallet']) expect(wrapper.text()).not.toContain(value)
|
||||
})
|
||||
it('does not fall back from missing source number to an internal or tail order ID', async () => {
|
||||
const wrapper = render('customer-1', ''); await flushPromises()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少客户或来源转运单号')
|
||||
})
|
||||
it('shows a real empty allocation state for transfers that do not need relabel', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { needRelabel: false, status: 'ALLOCATED', allocations: [] }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('尚未确认本票库存来源分配')
|
||||
expect(wrapper.text()).not.toContain('不得提前出库')
|
||||
expect(wrapper.text()).not.toContain('查看本票换标单')
|
||||
})
|
||||
it('does not query across customer scope when the customer is missing', async () => {
|
||||
const wrapper = render('', 'WT-1'); await flushPromises()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少客户或来源转运单号')
|
||||
})
|
||||
it('does not display a response that arrives after the page is closed', async () => {
|
||||
let resolve!: (value: WarehouseTransferDetailDto) => void
|
||||
vi.mocked(getWarehouseTransfer).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
const wrapper = render(); wrapper.unmount()
|
||||
resolve(fixture('WT-1', { relabelNo: 'RL-LATE' })); await flushPromises()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('ignores old source responses and rejects a mismatched transfer identity', async () => {
|
||||
let resolveFirst!: (value: WarehouseTransferDetailDto) => void
|
||||
vi.mocked(getWarehouseTransfer).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve })).mockResolvedValueOnce(fixture('WT-2'))
|
||||
const wrapper = render(); await wrapper.setProps({ transferNo: 'WT-2' }); await flushPromises()
|
||||
resolveFirst(fixture('WT-1', { relabelNo: 'RL-OLD' })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('WT-2'); expect(wrapper.text()).not.toContain('RL-OLD')
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValueOnce(fixture('WT-WRONG'))
|
||||
await wrapper.findAll('button').find(x => x.text() === '刷新转运状态')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('返回的转运单与当前票不一致'); expect(wrapper.text()).not.toContain('WT-WRONG')
|
||||
})
|
||||
it('clears stale data on read failure and retries explicitly', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
vi.mocked(getWarehouseTransfer).mockRejectedValueOnce(new Error('测试读取失败'))
|
||||
await wrapper.findAll('button').find(x => x.text() === '刷新转运状态')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('转运履约加载失败,请重试'); expect(wrapper.text()).not.toContain('尚未关联换标单')
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValueOnce(fixture('WT-1', { relabelNo: 'RL-RETRY' }))
|
||||
await wrapper.findAll('button').find(x => x.text().includes('重新加载'))!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('RL-RETRY')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { allocateWarehouseTransfer, getWarehouseTransfer, type AllocateWarehouseTransferPayload, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
import { listWarehouseTransferSources, type WarehouseTransferSourceCandidate } from '../api/warehouseTransferSources'
|
||||
import { lastMileStatusLabel, relabelApplicationStatusLabel } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const props = defineProps<{ customerId: string; transferNo: string }>()
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<WarehouseTransferDetailDto | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
let sourceRequest = 0
|
||||
const allocationOpen = ref(false), sourceLoading = ref(false), allocationSaving = ref(false)
|
||||
const sourceError = ref<ApiErrorDetails | null>(null), allocationError = ref<ApiErrorDetails | null>(null)
|
||||
const sourceRows = ref<WarehouseTransferSourceCandidate[]>([]), sourceIssues = ref<string[]>([])
|
||||
const selected = ref<string[]>([]), quantities = ref<Record<string, number>>({})
|
||||
const originalCommand = ref<AllocateWarehouseTransferPayload | null>(null)
|
||||
const allocationRejected = ref(false), previousRejection = ref<ApiErrorDetails | null>(null)
|
||||
function isDefiniteAllocationRejection(cause: unknown) {
|
||||
const rejectionCodes = ['A0400', 'A0404', 'A0409', 'A0422']
|
||||
const knownStatus = (status: number | undefined) => status === undefined || [200, 400, 404, 409, 422].includes(status)
|
||||
if (cause instanceof ApiResponseError) return rejectionCodes.includes(cause.code) && knownStatus(cause.status)
|
||||
if (axios.isAxiosError<{ code?: string }>(cause)) return !!cause.response && knownStatus(cause.response.status) && rejectionCodes.includes(cause.response.data?.code || '')
|
||||
return false
|
||||
}
|
||||
const canAllocate = computed(() => access.can('lastleg:warehouse-transfer:manage') && access.can(permission.inventoryList) && (detail.value?.status === 'APPROVED' || originalCommand.value !== null))
|
||||
const selectedPackages = computed(() => selected.value.reduce((sum, id) => sum + (quantities.value[id] || 0), 0))
|
||||
const selectionValid = computed(() => !!detail.value && !sourceIssues.value.length && selected.value.length > 0 && selectedPackages.value === detail.value.packageCount && selected.value.every(id => {
|
||||
const row = sourceRows.value.find(item => item.inventoryPositionId === id), count = quantities.value[id] || 0
|
||||
return row?.canSelect && Number.isSafeInteger(count) && count > 0 && count <= row.availablePackages
|
||||
}))
|
||||
const sourceColumns: DataTableColumn[] = [
|
||||
{ key: 'selection', label: '选择', width: 70, fixed: 'left', slotName: 'sourceSelection' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '来源入仓码', minWidth: 160 },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘码', minWidth: 160 },
|
||||
{ key: 'warehouseLocationCode', prop: 'warehouseLocationCode', label: '库位', minWidth: 110 },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: '实际旧 SKU', minWidth: 130 },
|
||||
{ key: 'productNameZh', prop: 'productNameZh', label: '品名', minWidth: 130 },
|
||||
{ key: 'availablePackages', prop: 'availablePackages', label: '当前可分配', width: 100 },
|
||||
{ key: 'quantity', label: '本次箱数', width: 130, slotName: 'sourceQuantity' },
|
||||
{ key: 'blockReason', prop: 'blockReason', label: '不可选择原因', minWidth: 200 },
|
||||
]
|
||||
async function openAllocation() {
|
||||
if (!canAllocate.value) return
|
||||
allocationOpen.value = true
|
||||
if (!originalCommand.value) await loadSources()
|
||||
}
|
||||
async function loadSources() {
|
||||
if (originalCommand.value) return
|
||||
if (!detail.value) {
|
||||
await load()
|
||||
if (error.value) { sourceError.value = error.value; return }
|
||||
}
|
||||
if (!detail.value || !canAllocate.value) return
|
||||
const current = ++sourceRequest
|
||||
sourceLoading.value = true; sourceError.value = null; allocationError.value = null
|
||||
sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
|
||||
try {
|
||||
const result = await listWarehouseTransferSources(props.customerId, detail.value)
|
||||
if (current === sourceRequest) { sourceRows.value = result.candidates; sourceIssues.value = result.issues }
|
||||
} catch (cause) { if (current === sourceRequest) sourceError.value = getApiErrorDetails(cause, '来源库存加载失败,请重试') }
|
||||
finally { if (current === sourceRequest) sourceLoading.value = false }
|
||||
}
|
||||
function selectSource(id: string, value: boolean) {
|
||||
const row = sourceRows.value.find(candidate => candidate.inventoryPositionId === id)
|
||||
if (!row?.canSelect || originalCommand.value) return
|
||||
selected.value = value ? [...new Set([...selected.value, row.inventoryPositionId])] : selected.value.filter(id => id !== row.inventoryPositionId)
|
||||
if (value && !quantities.value[row.inventoryPositionId]) quantities.value[row.inventoryPositionId] = 1
|
||||
}
|
||||
async function confirmAllocation() {
|
||||
if (allocationSaving.value || allocationRejected.value || !canAllocate.value || (!originalCommand.value && !selectionValid.value)) return
|
||||
if (!originalCommand.value) originalCommand.value = { idempotencyKey: crypto.randomUUID(), allocations: selected.value.map(id => ({ inventoryPositionId: id, palletId: sourceRows.value.find(row => row.inventoryPositionId === id)!.palletId, packageCount: quantities.value[id]! })) }
|
||||
const customerId = props.customerId, transferNo = props.transferNo, command = originalCommand.value
|
||||
allocationSaving.value = true; allocationError.value = null
|
||||
try {
|
||||
const value = await allocateWarehouseTransfer(customerId, transferNo, command)
|
||||
if (customerId !== props.customerId || transferNo !== props.transferNo) return
|
||||
if (value.transferNo !== transferNo) throw new ApiResponseError('分配响应与当前转运单不一致,请使用原操作核查。', 'TRANSFER_IDENTITY_MISMATCH')
|
||||
detail.value = value; originalCommand.value = null; allocationOpen.value = false
|
||||
previousRejection.value = null
|
||||
} catch (cause) {
|
||||
if (customerId !== props.customerId || transferNo !== props.transferNo) return
|
||||
allocationRejected.value = isDefiniteAllocationRejection(cause)
|
||||
allocationError.value = getApiErrorDetails(cause, '库存分配结果尚未确认,已保留本次选择,重试使用相同内容')
|
||||
}
|
||||
finally { allocationSaving.value = false }
|
||||
}
|
||||
async function reselectSources() {
|
||||
if (!allocationRejected.value || allocationSaving.value || !canAllocate.value) return
|
||||
previousRejection.value = allocationError.value
|
||||
allocationRejected.value = false; originalCommand.value = null; allocationError.value = null
|
||||
sourceRequest++; sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
|
||||
await load()
|
||||
if (error.value) { sourceError.value = error.value; return }
|
||||
if (detail.value?.status !== 'APPROVED') {
|
||||
sourceError.value = { message: '当前转运状态已变化,请核对最新履约后再分配库存。' }
|
||||
return
|
||||
}
|
||||
await loadSources()
|
||||
}
|
||||
const fields = computed(() => detail.value ? [
|
||||
['转运单号', detail.value.transferNo], ['转运状态', lastMileStatusLabel(detail.value.status)],
|
||||
['需要换标', detail.value.needRelabel ? '是' : '否'], ['关联换标单', detail.value.relabelNo || '未关联'],
|
||||
['换标状态', detail.value.relabelStatus ? relabelApplicationStatusLabel(detail.value.relabelStatus) : '—'],
|
||||
] : [])
|
||||
const gateText = computed(() => {
|
||||
if (!detail.value?.needRelabel) return ''
|
||||
if (!detail.value.relabelNo) return '尚未关联换标单。须先确认本票库存来源并分配,再按关联换标单完成 PDA 作业、复核和客户确认;不得提前出库。'
|
||||
if (!['READY_FOR_OUTBOUND', 'COMPLETED'].includes(detail.value.relabelStatus || '')) return '本票换标尚未完成复核和客户确认,不得提前出库。请沿关联换标单查看 PDA 作业及客户确认进度。'
|
||||
return '换标已完成。后续出库仍须按转运单与服务端库存门禁执行。'
|
||||
})
|
||||
const allocationRows = computed(() => (detail.value?.allocations || []).map(row => ({ ...row, skuCode: row.skuCode && row.skuCode !== 'MIXED' ? row.skuCode : '—' })))
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '来源入仓码', minWidth: 160 },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '来源托盘码', minWidth: 180 },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: '实际 SKU', minWidth: 150 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '分配箱数', width: 100 },
|
||||
]
|
||||
async function load() {
|
||||
const current = ++request
|
||||
detail.value = null; error.value = null; loading.value = false
|
||||
if (!props.customerId || !props.transferNo) { error.value = { message: '本票缺少客户或来源转运单号,无法读取转运履约。' }; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const value = await getWarehouseTransfer(props.customerId, props.transferNo)
|
||||
if (current !== request) return
|
||||
if (value.transferNo !== props.transferNo) throw new ApiResponseError('返回的转运单与当前票不一致,请重新加载。', 'TRANSFER_IDENTITY_MISMATCH')
|
||||
detail.value = value
|
||||
} catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '转运履约加载失败,请重试') }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
function openRelabel() {
|
||||
if (detail.value?.relabelNo && access.can(permission.relabelOrderList)) void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: detail.value.relabelNo } })
|
||||
}
|
||||
watch(() => [props.customerId, props.transferNo], () => {
|
||||
sourceRequest++; allocationOpen.value = false; originalCommand.value = null
|
||||
sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
|
||||
sourceError.value = null; allocationError.value = null
|
||||
allocationRejected.value = false; previousRejection.value = null
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { request++; sourceRequest++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="transfer-fulfillment" aria-label="本票转运与换标履约" :aria-busy="loading">
|
||||
<div class="transfer-fulfillment-heading"><h2>转运与换标履约</h2><div><el-button v-if="canAllocate" @click="openAllocation">{{ allocationRejected ? '查看分配问题' : originalCommand ? '重试本次库存分配' : '选择库存来源 / 分配' }}</el-button><el-button :loading="loading" :disabled="allocationSaving" @click="load">刷新转运状态</el-button></div></div>
|
||||
<LoadErrorState v-if="error" compact title="转运履约加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<dl class="density-fields"><div v-for="[label, value] in fields" :key="label"><dt>{{ label }}</dt><dd>{{ value }}</dd></div></dl>
|
||||
<p v-if="gateText" role="status">{{ gateText }}</p>
|
||||
<el-button v-if="detail.relabelNo && access.can(permission.relabelOrderList)" @click="openRelabel">查看本票换标单</el-button>
|
||||
<DataTable :data="allocationRows" :columns="columns" row-key="inventoryPositionId" :show-pagination="false" empty-text="尚未确认本票库存来源分配" />
|
||||
</template>
|
||||
</section>
|
||||
<AppDialog v-model="allocationOpen" class="admin-density-dialog" title="选择本票库存来源" width="min(1320px, calc(100vw - 40px))" :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
|
||||
<p>{{ transferNo }} · 本票 {{ detail?.packageCount }} 箱 · 已选择 {{ selectedPackages }} 箱</p>
|
||||
<p v-if="originalCommand && !allocationRejected" role="status">分配结果尚未确认,已保留本次选择,重试使用相同内容。</p>
|
||||
<p v-if="allocationRejected" role="status">本次分配未成功,请重新选择来源并核对当前可分配箱数后再次提交。</p>
|
||||
<section v-if="previousRejection" aria-label="上次分配诊断"><p>上次分配未成功:{{ previousRejection.message }}</p><ApiErrorMeta :details="previousRejection" /></section>
|
||||
<LoadErrorState v-if="sourceError" compact title="来源库存加载失败" :message="sourceError.message" @retry="loadSources"><template #details><ApiErrorMeta :details="sourceError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
|
||||
<p v-for="issue in sourceIssues" :key="issue" role="status">{{ issue }}</p>
|
||||
<DataTable v-if="!sourceError" :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
|
||||
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
|
||||
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transfer-fulfillment { padding-bottom: var(--admin-space-5); }
|
||||
.transfer-fulfillment-heading { display: flex; align-items: center; justify-content: space-between; }
|
||||
.transfer-fulfillment-heading h2 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.transfer-fulfillment p { color: var(--cc-muted); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user