fix(appointments): ignore stale impact history responses

This commit is contained in:
2026-10-11 21:09:30 +08:00
parent 43611ecc55
commit 20b4d1425b
2 changed files with 53 additions and 6 deletions
@@ -11,6 +11,51 @@ const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
describe('appointment impact handling', () => {
beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) })
function deferred() {
let resolve!: (value: ImpactDetail) => void, reject!: (reason: Error) => void
const promise=new Promise<ImpactDetail>((yes,no)=>{resolve=yes;reject=no})
return {promise,resolve,reject}
}
const pageDetail=(label:string):ImpactDetail=>({...detail,history:{total:60,records:[{version:1,action:'RECORD',operatorName:null,result:label,nextAction:null,followupDate:null,createdAt:'2026-10-11T00:00:00'}]}})
it('keeps the latest history page when responses finish in reverse order',async()=>{
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
const wrapper=render();await flushPromises()
const second=deferred(),third=deferred()
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
const pager=wrapper.findComponent({name:'ElPagination'})
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
await flushPromises()
third.resolve(pageDetail('第三页最新'));await flushPromises()
second.resolve(pageDetail('第二页过期'));await flushPromises()
expect(wrapper.text()).toContain('第三页最新');expect(wrapper.text()).not.toContain('第二页过期')
wrapper.unmount()
})
it('ignores an obsolete failure and keeps loading until the current request finishes',async()=>{
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
const wrapper=render();await flushPromises()
const second=deferred(),third=deferred()
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
const pager=wrapper.findComponent({name:'ElPagination'})
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
second.reject(new Error('obsolete page failure'));await flushPromises()
expect(wrapper.text()).not.toContain('obsolete page failure')
expect(pager.props('disabled')).toBe(true)
expect(wrapper.findAll('button').find(b=>b.text()==='认领跟进')!.attributes('disabled')).toBeDefined()
wrapper.findAllComponents({name:'ElButton'}).find(b=>b.text()==='认领跟进')!.vm.$emit('click')
await flushPromises();expect(handleImpact).not.toHaveBeenCalled()
third.resolve(pageDetail('第三页最新'));await flushPromises()
expect(pager.props('disabled')).toBe(false);wrapper.unmount()
})
it('does not let a closed dialog response replace a newly opened impact',async()=>{
const old=deferred();vi.mocked(getImpact).mockReturnValueOnce(old.promise)
const first=render();await flushPromises();first.unmount()
vi.mocked(getImpact).mockResolvedValueOnce({...pageDetail('新影响历史'),impact:{...detail.impact,impactNo:'AI-NEW'}})
const next=render();await flushPromises()
old.resolve(pageDetail('已关闭历史'));await flushPromises()
expect(next.text()).toContain('AI-NEW');expect(next.text()).not.toContain('已关闭历史');next.unmount()
})
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
const wrapper = render(false); await flushPromises()
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
@@ -12,14 +12,16 @@ const router = useRouter(), detail = ref<ImpactDetail | null>(null), loading = r
const error = ref<ApiErrorDetails | null>(null), pending = ref<ImpactCommand | null>(null)
const form = reactive({ result: '', nextAction: '', followupDate: '' })
const actions: Record<string, string> = { OBSERVED: '船期核对', CLAIM: '认领', RECORD: '记录处理', RESOLVE: '完成核对' }
let loadGeneration = 0
async function load() {
const generation = ++loadGeneration
loading.value = true; error.value = null
try { detail.value = await getImpact(props.containerId, historyPage.value) }
catch (cause) { error.value = getApiErrorDetails(cause, '影响详情加载失败') }
finally { loading.value = false }
try { const result = await getImpact(props.containerId, historyPage.value); if (generation === loadGeneration) detail.value = result }
catch (cause) { if (generation === loadGeneration) error.value = getApiErrorDetails(cause, '影响详情加载失败') }
finally { if (generation === loadGeneration) loading.value = false }
}
async function save(action: ImpactCommand['action']) {
if (!detail.value || saving.value || !props.canWrite) return
if (!detail.value || loading.value || saving.value || !props.canWrite) return
if (!pending.value && action !== 'CLAIM' && !form.result.trim()) { ElMessage.warning('请填写实际处理结果'); return }
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
saving.value = true; error.value = null
@@ -33,7 +35,7 @@ function beforeClose(done: () => void) { if (canLeave()) done() }
function openTrip(tripNo: string) { if (canLeave()) void router.push(`/overseas-master-data/trips/${encodeURIComponent(tripNo)}`) }
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
const unregister = register?.(canLeave)
onBeforeUnmount(() => unregister?.())
onBeforeUnmount(() => { ++loadGeneration; unregister?.() })
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
onMounted(load)
@@ -65,7 +67,7 @@ onMounted(load)
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
</div>
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="saving || !!pending" @current-change="load" /></details>
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
</template>
</AppDialog>
</template>