fix: close appointment cancellation bypass and clarify verified cargo
This commit is contained in:
@@ -22,7 +22,8 @@ export function appointmentPayload(form: AppointmentForm): FbaAppointmentDetails
|
||||
startOffset: form.startOffset.trim() || null, endOffset: form.endOffset.trim() || null }
|
||||
}
|
||||
export function appointmentFormError(form: AppointmentForm): string | null {
|
||||
if (form.appointmentStatus === 'CANCELLED') return '请在预约列表使用取消预约操作,并填写实际取消原因'
|
||||
if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间'
|
||||
if (['FAILED', 'CANCELLED'].includes(form.appointmentStatus) && !form.appointmentRemark.trim()) return '请填写取消或未约成的原因'
|
||||
if (form.appointmentStatus === 'FAILED' && !form.appointmentRemark.trim()) return '请填写未约成的原因'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string
|
||||
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
|
||||
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
|
||||
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
|
||||
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="appointmentNo" label="取消安排" value="CANCELLED" /></el-select></el-form-item>
|
||||
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
|
||||
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
||||
<el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="['FAILED','CANCELLED'].includes(model.appointmentStatus) ? '原因' : '备注'" :required="['FAILED','CANCELLED'].includes(model.appointmentStatus)"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
|
||||
<el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
|
||||
<details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
|
||||
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
|
||||
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import InboundOrdersView from './InboundOrdersView.vue'
|
||||
import { getFbaAppointment, updateFbaAppointment } from '../api/inboundOrders'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({push: vi.fn()}), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({can: () => true}) }))
|
||||
vi.mock('../api/inboundOrders', () => ({
|
||||
listInboundOrders: vi.fn().mockResolvedValue({records:[{lineId:'1',planNo:'IP-1',customerId:'A',appointmentNo:'AP-1',fbaWarehouseCode:'ONT8'}],total:1}),
|
||||
getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn(), createFbaAppointment: vi.fn(), updateInboundPlan: vi.fn(),
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: {warning:vi.fn(),success:vi.fn()} }))
|
||||
it('keeps the inbound appointment retry across close, reopen and route attempts', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue({id:'1',appointmentNo:'AP-1',version:2,originWarehouseId:'100',fbaWarehouseCode:'ONT8',appointmentStatus:'PENDING',selectedCount:1,items:[]})
|
||||
vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({} as never)
|
||||
const wrapper=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'],template:'<section v-if="modelValue"><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save @click="$emit(\'confirm\')">保存</button></section>'},
|
||||
ElButton:{template:'<button><slot/></button>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
|
||||
}}})
|
||||
await flushPromises()
|
||||
const open=()=>wrapper.findAll('button').find(b=>b.text()==='预约')!.trigger('click')
|
||||
await open();await flushPromises();await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
const original=vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
const dialog=wrapper.findAllComponents({name:'AppDialog'})[0]!
|
||||
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { CalendarCheck2, Truck } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -127,6 +127,7 @@ function selectionError() {
|
||||
}
|
||||
|
||||
function openAppointment() {
|
||||
if (!canLeaveAppointment()) return
|
||||
const error = selectionError()
|
||||
if (error) { ElMessage.warning(error); return }
|
||||
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
||||
@@ -142,6 +143,7 @@ function openSmartDispatch() {
|
||||
}
|
||||
|
||||
async function openAppointmentEdit(source: object) {
|
||||
if (!canLeaveAppointment()) return
|
||||
const row = source as InboundOrderListItem
|
||||
if (!row.appointmentNo || row.appointmentNo === '—') return
|
||||
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
|
||||
@@ -158,7 +160,16 @@ async function openAppointmentEdit(source: object) {
|
||||
} finally { appointmentLoading.value = false }
|
||||
}
|
||||
|
||||
function canLeaveAppointment() {
|
||||
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
|
||||
function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
|
||||
onBeforeRouteLeave(canLeaveAppointment)
|
||||
onBeforeRouteUpdate(canLeaveAppointment)
|
||||
async function submitAppointment() {
|
||||
if (saving.value || appointmentLoading.value) return
|
||||
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
|
||||
const validation = appointmentFormError(appointmentForm)
|
||||
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
|
||||
@@ -213,7 +224,7 @@ onMounted(() => { void load() })
|
||||
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog" v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
|
||||
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" 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>
|
||||
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="saving || !!pendingAppointment" />
|
||||
|
||||
@@ -14,7 +14,7 @@ import InventoryTransactionsView from './InventoryTransactionsView.vue'
|
||||
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/inboundOrders', () => ({
|
||||
listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user