2026-09-30 14:01:58 +08:00
|
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
2026-10-20 10:11:31 +08:00
|
|
|
import ElementPlus, { ElMessageBox } from 'element-plus'
|
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
|
|
|
import * as api from '../api/platformSailingCalendar'
|
2026-09-30 14:01:58 +08:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { listSailingCalendar } from '../api/platformSailingCalendar'
|
2026-10-20 10:11:31 +08:00
|
|
|
import type { SailingCalendarResponse, SailingTemplateResource } from '../types'
|
2026-09-30 14:01:58 +08:00
|
|
|
import SailingCalendarView from './SailingCalendarView.vue'
|
|
|
|
|
|
|
|
|
|
const permissions = vi.hoisted(() => ({ write: true }))
|
|
|
|
|
vi.mock('@/core/access/accessPreview', () => ({
|
|
|
|
|
useAccessPreviewStore: () => ({ can: () => permissions.write }),
|
|
|
|
|
}))
|
|
|
|
|
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
|
|
|
|
|
vi.mock('../api/platformSailingCalendar', () => ({
|
|
|
|
|
listSailingCalendar: vi.fn(), cancelSailing: vi.fn(), copySailing: vi.fn(),
|
|
|
|
|
createSailingTemplate: vi.fn(), createSailing: vi.fn(), generateSailingTemplate: vi.fn(),
|
|
|
|
|
getSailing: vi.fn(), getSailingBookingAdvice: vi.fn(), getSailingVolumeDetail: vi.fn(),
|
|
|
|
|
listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]),
|
|
|
|
|
previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(),
|
|
|
|
|
}))
|
2026-10-20 10:11:31 +08:00
|
|
|
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 }
|
2026-09-30 14:01:58 +08:00
|
|
|
const calendar: SailingCalendarResponse = {
|
|
|
|
|
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
|
|
|
|
|
async function mountView() {
|
|
|
|
|
wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
return wrapper
|
|
|
|
|
}
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.useFakeTimers({ toFake: ['Date'] })
|
|
|
|
|
vi.setSystemTime(new Date(2026, 8, 30, 12))
|
|
|
|
|
vi.clearAllMocks()
|
2026-10-20 10:11:31 +08:00
|
|
|
vi.mocked(api.listSailingTemplates).mockResolvedValue([])
|
|
|
|
|
vi.mocked(api.listSailingChannels).mockResolvedValue([])
|
2026-09-30 14:01:58 +08:00
|
|
|
permissions.write = true
|
|
|
|
|
vi.mocked(listSailingCalendar).mockResolvedValue(calendar)
|
|
|
|
|
})
|
2026-10-20 10:11:31 +08:00
|
|
|
afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() })
|
2026-09-30 14:01:58 +08:00
|
|
|
|
|
|
|
|
describe('SailingCalendarView integrated workspace', () => {
|
2026-10-19 17:56:31 +08:00
|
|
|
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)
|
|
|
|
|
const createButtons = view.findAll('button').filter(button => button.text() === '新增单个船次')
|
|
|
|
|
expect(createButtons).toHaveLength(1)
|
|
|
|
|
const otherDate = view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '29')!
|
|
|
|
|
await otherDate.trigger('click')
|
|
|
|
|
await createButtons[0].trigger('click')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(document.querySelector('.el-dialog__title')?.textContent).toBe('新增船期')
|
|
|
|
|
const departurePicker = view.findAllComponents({ name: 'ElDatePicker' }).find(picker => picker.props('type') === 'datetime')!
|
|
|
|
|
expect(departurePicker.props('modelValue')).toBe('2026-09-29T10:00:00')
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-30 14:01:58 +08:00
|
|
|
it('preserves keyword search, date selection and month navigation with the shared header', async () => {
|
|
|
|
|
const view = await mountView()
|
|
|
|
|
expect(view.get('h1').text()).toBe('船期日历')
|
|
|
|
|
expect(view.get('.data-table__business-actions').text()).toContain('新增单个船次')
|
|
|
|
|
expect(view.find('button[aria-label="列设置"]').exists()).toBe(false)
|
|
|
|
|
expect(view.find('.data-table__pagination').exists()).toBe(false)
|
|
|
|
|
expect(view.get('.detail-panel').text()).toContain('SAIL-TEST')
|
|
|
|
|
await view.get('.sailing-card').trigger('click')
|
|
|
|
|
expect(view.get('.detail-actions').text()).toContain('货量明细')
|
|
|
|
|
const otherDate = view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '29')!
|
|
|
|
|
await otherDate.trigger('click')
|
|
|
|
|
expect(view.get('.detail-header h2').text()).toBe('2026-09-29')
|
|
|
|
|
expect(view.get('.detail-panel').text()).toContain('当天暂无船期')
|
|
|
|
|
const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]')
|
|
|
|
|
await keyword.setValue('TEST VESSEL')
|
|
|
|
|
await keyword.trigger('keyup', { key: 'Enter' })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09', keyword: 'TEST VESSEL' }))
|
|
|
|
|
await view.get('.toolbar-filters button').trigger('click')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: undefined }))
|
|
|
|
|
await view.get('button[aria-label="下个月"]').trigger('click')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps filters and retry available on failure and preserves read-only permissions', async () => {
|
|
|
|
|
permissions.write = false
|
|
|
|
|
vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('日历加载失败'))
|
|
|
|
|
const view = await mountView()
|
|
|
|
|
expect(view.get('h1').text()).toBe('船期日历')
|
|
|
|
|
expect(view.find('.toolbar-filters').exists()).toBe(true)
|
|
|
|
|
expect(view.find('.calendar-layout').exists()).toBe(false)
|
|
|
|
|
expect(view.find('.data-table__business-actions button').exists()).toBe(false)
|
|
|
|
|
await view.get('.load-error-state__actions button').trigger('click')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
|
|
|
|
|
expect(view.get('.detail-panel').text()).toContain('SAIL-TEST')
|
|
|
|
|
await view.get('.sailing-card').trigger('click')
|
|
|
|
|
const operations = view.get('.detail-actions').text()
|
|
|
|
|
expect(operations).toContain('货量明细')
|
|
|
|
|
for (const action of ['编辑', '复制下一班', '取消']) expect(operations).not.toContain(action)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-10-20 10:11:31 +08:00
|
|
|
|
|
|
|
|
const weeklyTemplate: SailingTemplateResource = {
|
|
|
|
|
id: 'template-internal', templateNo: 'ST-TEST-01', channelId: 'channel-1', channelName: '测试渠道',
|
|
|
|
|
warehouseId: 'warehouse-1', warehouseName: '测试仓', weekdays: [1, 3], effectiveFrom: '2026-09-30', effectiveTo: null,
|
|
|
|
|
departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', status: 'ACTIVE', version: 7,
|
|
|
|
|
}
|
|
|
|
|
async function clickAction(view: VueWrapper, label: string) {
|
|
|
|
|
const button = view.findAll('button').find(item => item.text() === label)
|
|
|
|
|
expect(button, label).toBeDefined()
|
|
|
|
|
await button!.trigger('click')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
}
|
|
|
|
|
async function editWeeklyTemplate() {
|
|
|
|
|
vi.mocked(api.listSailingTemplates).mockResolvedValue([weeklyTemplate])
|
|
|
|
|
const view = await mountView()
|
|
|
|
|
await clickAction(view, '按周批量生成')
|
|
|
|
|
await clickAction(view, '编辑')
|
|
|
|
|
const dialog = view.findAllComponents(AppDialog).find(item => item.props('title').startsWith('编辑周模板'))!
|
|
|
|
|
expect(dialog.props('title')).toBe('编辑周模板 · ST-TEST-01')
|
|
|
|
|
return { view, dialog }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('SailingCalendarView dialog workflows', () => {
|
2026-10-20 23:34:11 +08:00
|
|
|
it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => {
|
|
|
|
|
const view = await mountView()
|
|
|
|
|
await clickAction(view, '新增单个船次')
|
|
|
|
|
const dialog = view.findAllComponents(AppDialog).find(item => item.props('title') === '新增船期')!
|
|
|
|
|
const result = () => document.querySelector('.cutoff-calculated')!.textContent
|
|
|
|
|
expect(result()).toContain('2026-09-27 18:00')
|
|
|
|
|
const presets = dialog.findAllComponents({ name: 'ElRadioGroup' }).find(item => item.classes().includes('cutoff-presets'))!
|
|
|
|
|
presets.vm.$emit('update:modelValue', 2)
|
|
|
|
|
presets.vm.$emit('change', 2)
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(result()).toContain('2026-09-28 18:00')
|
|
|
|
|
const days = dialog.findComponent({ name: 'ElInputNumber' })
|
|
|
|
|
days.vm.$emit('update:modelValue', 5)
|
|
|
|
|
days.vm.$emit('change', 5)
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(result()).toContain('2026-09-25 18:00')
|
|
|
|
|
expect(presets.props('modelValue')).toBe(5)
|
|
|
|
|
const time = dialog.findComponent({ name: 'ElTimePicker' })
|
|
|
|
|
time.vm.$emit('update:modelValue', '16:30:00')
|
|
|
|
|
time.vm.$emit('change', '16:30:00')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(result()).toContain('2026-09-25 16:30')
|
|
|
|
|
const mode = dialog.findAllComponents({ name: 'ElRadioGroup' }).find(item => item.classes().includes('cutoff-mode-group'))!
|
|
|
|
|
mode.vm.$emit('update:modelValue', 'custom')
|
|
|
|
|
mode.vm.$emit('change', 'custom')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(document.querySelector('.cutoff-rule-row')).toBeNull()
|
|
|
|
|
const pickers = dialog.findAllComponents({ name: 'ElDatePicker' })
|
|
|
|
|
pickers[1].vm.$emit('update:modelValue', '2026-09-29T15:00:00')
|
|
|
|
|
pickers[1].vm.$emit('change', '2026-09-29T15:00:00')
|
|
|
|
|
pickers[0].vm.$emit('update:modelValue', '2026-10-02T10:00:00')
|
|
|
|
|
pickers[0].vm.$emit('change', '2026-10-02T10:00:00')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(result()).toContain('2026-09-29 15:00')
|
|
|
|
|
expect(result()).toContain('自定义时间')
|
|
|
|
|
mode.vm.$emit('update:modelValue', 'rule')
|
|
|
|
|
mode.vm.$emit('change', 'rule')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(result()).toContain('2026-09-27 16:30')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-20 10:11:31 +08:00
|
|
|
it('retains edited cutoff time and optimistic version through fullscreen and save', async () => {
|
|
|
|
|
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('编辑船期'))!
|
|
|
|
|
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
|
|
|
|
|
const datePickers = dialog.findAllComponents({ name: 'ElDatePicker' })
|
|
|
|
|
await datePickers[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
const fullscreen = document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!
|
|
|
|
|
fullscreen.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(document.querySelector('.el-dialog.is-fullscreen')).not.toBeNull()
|
|
|
|
|
expect(vi.mocked(api.updateSailing)).not.toHaveBeenCalled()
|
|
|
|
|
document.querySelector<HTMLButtonElement>('[aria-label="退出全屏"]')!.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
const save = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '保存')!
|
|
|
|
|
save.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({
|
|
|
|
|
expectedVersion: 1, warehouseId: 'warehouse-1', channelCode: 'CH-TEST', cutoffAt: '2026-09-28T16:30:00', etdAt: '2026-09-30T10:00:00',
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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' }] })
|
|
|
|
|
vi.mocked(api.getSailingBookingAdvice).mockResolvedValue({ actualReceivedCbm: 12, catchableReceivedCbm: 12, lateReceivedCbm: 0, catchableTotalCbm: 12, planningCapacityCbm: 60, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: '40HQ', containerTypeName: '40HQ', capacityCbm: 60, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00' })
|
|
|
|
|
const view = await mountView()
|
|
|
|
|
await view.get('.sailing-card').trigger('click')
|
|
|
|
|
for (const [label, title, expectedText] of [
|
|
|
|
|
['货量明细', '货量明细(只读)', 'RK-TEST-01'], ['变更历史', '船期变更历史', '调整截止时间'], ['订柜建议', '订柜建议(只读初算)', '40HQ'],
|
|
|
|
|
]) {
|
|
|
|
|
await clickAction(view, label)
|
|
|
|
|
const dialog = view.findAllComponents(AppDialog).find(item => item.props('title') === `${title} · SAIL-TEST`)!
|
|
|
|
|
expect(dialog.props('modelValue')).toBe(true)
|
|
|
|
|
expect(document.body.textContent).toContain(expectedText)
|
|
|
|
|
dialog.vm.$emit('update:modelValue', false)
|
|
|
|
|
await flushPromises()
|
|
|
|
|
}
|
|
|
|
|
for (const operation of [api.createSailing, api.updateSailing, api.copySailing, api.generateSailingTemplate]) expect(operation).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('saves the same weekly pattern and version from the wide editor', async () => {
|
|
|
|
|
const { dialog } = await editWeeklyTemplate()
|
|
|
|
|
const time = dialog.findAllComponents({ name: 'ElTimePicker' })[0]
|
|
|
|
|
await time.vm.$emit('update:modelValue', '11:15:00')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
const save = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '保存模板')!
|
|
|
|
|
save.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(api.updateSailingTemplate).toHaveBeenCalledExactlyOnceWith('template-internal', expect.objectContaining({ weekdays: [1, 3], departureTime: '11:15:00', expectedVersion: 7, channelId: 'channel-1', warehouseId: 'warehouse-1' }))
|
|
|
|
|
expect(api.generateSailingTemplate).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('previews conflicts and keeps the selected range until explicit generation confirmation', async () => {
|
|
|
|
|
vi.mocked(api.previewSailingTemplateGeneration).mockResolvedValue({ templateId: 'template-internal', records: [{ etdAt: '2026-10-05T10:00:00', cutoffAt: '2026-10-02T18:00:00', conflict: true, conflictReason: '已有同日船期' }] })
|
|
|
|
|
vi.mocked(api.generateSailingTemplate).mockResolvedValue({ templateId: 'template-internal', generatedCount: 2, skippedCount: 1, records: [] })
|
|
|
|
|
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
|
|
|
|
const { dialog } = await editWeeklyTemplate()
|
|
|
|
|
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog button'))
|
|
|
|
|
buttons.find(button => button.textContent?.trim() === '未来 4 周')!.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
const preview = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '预览生成')!
|
|
|
|
|
preview.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(api.previewSailingTemplateGeneration).toHaveBeenCalledExactlyOnceWith('template-internal', { from: '2026-09-30', to: '2026-10-28' })
|
|
|
|
|
expect(document.body.textContent).toContain('已有同日船期')
|
|
|
|
|
expect(api.generateSailingTemplate).not.toHaveBeenCalled()
|
|
|
|
|
expect(confirmation).not.toHaveBeenCalled()
|
|
|
|
|
const expand = Array.from(document.querySelectorAll<HTMLButtonElement>('[aria-label="展开全屏"]')).at(-1)!
|
|
|
|
|
expand.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(dialog.findAllComponents({ name: 'ElDatePicker' }).at(-1)!.props('modelValue')).toBe('2026-10-28')
|
|
|
|
|
Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '生成船期')!.click()
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('2026-09-30 至 2026-10-28'), '批量生成船期', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认生成' }))
|
|
|
|
|
expect(api.generateSailingTemplate).toHaveBeenCalledExactlyOnceWith('template-internal', { from: '2026-09-30', to: '2026-10-28' })
|
|
|
|
|
})
|
|
|
|
|
})
|