From eb0b0f85d171094d068c8248dbef1148be53c36d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 11:38:19 +0800 Subject: [PATCH] fix(calendar): search dispatches by container and clarify local dates --- .../AppointmentCalendarPanel.test.ts | 36 ++++++++++++++++++- .../AppointmentCalendarPanel.vue | 2 +- .../AppointmentCalendarView.test.ts | 28 +++++++++++++-- .../AppointmentCalendarView.vue | 7 ++-- .../appointment-calendar/tripCalendar.test.ts | 16 ++++++++- .../appointment-calendar/tripCalendar.ts | 6 +++- 6 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index 3ca8267c..4cb691ee 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -2,11 +2,45 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue' import { scheduleTypes, type ScheduleEvent } from './schedule' +import { projectTripCalendar, type TripCalendarEntry } from './tripCalendar' const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] }) -const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events }, +const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events }, global: { stubs: { ElButton: { template: '' } } } }) +function reportedDispatch() { + const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] } + return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events +} describe('four-type appointment calendar', () => { + it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => { + const wrapper = render(reportedDispatch(), '2026-10-23') + const search = wrapper.get('[aria-label="搜索当前日历"]') + expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库') + await search.setValue(keyword) + expect(wrapper.findAll('.detail-event')).toHaveLength(0) + expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无安排') + expect(wrapper.get('[data-date="2026-10-23"]').findAll('.event-chip')).toHaveLength(0) + expect(wrapper.get('[data-date="2026-10-22"]').findAll('.event-chip')).toHaveLength(1) + await wrapper.get('[data-date="2026-10-22"]').trigger('click') + expect(wrapper.findAll('.detail-event')).toHaveLength(1) + expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001') + expect(wrapper.get('.detail-event').text()).toContain('09:00') + expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321') + expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00') + expect(wrapper.emitted('range')).toBeUndefined() + wrapper.unmount() + }) + it('does not match an unrelated container and can find the same event by the dispatch number', async () => { + const wrapper = render(reportedDispatch(), '2026-10-22') + await wrapper.get('[aria-label="搜索当前日历"]').setValue('UNRELATED9999') + expect(wrapper.findAll('.detail-event')).toHaveLength(0) + expect(wrapper.findAll('.event-chip')).toHaveLength(0) + await wrapper.get('[aria-label="搜索当前日历"]').setValue('PC-CALENDAR-001') + expect(wrapper.findAll('.detail-event')).toHaveLength(1) + expect(wrapper.findAll('.event-chip')).toHaveLength(1) + expect(wrapper.emitted('range')).toBeUndefined() + wrapper.unmount() + }) it('shows all four types by default with a calendar and persistent daily detail', () => { const wrapper = render(scheduleTypes.map(type => event(type))) expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4) diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index f2cb3991..76913418 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -56,7 +56,7 @@ function changeMode(next: CalendarMode) {
- +
{{ day }}
diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index b776a5ba..e447971d 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -6,7 +6,7 @@ import { ElMessageBox } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import AppointmentCalendarView from './AppointmentCalendarView.vue' import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi' -import { listTripCalendar } from './tripCalendar' +import { listTripCalendar, type TripCalendarEntry } 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() })) @@ -16,6 +16,9 @@ vi.mock('./tripCalendar', async original => ({ ...await original ({ 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 } +function dispatchEntry(overrides: Partial = {}): TripCalendarEntry { + return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides } +} export function renderSchedule() { return mount(AppointmentCalendarView, { global: { stubs: { ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub, @@ -147,7 +150,7 @@ describe('simplified appointment entry', () => { }) it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => { routing.denied.add('platform:fleet:trip:list') - vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] }) + vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [dispatchEntry({ dispatchNo: 'DS/1', containerNumbers: [], plannedDepartureAt: '2026-10-21T16:00:00Z' })], unresolvedRecords: [] }) const wrapper = renderSchedule(); await flushPromises() const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch') @@ -158,4 +161,25 @@ describe('simplified appointment entry', () => { expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } }) wrapper.unmount() }) + it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', async () => { + const good = dispatchEntry() + const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] }) + vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] }) + const wrapper = renderSchedule(); await flushPromises() + const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) + const dispatches = (panel.props('events') as ScheduleEvent[]).filter(event => event.type === 'dispatch') + expect(dispatches).toHaveLength(1) + expect(dispatches[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00' }) + expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' }) + expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' }) + expect(panel.props('incomplete')).toBe(true) + const notice = wrapper.get('.data-notice').text() + expect(notice).toContain('1 条排车未能定位到日历日期') + expect(notice).toContain('PC-TIMEZONE-MISSING:起运仓时区待核实') + expect(notice).toContain('无法确定当地日期') + expect(notice).not.toContain('PC-CALENDAR-001') + expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false) + expect(listTripCalendar).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index 99090ae1..d9c4d016 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -16,7 +16,7 @@ import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue' import { calendarRange, dateKey, type CalendarMode } from './calendar' import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi' import { projectContainerSchedule, type ScheduleEvent } from './schedule' -import { listTripCalendar, projectTripCalendar } from './tripCalendar' +import { listTripCalendar, projectTripCalendar, tripIssues } from './tripCalendar' import { rejectedCommand, type LeaveGuard } from './useWorkDraft' import type { PlanningSourceLookup } from './planningApi' import type { WorkAction } from './workApi' @@ -71,7 +71,10 @@ async function load(next = range.value) { const result = projectTripCalendar(await listTripCalendar(next, signal), next) if (request !== generation) return trips.value = result.events - if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`) + if (result.unresolved.length) { + const details = result.unresolved.slice(0, 3).map(row => `${row.dispatchNo?.trim() || '排车单号缺失'}:${tripIssues(row) || '资料待核实'}`).join(';') + notices.value.push(`${result.unresolved.length} 条排车未能定位到日历日期(${details}${result.unresolved.length > 3 ? ';其余请到排车列表核对' : ''})。`) + } } catch (cause) { fail('排车读取失败', cause) } })(), ]) diff --git a/src/modules/appointment-calendar/tripCalendar.test.ts b/src/modules/appointment-calendar/tripCalendar.test.ts index 4276a4b8..75c0bb4b 100644 --- a/src/modules/appointment-calendar/tripCalendar.test.ts +++ b/src/modules/appointment-calendar/tripCalendar.test.ts @@ -4,9 +4,23 @@ import { http } from '@/shared/api/http' vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data })) const range = { from: '2026-10-01', to: '2026-11-01' } function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry { - return { sourceType: 'DISPATCH', dispatchNo, 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: [] } + return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], 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('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => { + const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] } + const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range) + expect(projected.events).toHaveLength(1) + expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' }) + expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' }) + expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' }) + expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' }) + const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + const parts = Object.fromEntries(beijing.formatToParts(new Date(row.plannedDepartureAt!)).map(part => [part.type, part.value])) + expect([parts.year, parts.month, parts.day, parts.hour, parts.minute]).toEqual(['2026', '10', '23', '00', '00']) + expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-22', to: '2026-10-23' }).events).toHaveLength(1) + expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-23', to: '2026-10-24' }).events).toEqual([]) + }) it('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => { const row = trip('DS/REAL', '2026-10-10T12:00:00Z') const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range) diff --git a/src/modules/appointment-calendar/tripCalendar.ts b/src/modules/appointment-calendar/tripCalendar.ts index afcf4b2b..55419687 100644 --- a/src/modules/appointment-calendar/tripCalendar.ts +++ b/src/modules/appointment-calendar/tripCalendar.ts @@ -4,6 +4,7 @@ import type { ScheduleEvent } from './schedule' export interface TripCalendarEntry { sourceType: 'DISPATCH' dispatchNo: string + containerNumbers: string[] status: string originWarehouseId: string | null originWarehouseCode: string | null @@ -72,14 +73,17 @@ 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 planned = localStamp(row.plannedDepartureAt, row.originTimezone) const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone) events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time, - title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排', + title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '按起运仓当地计划发车时间展示', sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`, fields: [ { label: '运输状态', value: labels[row.status] || '待核实' }, + { label: '关联柜号', value: row.containerNumbers.join('、') }, { label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode }, { label: '地点时区', value: row.originTimezone }, + { label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null }, { label: '计划出发(UTC)', value: row.plannedDepartureAt }, { label: '路线', value: row.stops.slice(0, 3).map(stop => stop.name).join(' → ') + (row.stops.length > 3 ? ` 等${row.stops.length}站` : '') }, { label: '实际出发', value: actual ? `${actual.date} ${actual.time}` : row.departedAt ? '时刻待核实' : '尚未记录' },