@@ -84,126 +42,75 @@ defineExpose({ selectedDate, mode })
-
-
-
-
+
{{ heading }}
今天
-
-
-
数据提示 · {{ notices.length }}
-
-
{{ day }}
-
-
-
diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
index f3facbfd..49f1f7e3 100644
--- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
+++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
@@ -1,235 +1,118 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { inject, onBeforeUnmount } from 'vue'
-import { ElDialog } from 'element-plus'
-import type { LeaveGuard } from './useWorkDraft'
+import { onBeforeRouteLeave } from 'vue-router'
+import { ElMessageBox } from 'element-plus'
+import { ApiResponseError } from '@/shared/api/http'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
-import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
-import { listArrivalCalendar } from './api'
-import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
-const routing = vi.hoisted(() => ({ query: {} as Record
, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
+import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
+import { listTripCalendar } from './tripCalendar'
+import type { ScheduleEvent } from './schedule'
+const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record, push: vi.fn(), replace: vi.fn(), denied: new Set() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
-vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
-vi.mock('./workApi', async original => ({ ...await original(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
-vi.mock('./api', async original => ({ ...await original(), listArrivalCalendar: vi.fn() }))
-function render(realDialog = false) {
- return mount(AppointmentCalendarView, { attachTo: document.body, global: { components: { ElDialog }, directives: { loading: {} }, stubs: {
- teleport: true,
- ElTooltip: { template: '' },
- ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: ' $emit(\'update:modelValue\', false))">关闭
' },
+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(), listTripCalendar: vi.fn() }))
+vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
+const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
+const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
+export function renderSchedule() {
+ return mount(AppointmentCalendarView, { global: { stubs: {
AdminPage: { template: '' },
- ElButton: { props: ['type'], template: '' },
- WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '
' },
- WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
- const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
- const unregister = register(async () => routing.allowLeave)
- onBeforeUnmount(unregister)
- }, template: '{{ itemKey }}
' },
- SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '' },
- LoadErrorState: { props: ['title', 'message'], template: '{{ title }} {{ message }}重试' },
- ApiErrorMeta: true,
+ AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode'], template: '' },
+ AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '' },
+ WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
+ LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '{{ title }} {{ message }}重试' },
} } })
}
-describe('appointment work calendar', () => {
- beforeEach(() => {
- vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
- routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
- vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
- vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
- vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
- vi.mocked(listArrivalCalendar).mockResolvedValue([])
- })
- it('retains the old batch and page when a resolved later batch continues into its vehicle work', async () => {
- routing.query = { workAnchor: '2026-10-15', workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'old-batch-work' }
- const wrapper = render(); await flushPromises()
- wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-batch-work'); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
- wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-vehicle-work', followAction); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-vehicle-work')
- await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
- await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('old-batch-work')
- await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
- expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ view: 'UNDATED', page: 3 }), expect.any(AbortSignal))
+beforeEach(() => {
+ vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
+ vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
+ vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
+ vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
+ vi.mocked(saveWarehouseArrival).mockResolvedValue({})
+})
+describe('simplified appointment entry', () => {
+ it('keeps unsaved input when the operator declines to leave', async () => {
+ vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
+ const wrapper = renderSchedule(); await flushPromises()
+ wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
+ await wrapper.get('input').setValue('2026-10-22T09:30')
+ await wrapper.get('[data-close]').trigger('click'); await flushPromises()
+ expect(ElMessageBox.confirm).toHaveBeenCalled()
+ expect(wrapper.find('[data-dialog]').exists()).toBe(true)
+ expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
wrapper.unmount()
})
- it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
- const wrapper = render(); await flushPromises()
- expect(wrapper.text()).toContain('预约工作日历')
- expect(wrapper.get('[data-calendar] .work-inbox').attributes('aria-label')).toBe('全局待办')
- expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
- expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
+ it('loads original sources and real trips without loading work summaries or generated work items', async () => {
+ const wrapper = renderSchedule(); await flushPromises()
+ expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-09' }, ['1'], expect.any(AbortSignal))
+ const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
+ expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
+ expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
wrapper.unmount()
})
- it('keeps day/type selection and pagination context in the overlay and clears selection on close', async () => {
- vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 30, pages: 4 })
- const wrapper = render(); await flushPromises()
- const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
- calendar.vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
- expect(calendar.props()).toMatchObject({ selectedDate: '2026-10-15', selectedType: 'PICKUP' })
- expect(wrapper.get('.panel-header').text()).toContain('2026-10-15 · 提柜')
- expect(wrapper.find('.panel-body .work-pagination').exists()).toBe(false)
- expect(wrapper.get('.panel-footer').text()).toContain('共 30 项')
- await wrapper.get('.panel-footer .work-pagination button:last-child').trigger('click'); await flushPromises()
- expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-10-15', types: ['PICKUP'], page: 2 }), expect.any(AbortSignal))
- await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
- expect(wrapper.find('.work-panel').exists()).toBe(false)
- expect(calendar.props('selectedDate')).toBeUndefined()
+ it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
+ vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
+ const wrapper = renderSchedule(); await flushPromises()
+ expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
+ expect(wrapper.text()).toContain('计划到仓读取失败')
+ expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
wrapper.unmount()
})
- it('expands the existing detail without remounting or bypassing the unsaved close guard', async () => {
- const wrapper = render(); await flushPromises()
- await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
- await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
- const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
- routing.allowLeave = false
- await wrapper.get('[aria-label="展开全屏"]').trigger('click')
- expect(wrapper.get('.el-dialog').classes()).toContain('is-fullscreen')
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
- await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
- expect(wrapper.find('.work-panel').exists()).toBe(true)
- routing.allowLeave = true
- await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
- expect(wrapper.find('.work-panel').exists()).toBe(false)
+ it('does not request unreadable domains and describes the incomplete scope', async () => {
+ routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
+ const wrapper = renderSchedule(); await flushPromises()
+ expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
+ expect(wrapper.text()).toContain('缺少车次读取权限')
+ expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount()
})
- it.each(['mask', 'escape'] as const)('closes from %s through the real dialog without bypassing draft protection', async method => {
- const wrapper = render(true); await flushPromises()
- await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
- await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
- await wrapper.get('.panel-body').trigger('click')
- expect(wrapper.find('[data-detail]').exists()).toBe(true)
- const dismiss = async () => {
- if (method === 'mask') {
- const mask = wrapper.get('.el-overlay-dialog')
- await mask.trigger('mousedown'); await mask.trigger('mouseup'); await mask.trigger('click')
- } else {
- document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }))
- }
- await flushPromises()
- }
- routing.allowLeave = false
- await dismiss()
- expect(wrapper.find('[data-detail]').exists()).toBe(true)
- routing.allowLeave = true
- await dismiss()
- expect(wrapper.find('.work-panel').exists()).toBe(false)
+ it('ignores stale calendar responses when the date range changes', async () => {
+ let resolveOld!: (value: Awaited>) => void
+ vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
+ const wrapper = renderSchedule(); await flushPromises()
+ wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
+ resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
+ expect(wrapper.text()).not.toContain('缺少还柜读取权限')
+ expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
wrapper.unmount()
})
- it.each(['VIEW_DETAIL', 'VIEW_HISTORY', 'SAVE_FOLLOWUP'] as const)('keeps %s navigation intact while distinguishing read-only actions', async code => {
- const nextAction = { ...followAction, code, label: '服务端动作文案' }
- vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ nextAction })], page: 1, size: 8, total: 1, pages: 1 })
- const wrapper = render(); await flushPromises()
- await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
- const button = wrapper.get('.work-row-action button')
- expect(button.attributes('data-variant')).toBe(code === 'SAVE_FOLLOWUP' ? 'primary' : 'default')
- expect(wrapper.get('.item-schedule').text()).toContain('作业日期未定')
- expect(wrapper.get('.item-schedule').text()).toContain('America/Los_Angeles')
- expect(wrapper.get('.item-context').text()).toContain('地点:ONT8')
- expect(wrapper.get('.item-context').text()).toContain('负责人:测试调度')
- expect(wrapper.get('.item-context').text()).toContain('跟进:2026-10-15')
- await button.trigger('click'); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(nextAction)
+ it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
+ vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
+ const wrapper = renderSchedule(); await flushPromises()
+ wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
+ await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
+ await wrapper.get('[data-save]').trigger('click'); await flushPromises()
+ expect(wrapper.text()).toContain('提交结果尚未确认')
+ expect(wrapper.get('input').attributes('disabled')).toBeDefined()
+ const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise
+ expect(await guard()).toBe(false)
+ await wrapper.get('[data-save]').trigger('click'); await flushPromises()
+ expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
+ expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
+ expect(wrapper.find('[data-dialog]').exists()).toBe(false)
wrapper.unmount()
})
- it('sends an explicit safe return context with external business navigation', async () => {
- routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
- const wrapper = render(); await flushPromises()
- wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
- expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
- expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
+ it('requires an explicit reread after a conflict and retains the entered date', async () => {
+ vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
+ const wrapper = renderSchedule(); await flushPromises()
+ wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
+ await wrapper.get('input').setValue('2026-10-22T09:30')
+ await wrapper.get('[data-save]').trigger('click'); await flushPromises()
+ expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
+ vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
+ await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
+ expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
+ await wrapper.get('[data-save]').trigger('click'); await flushPromises()
+ expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
wrapper.unmount()
})
- it('keeps a successful save in the current detail and retains the original global page on return', async () => {
- routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3' }
- const wrapper = render(); await flushPromises()
- const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
- detail.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
- await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
- expect(wrapper.find('[data-detail]').exists()).toBe(false)
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
+ it('keeps the selected date and view in the source return link', async () => {
+ const wrapper = renderSchedule(); await flushPromises()
+ const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
+ panel.vm.$emit('select', '2026-10-21', 'week')
+ panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
+ expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
- it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
- const wrapper = render(); await flushPromises()
- await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
- const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
- calendar.vm.$emit('anchor', 1); await flushPromises()
- calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
- const calls = vi.mocked(getWorkCalendar).mock.calls.length
- calendar.vm.$emit('types', []); await flushPromises()
- expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
- expect(getWorkSummary).toHaveBeenCalledTimes(1)
- expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
- expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
- wrapper.unmount()
- })
- it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
- const wrapper = render(); await flushPromises()
- wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
- await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
- expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
- await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
- wrapper.unmount()
- })
- it('shows a failed source as an error, not zero work or all complete', async () => {
- vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
- vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
- vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
- const wrapper = render(); await flushPromises()
- await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
- expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
- expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
- expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
- wrapper.unmount()
- })
- it('ignores a late response after the global scope changes', async () => {
- let resolveOld!: (value: Awaited>) => void
- vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
- const wrapper = render(); await flushPromises()
- await wrapper.get('[data-view="OVERDUE"]').trigger('click')
- await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
- resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
- expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
- wrapper.unmount()
- })
- it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
- const wrapper = render(); await flushPromises()
- await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
- await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
- wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
- expect(wrapper.text()).toContain('返回原事项')
- wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
- expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
- wrapper.unmount()
- })
- it('goes directly to the current container unfinished list after route confirmation', async () => {
- const wrapper = render(); await flushPromises()
- await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
- await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
- wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
- expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
- expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
- expect(getWorkSummary).toHaveBeenCalledTimes(2)
- wrapper.unmount()
- })
- it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
- routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
- const wrapper = render(); await flushPromises()
- expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
- wrapper.unmount()
- routing.canReadSource = false
- const denied = render(); await flushPromises()
- expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
- expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
- })
})
diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue
index 81175a5a..85bba439 100644
--- a/src/modules/appointment-calendar/AppointmentCalendarView.vue
+++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue
@@ -1,303 +1,192 @@
-
-
-
-
-
- {{ linkError }}
- 预约工作清单同步中,正在自动读取最新日历与待办;已保存内容无需重复提交。
-
-
-
-
filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })">
-
-
-
-
-
-
-
-
-
-
-
-
正在处理关联前置事项,完成后可继续原事项。
-
{{ navigationError }}
-
-
setScope({ kind: 'container', containerBookingId: id, label })" />
- {{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}
到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。
-
-
- 正在读取明细…
-
-
- {{ item.nextAction.label }}查看详情
-
-
{{ item.title }}{{ workStatus(item) }}
-
{{ workDate(item) }}{{ item.timezone || '地点时区待核实' }}
-
{{ typeLabels[item.type] }}{{ flowLabels[item.flow] }}地点:{{ item.destinationName || item.originName || '地点待核实' }}负责人:{{ item.owner?.name || '待分派' }}跟进:{{ item.followupDate }}
-
{{ reason }}
-
-
- 当前范围没有工作事项。
-
-
-
-
-
-
-
+
+
+ {{ notice }}
+
+
+
+ {{ editor.title }}
+ 自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。
+
+ 上次提交结果尚未确认,请使用原请求重试,避免重复修改。
+
+ 重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。
+
+
+
+ 返回原事项
+ external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
+
+
diff --git a/src/modules/appointment-calendar/AppointmentRepair.test.ts b/src/modules/appointment-calendar/AppointmentRepair.test.ts
index 89a7a755..64f008cc 100644
--- a/src/modules/appointment-calendar/AppointmentRepair.test.ts
+++ b/src/modules/appointment-calendar/AppointmentRepair.test.ts
@@ -1,86 +1,31 @@
import { flushPromises, mount } from '@vue/test-utils'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { ApiResponseError } from '@/shared/api/http'
-import { defineComponent, h, inject, KeepAlive, onBeforeUnmount, ref, type Ref } from 'vue'
-import { ElDialog, ElPopover } from 'element-plus'
-import type { LeaveGuard } from './useWorkDraft'
+import { defineComponent, h, KeepAlive, ref } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
-import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
-import { listArrivalCalendar } from './api'
-import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
-const routing = vi.hoisted(() => ({ query: {} as Record, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
-vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
-vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
-vi.mock('./workApi', async original => ({ ...await original(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
-vi.mock('./api', async original => ({ ...await original(), listArrivalCalendar: vi.fn() }))
-function render(realDialog = false, active?: Ref) {
- const host = active ? defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div', { 'data-other-page': '' }) }) }) : AppointmentCalendarView
- return mount(host, { attachTo: document.body, global: { components: { ElDialog, ElPopover }, directives: { loading: {} }, stubs: {
- teleport: !active,
- ElTooltip: active ? false : { template: '' },
- ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: ' $emit(\'update:modelValue\', false))">关闭
' },
- AdminPage: { template: '' },
- QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields', 'loading'], emits: ['update:modelValue', 'search'], template: '' },
- DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'page', 'pageSize'], emits: ['update:page'], template: `| {{ column.label }} |
|---|
| {{ row[column.key] }} |
` },
- ElButton: { props: ['type'], template: '' },
- WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '
' },
- WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
- const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
- const unregister = register(async () => routing.allowLeave)
- onBeforeUnmount(unregister)
- }, template: '{{ itemKey }}
' },
- SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '' },
- LoadErrorState: { props: ['title', 'message'], template: '{{ title }} {{ message }}重试' },
- ApiErrorMeta: true,
- } } })
-}
-describe('appointment repair regression', () => {
- afterEach(() => vi.useRealTimers())
+import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
+import { listTripCalendar } from './tripCalendar'
+vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
+vi.mock('./tripCalendar', async original => ({ ...await original(), listTripCalendar: vi.fn() }))
+describe('calendar cache lifecycle', () => {
beforeEach(() => {
- vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
- routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
- vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
- vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
- vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
- vi.mocked(listArrivalCalendar).mockResolvedValue([])
+ vi.clearAllMocks()
+ vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
+ vi.mocked(getWarehouseArrivals).mockResolvedValue([])
+ vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
})
- it('waits for synchronization before restoring calendar, summary and current work without showing a failed save', async () => {
- vi.useFakeTimers()
- routing.query = { workScope: 'global', workView: 'ALL' }
- const pending = new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)
- vi.mocked(getWorkCalendar).mockRejectedValueOnce(pending)
- vi.mocked(getWorkSummary).mockRejectedValueOnce(pending)
- vi.mocked(getWorkItems).mockRejectedValueOnce(pending)
- const wrapper = render(); await flushPromises()
- expect(wrapper.text()).toContain('同步中')
- expect(wrapper.find('[role="alert"]').exists()).toBe(false)
- expect(wrapper.text()).not.toContain('加载失败')
- await vi.advanceTimersByTimeAsync(2000); await flushPromises()
- expect(getWorkCalendar).toHaveBeenCalledTimes(2)
- expect(getWorkSummary).toHaveBeenCalledTimes(2)
- expect(getWorkItems).toHaveBeenCalledTimes(2)
- expect(wrapper.text()).not.toContain('同步中')
- wrapper.unmount()
- })
- it('cancels synchronization polling when leaving the cached calendar', async () => {
- vi.useFakeTimers()
- vi.mocked(getWorkSummary).mockRejectedValue(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409))
- const active = ref(true), wrapper = render(false, active); await flushPromises()
- expect(getWorkSummary).toHaveBeenCalledTimes(1)
+ it('aborts reads when cached and reloads on return without creating background work polling', async () => {
+ const active = ref(true)
+ const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) })
+ const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '' }, AppointmentCalendarPanel: { template: '' }, AppDialog: true } } })
+ await flushPromises()
+ expect(getCalendarSources).toHaveBeenCalledTimes(1)
+ const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]!
active.value = false; await flushPromises()
- await vi.advanceTimersByTimeAsync(30000)
- expect(getWorkSummary).toHaveBeenCalledTimes(1)
- wrapper.unmount()
- })
- it('removes the teleported dialog while a cached appointment page is inactive and restores its context', async () => {
- routing.query = { workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'work:1' }
- const active = ref(true), wrapper = render(true, active); await flushPromises()
- expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
- active.value = false; await flushPromises()
- expect(document.body.querySelector('.work-dialog')).toBeNull()
+ expect(signal.aborted).toBe(true)
active.value = true; await flushPromises()
- expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
- expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
+ expect(getCalendarSources).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
})
diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
index 2bd91bd1..43202134 100644
--- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
+++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md
@@ -1,5 +1,24 @@
# 预约工作日历逐操作矩阵
+## 2026-10-19 四类日历恢复(当前结论)
+
+依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
+
+| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
+|---|---|---|---|
+| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
+| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
+| 预约列表 | 新增、审核、删除 | 本轮明确取消通用入口 | 来源业务生成记录;不在日历另建预约或改状态 |
+| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
+| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
+| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
+
+2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
+
+发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
+
+本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
+
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
diff --git a/src/modules/appointment-calendar/schedule.test.ts b/src/modules/appointment-calendar/schedule.test.ts
new file mode 100644
index 00000000..1e696e18
--- /dev/null
+++ b/src/modules/appointment-calendar/schedule.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { projectContainerSchedule } from './schedule'
+import type { CalendarSources, WarehouseArrival } from './scheduleApi'
+const range = { from: '2026-11-01', to: '2026-12-01' }
+const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
+const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
+describe('four-type schedule projection', () => {
+ it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
+ const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
+ expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
+ })
+ it('adds two local calendar days across months without creating a time', () => {
+ const events = projectContainerSchedule(sources, [warehouse], range, true)
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
+ })
+ it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
+ const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
+ expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
+ expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
+ })
+ it('keeps manually moved containers visible when their arrival is outside this month', () => {
+ const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
+ expect(events).toHaveLength(1)
+ expect(events[0]?.date).toBe('2026-11-08')
+ })
+ it('does not replace the planned date with PDA actual arrival', () => {
+ const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
+ expect(event.date).toBe('2026-11-05')
+ expect(event.canEdit).toBe(false)
+ expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
+ })
+ it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
+ const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
+ expect(events.map(row => row.date)).toEqual([null, null])
+ expect(events.every(row => row.sourcePath === undefined)).toBe(true)
+ expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
+ })
+ it('does not invent default plans when the authoritative warehouse query failed', () => {
+ expect(projectContainerSchedule(sources, null, range, true)).toEqual([])
+ })
+ it('uses the actual return date after completion while preserving the deadline in details', () => {
+ const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true)
+ expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' })
+ })
+})
diff --git a/src/modules/appointment-calendar/schedule.ts b/src/modules/appointment-calendar/schedule.ts
new file mode 100644
index 00000000..0f02b5bb
--- /dev/null
+++ b/src/modules/appointment-calendar/schedule.ts
@@ -0,0 +1,70 @@
+import { addDays } from './calendar'
+import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
+
+export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
+export type ScheduleType = typeof scheduleTypes[number]
+export const scheduleLabels: Record = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' }
+export interface ScheduleEvent {
+ key: string
+ type: ScheduleType
+ date: string | null
+ time: string
+ title: string
+ subtitle?: string
+ fields: Array<{ label: string; value?: string | null }>
+ sourcePath?: string
+ warehouse?: WarehouseArrival
+ canEdit?: boolean
+}
+const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to
+const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—'
+
+export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
+ const events: ScheduleEvent[] = []
+ const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
+ for (const row of sources.containers) {
+ const label = row.containerNo || row.cabinetNo || '—'
+ const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
+ { label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
+ const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
+ const date = row.arrivalAt?.slice(0, 10) || null
+ if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date,
+ time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath,
+ subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定',
+ fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] })
+ // 读取失败时不能用默认值遮掉无法读取的人工安排。
+ if (warehouses === null) continue
+ const warehouse = warehouseMap.get(row.containerBookingId)
+ warehouseMap.delete(row.containerBookingId)
+ const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
+ const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
+ if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
+ time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
+ canEdit: warehouse?.canEdit === true,
+ subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
+ fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
+ { label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
+ { label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
+ }
+ // 人工改期可能跨月,与当前月份的到港记录无关。
+ for (const row of warehouseMap.values()) {
+ const date = row.plannedAt?.slice(0, 10) || null
+ if (!date || !inRange(date, range)) continue
+ events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
+ title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
+ sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
+ fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
+ { label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
+ }
+ for (const row of sources.emptyReturns) {
+ const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
+ if (!at || !inRange(at.slice(0, 10), range)) continue
+ events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
+ title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
+ subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
+ sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
+ fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
+ { label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] })
+ }
+ return events
+}
diff --git a/src/modules/appointment-calendar/scheduleApi.ts b/src/modules/appointment-calendar/scheduleApi.ts
new file mode 100644
index 00000000..edc3ad83
--- /dev/null
+++ b/src/modules/appointment-calendar/scheduleApi.ts
@@ -0,0 +1,50 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+
+export interface CalendarContainer {
+ containerBookingId: string
+ cabinetNo: string | null
+ containerNo: string | null
+ vesselName: string | null
+ voyageNo: string | null
+ destinationPortName: string | null
+ destinationWarehouseCode: string | null
+ arrivalAt: string | null
+ arrivalType: 'ACTUAL' | 'ESTIMATED'
+}
+export interface CalendarReturn {
+ containerBookingId: string
+ containerNo: string | null
+ fleetSupplierName: string | null
+ actualEmptyReturnAt: string | null
+ emptyReturnWarning: { status: string; deadlineAt: string | null }
+}
+export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
+export interface WarehouseArrival {
+ sourceContainerBookingId: string
+ containerNo: string | null
+ warehouseName: string | null
+ warehouseCode: string | null
+ plannedAt: string | null
+ actualAt: string | null
+ version: number
+ planCount: number
+ canEdit: boolean
+}
+export interface ScheduleRange { from: string; to: string }
+export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
+ return unwrapApiResponse(await http.get, ApiEnvelope>(
+ '/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
+ ))
+}
+export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
+ return unwrapApiResponse(await http.post, ApiEnvelope>(
+ '/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
+ ))
+}
+export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
+export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
+ return unwrapApiResponse(await http.put, ApiEnvelope>(
+ `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body,
+ { headers: { 'Idempotency-Key': key } },
+ ))
+}
diff --git a/src/modules/appointment-calendar/tripCalendar.test.ts b/src/modules/appointment-calendar/tripCalendar.test.ts
index 13e8b1e3..5d3a9ea2 100644
--- a/src/modules/appointment-calendar/tripCalendar.test.ts
+++ b/src/modules/appointment-calendar/tripCalendar.test.ts
@@ -7,6 +7,13 @@ function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'Amer
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
+ it('counts a shared trip once and excludes cancelled arrangements', () => {
+ const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
+ const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
+ const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
+ expect(result.events).toHaveLength(1)
+ expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
+ })
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
const localRange = {from:'2026-09-28',to:'2026-11-09'}
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
diff --git a/src/modules/appointment-calendar/tripCalendar.ts b/src/modules/appointment-calendar/tripCalendar.ts
index b28b8fee..0115bf4c 100644
--- a/src/modules/appointment-calendar/tripCalendar.ts
+++ b/src/modules/appointment-calendar/tripCalendar.ts
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
-import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
+import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry {
sourceType: 'TRIP_PLAN'
@@ -54,9 +54,10 @@ function localStamp(value: string | null, zone: string | null) {
} catch { return null }
}
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
- const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
+ const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
- for (const row of result.records) {
+ for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
+ if (row.status === 'CANCELLED') continue
// Executed work stays on its actual local date even if the plan changes later.
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
if (row.originCountryCode !== 'US' || !occurrence) {
@@ -65,8 +66,8 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
if (occurrence.date < range.from || occurrence.date >= range.to) continue
const actual = localStamp(row.departedAt, row.originTimezone)
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
- events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
- title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
+ events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
+ title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
fields: [
{ label: '车次状态', value: labels[row.status] || '待核实' },
diff --git a/src/modules/overseas-master-data/components/PickupDemandPanel.vue b/src/modules/overseas-master-data/components/PickupDemandPanel.vue
index 95225533..9fa200fa 100644
--- a/src/modules/overseas-master-data/components/PickupDemandPanel.vue
+++ b/src/modules/overseas-master-data/components/PickupDemandPanel.vue
@@ -61,12 +61,12 @@ watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}
-
+
已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}
-
+
diff --git a/src/modules/overseas-master-data/components/TripPickupPanel.vue b/src/modules/overseas-master-data/components/TripPickupPanel.vue
index c2121c8d..950ccf99 100644
--- a/src/modules/overseas-master-data/components/TripPickupPanel.vue
+++ b/src/modules/overseas-master-data/components/TripPickupPanel.vue
@@ -119,12 +119,12 @@ onBeforeUnmount(() => { sequence++ })
{{ pickupStatusLabels[row.status] || row.status }}{{ row.actualPackages ?? '待取货' }}
{{ file.fileName }}
-
+
{{ selected?.transferNo }} · {{ selected?.pickupAddress }}
-
+
-
+
diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue
index 5ff810f9..74fa0e02 100644
--- a/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue
+++ b/src/modules/overseas-warehouse/components/WarehouseTransferReceiptPanel.vue
@@ -159,7 +159,7 @@ onBeforeUnmount(() => { generation++ })
-
+
保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。
取消确认增加
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
index 12ec2621..d572d6ee 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
@@ -284,11 +284,12 @@ onMounted(load)
{{ field.label }}{{ field.value }}
-
-
+
+ 📎贴标文件
+
+
📦货物信息
📦退货签收与箱内清点
diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts
index 581a84ba..7e197daa 100644
--- a/src/styles/adminDensity.test.ts
+++ b/src/styles/adminDensity.test.ts
@@ -180,31 +180,26 @@ describe('approved Admin density ownership', () => {
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__header { border-bottom: 0; }')
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__footer { border-top: 0;')
})
- it('keeps appointment dialog content scrollable with fixed controls and unclipped calendar days', () => {
- const declarations = (file: string, selector: string) => {
+ it('keeps the simplified calendar in two columns with bounded previews and scrollable daily details', () => {
+ const view = parse(read('modules/appointment-calendar/AppointmentCalendarView.vue')).descriptor
+ const panel = parse(read('modules/appointment-calendar/AppointmentCalendarPanel.vue')).descriptor
+ expect(view.template!.content).toContain(' {
const values: Record = {}
- const css = parse(read(`modules/appointment-calendar/${file}`)).descriptor.styles.map(style => style.content).join('\n')
- cssRoot(css).walkRules(selector, rule => { rule.walkDecls(decl => { values[decl.prop] = decl.value }) })
+ cssRoot(panel.styles.map(style => style.content).join('\n')).walkRules(selector, rule => {
+ if (rule.parent?.type !== 'root') return
+ rule.walkDecls(decl => { values[decl.prop] = decl.value })
+ })
return values
}
- const panel = declarations('AppointmentCalendarView.vue', '.work-panel')
- expect(panel.position).toBeUndefined()
- expect(panel['min-height']).toBe('0')
- expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog.el-dialog:not(.is-fullscreen))').height).toBe('88dvh')
- expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog .el-dialog__body)').overflow).toBe('hidden')
- expect(declarations('AppointmentCalendarView.vue', '.work-layout')['grid-template-columns']).toBeUndefined()
- expect(declarations('AppointmentCalendarView.vue', '.panel-body')).toMatchObject({ 'overflow-y': 'auto', 'min-height': '0' })
- expect(declarations('AppointmentCalendarView.vue', '.panel-header').flex).toBe('none')
- expect(declarations('AppointmentCalendarView.vue', '.panel-footer').flex).toBe('none')
- expect(declarations('AppointmentCalendarView.vue', '.work-row-action .el-button')).toMatchObject({ 'line-height': '20px', 'white-space': 'normal', height: 'auto' })
- expect(declarations('AppointmentCalendarView.vue', '.work-row-action :deep(.el-button > span)')).toMatchObject({ 'line-height': 'inherit', 'white-space': 'normal', 'overflow-wrap': 'anywhere', 'min-width': '0', 'max-width': '100%' })
- expect(declarations('WorkCalendarPanel.vue', '.calendar-toolbar')['justify-content']).toBe('flex-start')
- expect(declarations('AppointmentCalendarView.vue', '.work-inbox').border).toBeUndefined()
- const day = declarations('WorkCalendarPanel.vue', '.work-day')
- expect(day.height).toBe('146px')
- expect(day['min-height']).toBe(day.height)
- expect(day['max-height']).toBe(day.height)
- expect(day.overflow).not.toBe('hidden')
+ expect(declarations('.appointment-calendar')['grid-template-columns']).toBe('minmax(0, 1fr) 360px')
+ expect(declarations('.calendar-day').height).toBe('146px')
+ expect(declarations('.week-grid .calendar-day').height).toBe('270px')
+ expect(declarations('.detail-events')['overflow-y']).toBe('auto')
+ expect(declarations('.detail-event dd')['overflow-wrap']).toBe('anywhere')
+ expect(panel.template!.content).toContain('.slice(0, 3)')
+ expect(panel.template!.content).not.toContain('新增预约')
})
it('shares list status and category tab styles across registered list consumers', () => {
for (const file of [
@@ -372,7 +367,7 @@ describe('approved Admin density ownership', () => {
visit(node.children)
} }
visit(ast.children)
- expect(panes).toBe(file === 'LastMileOrderDetailView' ? 6 : 4)
+ expect(panes).toBe(file === 'LastMileOrderDetailView' ? 7 : 4)
}
})