From da4cb9c8ad4a01cdeaaa7b8a226d7d0e6a595b6a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 16:36:21 +0800 Subject: [PATCH] fix(appointments): open canonical dispatch details from calendar --- .../AppointmentCalendarView.test.ts | 17 ++++++++-- .../AppointmentCalendarView.vue | 4 +-- .../appointment-calendar/PROTOTYPE_MATRIX.md | 6 ++++ .../appointment-calendar/tripCalendar.test.ts | 34 ++++++++++++------- .../appointment-calendar/tripCalendar.ts | 21 ++++++++---- .../pages/DispatchDetailView.test.ts | 15 ++++++++ .../pages/DispatchDetailView.vue | 7 ++-- 7 files changed, 78 insertions(+), 26 deletions(-) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index 98e7c540..528f29a2 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -88,10 +88,10 @@ describe('simplified appointment entry', () => { wrapper.unmount() }) 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') + routing.denied.add('platform:overseas-warehouse:dispatch: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('缺少排车读取权限') expect(wrapper.text()).toContain('缺少尾程订单读取权限') wrapper.unmount() }) @@ -143,4 +143,17 @@ describe('simplified appointment entry', () => { expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } }) wrapper.unmount() }) + 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: [] }) + const wrapper = renderSchedule(); await flushPromises() + const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) + const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch') + expect(listTripCalendar).toHaveBeenCalledOnce() + expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1') + panel.vm.$emit('select', '2026-10-21', 'week') + panel.vm.$emit('open', event); await flushPromises() + expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } }) + wrapper.unmount() + }) }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index 044a7ede..4992f16a 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -64,12 +64,12 @@ async function load(next = range.value) { } catch (cause) { fail('计划到仓读取失败', cause) } })(), (async () => { - if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return } + if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return } try { 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) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`) } catch (cause) { fail('排车读取失败', cause) } })(), ]) diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md index 6264dbee..9cb526df 100644 --- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md +++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md @@ -1,5 +1,11 @@ # 预约工作日历逐操作矩阵 +## 2026-10-20 排车入口收敛 + +用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。 + +自动验证:相关3文件42项、Admin全量453文件4086项、类型检查与生产构建通过;后端最终常规Maven流程完成生产/测试源码编译,`FleetTripCalendarServiceTest`及真实H2查询的`FleetTripCalendarMapperTest`共9项通过,覆盖真实排车号与内部车次号不同、独立旧车次/取消主单/取货排除、跨日期和查询上限。Gateway路由合同5项通过。均经共享`local-check.ps1`执行;早先其他自有业务在途编译错误及失效生成物尝试不作为通过证据,以最终常规Maven回归为准。接口路径复用、无网关配置或数据库结构变更,匹配发布单元为Admin与Lastleg。 + ## 2026-10-19 四类日历恢复(当前结论) 依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。 diff --git a/src/modules/appointment-calendar/tripCalendar.test.ts b/src/modules/appointment-calendar/tripCalendar.test.ts index 5d3a9ea2..4276a4b8 100644 --- a/src/modules/appointment-calendar/tripCalendar.test.ts +++ b/src/modules/appointment-calendar/tripCalendar.test.ts @@ -3,16 +3,24 @@ import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalend 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(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry { - 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: [] } +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: [] } } describe('location-based trip calendar', () => { + 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) + expect(result.events[0]).toMatchObject({ key: 'dispatch-DS/REAL', title: 'DS/REAL 排车', sourcePath: '/overseas-warehouse/dispatches/DS%2FREAL' }) + const missing = projectTripCalendar({ records: [{ ...row, dispatchNo: '' }], unresolvedRecords: [] }, range) + expect(missing.events).toEqual([]) + expect(missing.unresolved[0]!.incompleteReasons).toContain('DISPATCH_REFERENCE_UNKNOWN') + }) 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 shared = trip('DS-SHARED', '2026-10-10T12:00:00Z') + const cancelled = { ...trip('DS-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 排车' }) + expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'DS-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'} @@ -25,23 +33,23 @@ describe('location-based trip calendar', () => { it('includes the first and last local day and removes padded rows outside each origin date range', () => { const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')] const projected=projectTripCalendar({records,unresolvedRecords:[]},range) - expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']]) + expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['dispatch-NY-start','2026-10-01','02:30'],['dispatch-LA-end','2026-10-31','23:30']]) }) it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => { const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']} const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range) - expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive']) + expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['unknown','offset','bad','naive']) }) it('uses actual local departure through DST changes while retaining the original plan for comparison', () => { - const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))} - const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events + const row={...trip('DS-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))} + const events=projectTripCalendar({records:[row,trip('DS-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events expect(events.map(event=>event.time)).toEqual(['00:00','01:30']) - expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'}) + expect(events[0]).toMatchObject({date:'2026-11-02',title:'DS-DST 实际发车'}) expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'}) expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'}) }) it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => { - const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'} + const executed = {...trip('DS-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'} expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([]) const november = {from:'2026-11-01',to:'2026-12-01'} const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0] @@ -50,9 +58,9 @@ describe('location-based trip calendar', () => { expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time}) }) it('does not fall back to the plan when an actual departure cannot be resolved', () => { - const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'} + const invalid = {...trip('DS-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'} const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range) expect(result.events).toEqual([]) - expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID']) + expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['DS-INVALID']) }) }) diff --git a/src/modules/appointment-calendar/tripCalendar.ts b/src/modules/appointment-calendar/tripCalendar.ts index 0115bf4c..afcf4b2b 100644 --- a/src/modules/appointment-calendar/tripCalendar.ts +++ b/src/modules/appointment-calendar/tripCalendar.ts @@ -2,8 +2,8 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { ScheduleEvent } from './schedule' export interface TripCalendarEntry { - sourceType: 'TRIP_PLAN' - tripNo: string + sourceType: 'DISPATCH' + dispatchNo: string status: string originWarehouseId: string | null originWarehouseCode: string | null @@ -23,6 +23,7 @@ export const tripIssueLabels: Record = { ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实', ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整', LOCAL_DATE_UNRESOLVED: '无法确定当地日期', + DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失', } export function tripIssues(row: TripCalendarEntry) { return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';') @@ -56,7 +57,13 @@ function localStamp(value: string | null, zone: string | null) { export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) { const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords] const labels: Record = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' } - for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) { + const seen = new Set() + for (const row of result.records) { + if (!row.dispatchNo?.trim()) { + unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'DISPATCH_REFERENCE_UNKNOWN'] }); continue + } + if (seen.has(row.dispatchNo)) continue + seen.add(row.dispatchNo) 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) @@ -66,11 +73,11 @@ 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: 'dispatch', date: occurrence.date, time: occurrence.time, - title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排', - sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`, + events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time, + title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排', + sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`, fields: [ - { label: '车次状态', value: labels[row.status] || '待核实' }, + { label: '运输状态', value: labels[row.status] || '待核实' }, { label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode }, { label: '地点时区', value: row.originTimezone }, { label: '计划出发(UTC)', value: row.plannedDepartureAt }, diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts index a604054a..09f3121e 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts @@ -19,6 +19,21 @@ vi.mock('@/modules/overseas-master-data', async () => ({ const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } } beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) }) describe('whole-dispatch four-tab detail', () => { + it('returns to the original appointment date and view from the dispatch detail', async () => { + query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week' + const wrapper = mount(DispatchDetailView, options); await flushPromises() + expect(wrapper.get('button.density-back').text()).toContain('返回预约日历') + await wrapper.get('button.density-back').trigger('click') + expect(push).toHaveBeenCalledWith('/appointment-calendar?date=2026-10-21&mode=week') + wrapper.unmount() + }) + it('ignores an external calendar return URL and keeps the normal dispatch-list return', async () => { + query.appointmentReturn = 'https://outside.invalid/appointment-calendar?date=2026-10-21' + const wrapper = mount(DispatchDetailView, options); await flushPromises() + await wrapper.get('button.density-back').trigger('click') + expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatches' }) + wrapper.unmount() + }) it('uses only four accessible colored tabs and switches without another network read', async () => { const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }] vi.mocked(getDispatch).mockResolvedValueOnce(value) diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue index 6bf14f9b..52ee2267 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue @@ -3,6 +3,7 @@ import '@/styles/adminDensity.css' import { computed, onBeforeUnmount, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data' +import { appointmentReturnTarget } from '@/modules/appointment-calendar' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' @@ -65,7 +66,9 @@ async function load() { catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') } finally { if (current === generation) loading.value = false } } -function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) } +const appointmentReturn = computed(() => appointmentReturnTarget(route.query)) +const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表') +function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) } function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true } async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() } async function handleSaved() { rejectionError.value = null; await load() } @@ -74,7 +77,7 @@ onBeforeUnmount(() => { generation++ })