fix(admin): 完善船期日历刷新与交互状态
This commit is contained in:
@@ -38,6 +38,7 @@ function summary(raw?: Partial<SailingCalendarEntry['volumeSummary']> | null) {
|
||||
overCapacity: Boolean(raw?.overCapacity),
|
||||
planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm),
|
||||
bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm),
|
||||
availableBookedCapacityCbm: raw?.availableBookedCapacityCbm == null ? null : number(raw.availableBookedCapacityCbm),
|
||||
catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm),
|
||||
lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm),
|
||||
snapshotAt: raw?.snapshotAt ?? null,
|
||||
|
||||
@@ -19,13 +19,20 @@ vi.mock('../api/platformSailingCalendar', () => ({
|
||||
listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]),
|
||||
previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(),
|
||||
}))
|
||||
const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
|
||||
const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, availableBookedCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
|
||||
const calendar: SailingCalendarResponse = {
|
||||
nextRefreshAt: '2026-10-01T00:00:00',
|
||||
month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 },
|
||||
filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] },
|
||||
records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }],
|
||||
}
|
||||
let wrapper: VueWrapper | undefined
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: Error) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function mountView() {
|
||||
wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -33,7 +40,7 @@ async function mountView() {
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date(2026, 8, 30, 12))
|
||||
vi.setSystemTime(new Date('2026-09-30T12:00:00+08:00'))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.listSailingTemplates).mockResolvedValue([])
|
||||
vi.mocked(api.listSailingChannels).mockResolvedValue([])
|
||||
@@ -43,6 +50,141 @@ beforeEach(() => {
|
||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
|
||||
describe('SailingCalendarView integrated workspace', () => {
|
||||
it.each(['success', 'failure'] as const)('ignores a stale calendar %s after the next month has loaded', async (outcome) => {
|
||||
const view = await mountView()
|
||||
const previous = deferred<SailingCalendarResponse>()
|
||||
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise)
|
||||
const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]')
|
||||
await keyword.trigger('keyup', { key: 'Enter' })
|
||||
const october = { ...calendar, month: '2026-10', records: [{ ...calendar.records[0], id: 'october', sailingNo: 'SL-OCTOBER', etdAt: '2026-10-01T10:00:00' }] }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce(october)
|
||||
await view.get('button[aria-label="下个月"]').trigger('click')
|
||||
await flushPromises()
|
||||
if (outcome === 'success') previous.resolve(calendar)
|
||||
else previous.reject(new Error('旧月份请求失败'))
|
||||
await flushPromises()
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
|
||||
expect(view.get('.detail-panel').text()).toContain('SL-OCTOBER')
|
||||
expect(view.find('.load-error-state').exists()).toBe(false)
|
||||
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps the latest calendar request loading when an earlier request finishes first', async () => {
|
||||
const view = await mountView()
|
||||
const previous = deferred<SailingCalendarResponse>()
|
||||
const current = deferred<SailingCalendarResponse>()
|
||||
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise)
|
||||
await view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]').trigger('keyup', { key: 'Enter' })
|
||||
await view.get('button[aria-label="下个月"]').trigger('click')
|
||||
previous.resolve(calendar)
|
||||
await flushPromises()
|
||||
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('true')
|
||||
current.resolve({ ...calendar, month: '2026-10', records: [] })
|
||||
await flushPromises()
|
||||
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps the copied sailing selected when its date is later in the current month', async () => {
|
||||
vi.setSystemTime(new Date('2026-09-20T12:00:00+08:00'))
|
||||
const original = { ...calendar.records[0], etdAt: '2026-09-21T10:00:00', cutoffAt: '2026-09-20T18:00:00' }
|
||||
const copied = { ...original, id: 'copied', sailingNo: 'SL-COPIED', etdAt: '2026-09-28T10:00:00', cutoffAt: '2026-09-27T18:00:00' }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original] })
|
||||
const view = await mountView()
|
||||
await view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '21')!.trigger('click')
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
vi.mocked(api.copySailing).mockResolvedValueOnce({ sailing: copied, histories: [] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original, copied] })
|
||||
await clickAction(view, '复制下一班')
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-09-28')
|
||||
expect(view.get('.sailing-card.active').text()).toContain('SL-COPIED')
|
||||
expect(view.get('.detail-actions').text()).toContain('订柜建议')
|
||||
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-09-20')
|
||||
})
|
||||
|
||||
it('uses the Shanghai calendar date at the UTC day boundary', async () => {
|
||||
vi.setSystemTime(new Date('2026-09-29T16:00:00Z'))
|
||||
const view = await mountView()
|
||||
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09' }))
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-09-30')
|
||||
expect(view.get('.calendar-note').text()).toContain('仅今天及以后')
|
||||
})
|
||||
|
||||
it('refreshes at cutoff so the current cargo reference can roll forward', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T12:00:01' }] })
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
const emptyVolume = { ...volume, actualReceivedCbm: 0, catchableReceivedCbm: 0, recommendedContainerCount: 0, pendingContainerCount: 0 }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, summary: { ...emptyVolume, sailingCount: 1 }, records: [{ ...calendar.records[0], volumeSummary: emptyVolume }] })
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
expect(view.get('.volume-line').text()).toContain('建议 0 柜')
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes across midnight and month end and removes expired selection', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, month: '2026-10', records: [], summary: { ...calendar.summary, sailingCount: 0 } })
|
||||
await vi.advanceTimersByTimeAsync(1001)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
|
||||
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
|
||||
expect(view.find('.sailing-card').exists()).toBe(false)
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
expect(view.get('.calendar-note').text()).toContain('共 0 个船期')
|
||||
const calls = vi.mocked(listSailingCalendar).mock.calls.length
|
||||
view.unmount(); wrapper = undefined
|
||||
await vi.advanceTimersByTimeAsync(86_400_000)
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('refreshes the next month at the server boundary even when the earlier sailing is hidden', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T13:00:00', etdAt: '2026-09-30T18:00:00' }] })
|
||||
const view = await mountView()
|
||||
const nextMonth = { ...calendar, month: '2026-10', nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], id: 'sailing-2', etdAt: '2026-10-02T10:00:00', cutoffAt: '2026-10-01T18:00:00' }] }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue(nextMonth)
|
||||
await view.get('button[aria-label="下个月"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...nextMonth, nextRefreshAt: '2026-10-01T00:00:00' })
|
||||
await vi.advanceTimersByTimeAsync(3_600_002)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
|
||||
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
|
||||
})
|
||||
|
||||
it('backs off after a refresh failure instead of repeatedly requesting an expired boundary', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
|
||||
await mountView()
|
||||
vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('网络错误'))
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(59_000)
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1001)
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('reloads today and clears actions when the selected sailing is no longer returned', async () => {
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], summary: { ...calendar.summary, sailingCount: 0 } })
|
||||
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps one create entry and defaults the sailing to the selected calendar date', async () => {
|
||||
const view = await mountView()
|
||||
expect(view.find('.detail-header button').exists()).toBe(false)
|
||||
@@ -129,6 +271,44 @@ async function editWeeklyTemplate() {
|
||||
}
|
||||
|
||||
describe('SailingCalendarView dialog workflows', () => {
|
||||
it.each([
|
||||
['货量明细', false], ['货量明细', true], ['变更历史', false], ['变更历史', true], ['订柜建议', false], ['订柜建议', true],
|
||||
] as const)('keeps the latest %s response after reopening; switch sailing: %s', async (label, switchSailing) => {
|
||||
const nextSailing = { ...calendar.records[0], id: 'sailing-2', sailingNo: 'SAIL-NEXT' }
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [calendar.records[0], nextSailing] })
|
||||
let finishOld!: () => void
|
||||
if (label === '货量明细') {
|
||||
const previous = deferred<Awaited<ReturnType<typeof api.getSailingVolumeDetail>>>()
|
||||
const result = (code: string): Awaited<ReturnType<typeof api.getSailingVolumeDetail>> => ({ summary: volume, assignments: [{ id: code, inboundCode: code, volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
|
||||
vi.mocked(api.getSailingVolumeDetail).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
|
||||
finishOld = () => previous.resolve(result('OLD-ONLY'))
|
||||
} else if (label === '变更历史') {
|
||||
const previous = deferred<Awaited<ReturnType<typeof api.getSailing>>>()
|
||||
const result = (reason: string): Awaited<ReturnType<typeof api.getSailing>> => ({ sailing: nextSailing, histories: [{ id: reason, action: 'UPDATE', reason, createdAt: '2026-09-30T12:00:00' }] })
|
||||
vi.mocked(api.getSailing).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
|
||||
finishOld = () => previous.resolve(result('OLD-ONLY'))
|
||||
} else {
|
||||
const previous = deferred<Awaited<ReturnType<typeof api.getSailingBookingAdvice>>>()
|
||||
const result = (code: string) => ({ actualReceivedCbm: 12, catchableTotalCbm: 12, planningCapacityCbm: 68, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: code, containerTypeName: code, capacityCbm: 68, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00', catchableReceivedCbm: 12, lateReceivedCbm: 0 })
|
||||
vi.mocked(api.getSailingBookingAdvice).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
|
||||
finishOld = () => previous.resolve(result('OLD-ONLY'))
|
||||
}
|
||||
const view = await mountView()
|
||||
await view.findAll('.sailing-card')[0].trigger('click')
|
||||
await clickAction(view, label)
|
||||
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith(label === '货量明细' ? '货量明细' : label === '变更历史' ? '船期变更历史' : '订柜建议'))!
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
if (switchSailing) await view.findAll('.sailing-card')[1].trigger('click')
|
||||
await clickAction(view, label)
|
||||
expect(document.body.textContent).toContain('NEW-ONLY')
|
||||
finishOld()
|
||||
await flushPromises()
|
||||
expect(dialog.props('title')).toContain(switchSailing ? 'SAIL-NEXT' : 'SAIL-TEST')
|
||||
expect(document.body.textContent).toContain('NEW-ONLY')
|
||||
expect(document.body.textContent).not.toContain('OLD-ONLY')
|
||||
})
|
||||
|
||||
it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => {
|
||||
const view = await mountView()
|
||||
await clickAction(view, '新增单个船次')
|
||||
@@ -194,6 +374,49 @@ describe('SailingCalendarView dialog workflows', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(['cutoff', 'midnight'] as const)('preserves the editing target, changes and version across a %s refresh', async (boundary) => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
if (boundary === 'midnight') vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: boundary === 'midnight' ? '2026-10-01T00:00:00' : '2026-09-30T12:00:01.001' })
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
await clickAction(view, '编辑')
|
||||
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
|
||||
await dialog.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00')
|
||||
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], nextRefreshAt: '2026-10-02T00:00:00' })
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
expect(view.find('.detail-actions').exists()).toBe(false)
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
|
||||
document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button').forEach(button => {
|
||||
if (button.textContent?.trim() === '保存') button.click()
|
||||
})
|
||||
await flushPromises()
|
||||
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({
|
||||
expectedVersion: 1, cutoffAt: '2026-09-28T16:30:00',
|
||||
}))
|
||||
expect(api.createSailing).not.toHaveBeenCalled()
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the edited form when the server rejects a save after automatic refresh', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
|
||||
const view = await mountView()
|
||||
await view.get('.sailing-card').trigger('click')
|
||||
await clickAction(view, '编辑')
|
||||
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
|
||||
await vi.advanceTimersByTimeAsync(1002)
|
||||
await flushPromises()
|
||||
vi.mocked(api.updateSailing).mockRejectedValueOnce(new Error('版本冲突'))
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1 }))
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
|
||||
})
|
||||
|
||||
it('loads volume, history and booking advice as read-only data for the business-number title', async () => {
|
||||
vi.mocked(api.getSailingVolumeDetail).mockResolvedValue({ summary: volume, assignments: [{ id: 'internal-assignment', inboundCode: 'RK-TEST-01', volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
|
||||
vi.mocked(api.getSailing).mockResolvedValue({ sailing: calendar.records[0], histories: [{ id: 'history-internal', action: 'UPDATE', reason: '调整截止时间', createdAt: '2026-09-29T10:00:00' }] })
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -13,6 +13,7 @@ export interface SailingVolumeSummary {
|
||||
overCapacity: boolean
|
||||
planningCapacityCbm?: number | null
|
||||
bookedPlanningCapacityCbm?: number | null
|
||||
availableBookedCapacityCbm?: number | null
|
||||
catchableReceivedCbm?: number | null
|
||||
lateReceivedCbm?: number | null
|
||||
snapshotAt?: string | null
|
||||
@@ -63,6 +64,7 @@ export interface SailingCalendarSummary extends SailingVolumeSummary {
|
||||
export interface SailingCalendarResponse {
|
||||
month: string
|
||||
timezone: string
|
||||
nextRefreshAt: string
|
||||
records: SailingCalendarEntry[]
|
||||
summary: SailingCalendarSummary
|
||||
filterOptions: SailingCalendarFilterOptions
|
||||
|
||||
Reference in New Issue
Block a user