Files
admin-web/src/modules/appointment-calendar/AppointmentCalendarDialogs.native.test.ts
T
2026-10-20 23:34:11 +08:00

150 lines
15 KiB
TypeScript

import ElementPlus from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
import { batchContext } from './batch.test-support'
import type { ScheduleEvent } from './schedule'
import { getWorkDetail, type WorkAction } from './workApi'
import { workDetail, workItem } from './work.test-support'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals() })
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
beforeEach(() => {
vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' }
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
})
async function open() {
const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: {
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '<section data-calendar />' },
} } })
await flushPromises()
return wrapper
}
async function create(wrapper: Awaited<ReturnType<typeof open>>) {
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')!
const content = creationBody()
await content.get('input[type="checkbox"]').setValue(true)
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
return dialog
}
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
expect(dialog.props('width')).toBe('520px')
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(picker.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm', format: 'YYYY-MM-DD HH:mm' })
expect(dialog.getComponent({ name: 'ElForm' }).props('labelWidth')).toBe('150px')
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(picker.props('modelValue')).toBe('2026-10-22T09:30')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
})
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(dialog.get('input').attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('上次提交结果尚未确认')
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
})
it('clears the Element arrival picker without saving an empty required time', async () => {
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(dialog.find('input[type="datetime-local"]').exists()).toBe(false)
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
picker.vm.$emit('update:modelValue', null); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(saveWarehouseArrival).not.toHaveBeenCalled()
})
it('keeps complete long cargo names accessible in the selectable table', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName)
expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('')
expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/)
expect(resolvePlanningSource).not.toHaveBeenCalled()
})
it('preserves real FBA fields and entered values when expanding the create dialog', async () => {
const wrapper = await open(); const dialog = await create(wrapper)
const fields = dialog.getComponent({ name: 'FbaAppointmentFields' })
expect(fields.props('continuous')).toBe(true)
const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]!
date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises()
await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(date.props('modelValue')).toBe('2026-10-25')
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) }))
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001')
})
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
const wrapper = await open(); await create(wrapper)
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
const receipt = creationBody().get('[aria-label="预约保存回执"]')
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
})
it('does not open or request creation data without its original write permission', async () => {
routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment')
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises()
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false)
})
it('uses the same real continuous form through the original work detail deep link', async () => {
routing.query.workItem = 'work-delivery-1'
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } }
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }))
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
await content.get('.primary-next-action').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
expect(content.text()).toContain('本次保存已完成')
})