优化
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getInboundPlan, listInboundPlans } from './inboundPlans'
|
||||
import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
describe('inbound plan overseas API', () => {
|
||||
beforeEach(() => vi.mocked(http.get).mockReset())
|
||||
it('confirms the current shortage with customer, version and idempotency key', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { status: 'PARTIAL_INBOUND' } })
|
||||
const payload = { customerId: '190000000000000001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '短少1箱' }
|
||||
await confirmInboundShortage('IP/1', payload, 'request-key')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/confirm-shortage', payload, { headers: { 'Idempotency-Key': 'request-key' } })
|
||||
})
|
||||
it('pages plans with explicit customer scope', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||
await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' })
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface InboundPlanSummaryDto {
|
||||
}
|
||||
|
||||
export interface InboundPlanDetailDto {
|
||||
version?: number
|
||||
id: string
|
||||
planNo: string
|
||||
customerId: string
|
||||
@@ -81,6 +82,21 @@ export async function getInboundPlan(planNo: string, customerId: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } }))
|
||||
}
|
||||
|
||||
export interface ConfirmInboundShortagePayload {
|
||||
customerId: string
|
||||
version: number
|
||||
expectedPackageCount: number
|
||||
receivedPackageCount: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export async function confirmInboundShortage(planNo: string, payload: ConfirmInboundShortagePayload, idempotencyKey: string) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<{ status: string }>, ApiEnvelope<{ status: string }>>(
|
||||
`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/confirm-shortage`, payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
))
|
||||
}
|
||||
|
||||
export interface PlatformInventoryMoveRequest {
|
||||
overseasWarehouseId: string
|
||||
sourcePalletCode: string
|
||||
|
||||
@@ -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>
|
||||
@@ -46,6 +46,7 @@ const inboundPlanStatusLabels: Record<string, string> = {
|
||||
PDA_PENDING: '待入库',
|
||||
SIGNED: '已签收',
|
||||
PICKING: '拣货中',
|
||||
PENDING_RECONCILIATION: '待差异核对',
|
||||
PARTIAL_INBOUND: '部分入库',
|
||||
COMPLETED: '全部入库',
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { custom
|
||||
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
||||
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } }))
|
||||
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
|
||||
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
|
||||
} }))
|
||||
|
||||
@@ -15,6 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
@@ -98,7 +99,7 @@ onMounted(load)
|
||||
<template><OverseasDetailShell workbench 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">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><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></div></section>
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><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></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 v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template>
|
||||
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-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">
|
||||
|
||||
@@ -42,7 +42,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
|
||||
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }, { label: '待差异核对', value: 'PENDING_RECONCILIATION' }] },
|
||||
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
|
||||
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
|
||||
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }, { label: '多批预约', value: 'MULTIPLE' }] },
|
||||
|
||||
Reference in New Issue
Block a user