fix(admin): add inbound shortage confirmation action
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import InboundShortageConfirmation from './InboundShortageConfirmation.vue'
|
||||
|
||||
const access = vi.hoisted(() => ({ can: vi.fn(() => true) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access }))
|
||||
vi.mock('../api/inboundPlans', () => ({ confirmInboundShortage: vi.fn() }))
|
||||
const dialog = { name: 'AppDialog', props: ['modelValue', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<div v-if="modelValue"><slot /><button data-confirm :disabled="confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认并按实收入库</button></div>' }
|
||||
function detail(overrides: Partial<InboundPlanDetailDto> = {}): InboundPlanDetailDto {
|
||||
return { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
|
||||
orderType: 'FIRST_LEG', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
|
||||
lineCount: 1, palletCount: 2, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0,
|
||||
lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 2 }],
|
||||
pallets: [], allocations: [], ...overrides }
|
||||
}
|
||||
function render(value = detail()) {
|
||||
return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } })
|
||||
}
|
||||
describe('入库短少确认', () => {
|
||||
beforeEach(() => { access.can.mockReturnValue(true); vi.mocked(confirmInboundShortage).mockReset().mockResolvedValue({ status: 'PARTIAL_INBOUND' }) })
|
||||
it('requires a reason and submits actual counts without overwriting the expected count', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.text()).toContain('21')
|
||||
expect(wrapper.text()).toContain('20')
|
||||
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('textarea').setValue('现场清点短少1箱')
|
||||
await wrapper.get('[data-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', { customerId: '9007199254741001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '现场清点短少1箱' }, expect.any(String))
|
||||
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the reason on failure and uses fresh facts and a new key for retry', async () => {
|
||||
vi.mocked(confirmInboundShortage).mockRejectedValueOnce(new Error('记录已变化'))
|
||||
const wrapper = render()
|
||||
await wrapper.find('button').trigger('click'); await wrapper.get('textarea').setValue('短少1箱')
|
||||
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('短少1箱')
|
||||
await wrapper.setProps({ detail: detail({ version: 5 }) })
|
||||
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
|
||||
const calls = vi.mocked(confirmInboundShortage).mock.calls
|
||||
expect(calls[1]?.[1].version).toBe(5)
|
||||
expect(calls[1]?.[2]).not.toBe(calls[0]?.[2])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('hides the action without edit permission', () => {
|
||||
access.can.mockReturnValue(false)
|
||||
const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => {
|
||||
const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import { overseasWarehousePermissions } from '../permissions'
|
||||
|
||||
const props = defineProps<{ detail: InboundPlanDetailDto }>()
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const open = ref(false)
|
||||
const saving = ref(false)
|
||||
const reason = ref('')
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const expected = computed(() => props.detail.expectedPackageCount)
|
||||
const received = computed(() => props.detail.pickedPackageCount)
|
||||
const shortage = computed(() => (expected.value ?? 0) - (received.value ?? 0))
|
||||
const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit)
|
||||
&& props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null
|
||||
&& props.detail.orderType !== 'ONE_CLICK_DROPSHIP' && props.detail.lines.length > 0
|
||||
&& props.detail.lines.every(line => !!line.fbaWarehouseCode)
|
||||
&& expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
|
||||
|
||||
function show() {
|
||||
if (!eligible.value || saving.value) return
|
||||
reason.value = ''; error.value = null; open.value = true
|
||||
}
|
||||
async function confirm() {
|
||||
if (!open.value || saving.value || !eligible.value || !reason.value.trim()
|
||||
|| props.detail.version == null || expected.value == null || received.value == null) return
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
await confirmInboundShortage(props.detail.planNo, {
|
||||
customerId: props.detail.customerId, version: props.detail.version,
|
||||
expectedPackageCount: expected.value, receivedPackageCount: received.value, reason: reason.value.trim(),
|
||||
}, crypto.randomUUID())
|
||||
ElMessage.success(`已确认短少,${received.value} 箱实收已入库`)
|
||||
open.value = false
|
||||
emit('refresh')
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '短少确认失败,请核对最新收货记录后重试')
|
||||
emit('refresh')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button v-if="eligible" class="density-action" size="small" :disabled="saving" @click="show">确认短少入库</el-button>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" title="确认短少入库" width="560px"
|
||||
confirm-text="确认并按实收入库" :confirm-loading="saving" :confirm-disabled="!eligible || !reason.trim()" @confirm="confirm">
|
||||
<div class="density-dialog-sections">
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">入库单号</th><td>{{ detail.planNo }}</td></tr>
|
||||
<tr><th scope="row">应收箱数</th><td>{{ expected }}</td></tr>
|
||||
<tr><th scope="row">实收入库箱数</th><td>{{ received }}</td></tr>
|
||||
<tr><th scope="row">确认短少箱数</th><td>{{ shortage }}</td></tr>
|
||||
</tbody></table>
|
||||
<el-form class="density-form" label-position="left" label-width="100px" :disabled="saving">
|
||||
<el-form-item label="确认原因" required><el-input v-model="reason" aria-label="短少确认原因" type="textarea" :rows="2" maxlength="200" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="error" compact title="短少确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user