From b14d22b55ee6091026f219bffdf9d6d23bb8e349 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 12:01:12 +0800 Subject: [PATCH] feat(appointments): project trip plans by origin time zone --- .../AppointmentCalendarPanel.vue | 8 +- .../AppointmentCalendarView.test.ts | 22 ++++- .../AppointmentCalendarView.vue | 37 ++++++++- .../appointment-calendar/PROTOTYPE_MATRIX.md | 3 +- .../appointment-calendar/tripCalendar.test.ts | 36 ++++++++ .../appointment-calendar/tripCalendar.ts | 83 +++++++++++++++++++ 6 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 src/modules/appointment-calendar/tripCalendar.test.ts create mode 100644 src/modules/appointment-calendar/tripCalendar.ts diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index 3c5b269f..fbba8105 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -8,7 +8,7 @@ export interface CalendarEvent { key: string date: string time: string - type: 'arrival' | 'delivery' | 'pickup' + type: 'arrival' | 'delivery' | 'pickup' | 'trip' title: string subtitle?: string fields: Array<{ label: string; value?: string | null }> @@ -47,7 +47,7 @@ const grouped = computed(() => { }) const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || []) const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] -const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜' } +const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' } const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`) @@ -72,7 +72,7 @@ defineExpose({ selectedDate, mode })
预约日历 - 船舶到港预约送仓预约提柜 + 船舶到港预约送仓预约提柜用车计划
+ 新增预约 @@ -132,6 +132,7 @@ defineExpose({ selectedDate, mode }) .legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; } .legend.delivery::before { background: #27a87a; } .legend.pickup::before { background: #c17a20; } +.legend.trip::before { background: #8562b0; } .view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; } .view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; } .view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; } @@ -151,6 +152,7 @@ defineExpose({ selectedDate, mode }) .arrival { --event-bg: #edf3ff; --event-fg: #3765d4; } .delivery { --event-bg: #e8f8f1; --event-fg: #18845f; } .pickup { --event-bg: #fff4e3; --event-fg: #995a10; } +.trip { --event-bg: #f2edfa; --event-fg: #725199; } .event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); } .event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; } .event-more { padding: 1px 6px; line-height: 16px; } diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index 9717ac76..8fa2c24f 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import AppointmentCalendarView from './AppointmentCalendarView.vue' import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse' import { listArrivalCalendar, listPickupCalendar } from './api' +import { listTripCalendar } from './tripCalendar' +vi.mock('./tripCalendar', async (original) => ({...(await original()),listTripCalendar:vi.fn()})) import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi' -const routing = vi.hoisted(() => ({ query: {} as Record, canSourceRead: true, canPickup: true })) +const routing = vi.hoisted(() => ({ query: {} as Record, canSourceRead: true, canPickup: true, canTrips: true })) vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() })) import { listPlatformWarehouses } from '@/modules/warehouses' vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() })) @@ -13,7 +15,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() })) -vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) })) vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal()), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), @@ -45,11 +47,12 @@ function render() { describe('appointment calendar data integration', () => { beforeEach(() => { vi.resetAllMocks() - routing.query = {}; routing.canSourceRead = true; routing.canPickup = true + routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 }) vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([]) vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0}) vi.mocked(listArrivalCalendar).mockResolvedValue([]) + vi.mocked(listTripCalendar).mockResolvedValue({records:[],unresolvedRecords:[]}) vi.mocked(listPickupCalendar).mockResolvedValue({records: [], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'}) vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 }) vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }) @@ -79,6 +82,19 @@ describe('appointment calendar data integration', () => { expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})]) expect(panel.props('incomplete')).toBe(true) }) + it('omits trip requests without trip list permission and keeps other sources usable', async () => { + routing.canTrips=false + const wrapper=render();await flushPromises() + expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('无车次查看权限') + }) + it('keeps unresolved trip origins outside daily slots and clearly labels their UTC source time', async () => { + vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]}) + const wrapper=render();await flushPromises() + expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([]) + expect(wrapper.text()).toContain('待核实地点的车次(1)');expect(wrapper.text()).toContain('原计划时间保留 UTC') + expect(listTripCalendar).toHaveBeenCalledTimes(1) + }) it('creates one shared appointment for two customers without sending a fabricated header customer', async () => { const wrapper = render() await flushPromises() diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index 551d2078..bd30779f 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -25,6 +25,7 @@ import AppointmentCargoPanel from './AppointmentCargoPanel.vue' import AppointmentContainerPanel from './AppointmentContainerPanel.vue' import { calendarRange, dateKey } from './calendar' import { deleteAppointment, listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api' +import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar' const access = useAccessPreviewStore() const route = useRoute() @@ -38,6 +39,18 @@ const appointments = ref([]) const arrivals = ref([]) const pickups = ref([]) const pickupNotice = ref('') +const tripEvents = ref([]) +const unresolvedTrips = ref([]) +const tripNotice = ref('') +const unresolvedPage = ref(1), unresolvedSize = ref(10) +const unresolvedRows = computed(() => unresolvedTrips.value.slice((unresolvedPage.value - 1) * unresolvedSize.value, unresolvedPage.value * unresolvedSize.value)) +const unresolvedColumns: DataTableColumn[] = [ + { key: 'actions', label: '操作', slotName: 'actions', width: 110, fixed: 'left' }, + { prop: 'tripNo', label: '车次号', minWidth: 210 }, + { prop: 'originWarehouseName', label: '起运仓', minWidth: 170 }, + { prop: 'plannedDepartureAt', label: '原计划出发(UTC)', minWidth: 225 }, + { key: 'issues', label: '待核实资料', slotName: 'issues', minWidth: 230 }, +] const range = ref(calendarRange(dateKey(new Date()), 'month')) const loading = ref(false) const loadError = ref(null) @@ -129,7 +142,7 @@ const events = computed(() => [...arrivals.value.map(arrival => inboundCodes: appointment.items.map(item => item.inboundCode), canEdit: access.can(permission.inboundOrderAppointment), canDelete: access.can('platform:appointment:delete'), -}))]) +})), ...tripEvents.value]) async function load(nextRange = range.value) { range.value = nextRange @@ -143,13 +156,17 @@ async function load(nextRange = range.value) { appointments.value = [] arrivals.value = [] pickups.value = [] + tripEvents.value = []; unresolvedTrips.value = []; unresolvedPage.value = 1 const canReadPickup = access.can('platform:firstleg:terminal-drayage:list') + const canReadTrips = access.can('platform:fleet:trip:list') pickupNotice.value = canReadPickup ? '' : '无提柜查看权限,当前日历不包含提柜预约。' + tripNotice.value = canReadTrips ? '' : '无车次查看权限,当前日历不包含用车计划。' try { - const [result, arrivalResult, pickupResult] = await Promise.allSettled([ + const [result, arrivalResult, pickupResult, tripResult] = await Promise.allSettled([ listFbaAppointmentCalendar(nextRange, calendarController.signal), listArrivalCalendar(nextRange, calendarController.signal), canReadPickup ? listPickupCalendar(nextRange, calendarController.signal) : Promise.resolve(null), + canReadTrips ? listTripCalendar(nextRange, calendarController.signal) : Promise.resolve(null), ]) if (request !== calendarRequest) return if (result.status === 'fulfilled') appointments.value = result.value @@ -160,6 +177,11 @@ async function load(nextRange = range.value) { pickups.value = pickupResult.value.records if (pickupResult.value.truncated || Number(pickupResult.value.unclassifiedCount) > 0) pickupNotice.value = '提柜来源有超出数量上限或目的仓国家待核实的记录,当前统计不完整,请缩小日期范围或完善仓库资料。' } else if (pickupResult.status === 'rejected') sourceErrors.value.push({ title: '提柜预约加载失败', details: getApiErrorDetails(pickupResult.reason, '提柜预约加载失败') }) + if (tripResult.status === 'fulfilled' && tripResult.value) { + const projection = projectTripCalendar(tripResult.value, nextRange) + tripEvents.value = projection.events; unresolvedTrips.value = projection.unresolved + if (projection.unresolved.length) tripNotice.value = '部分车次无法确定起运地点或当地日期,已单列待核实,未计入每日安排。' + } else if (tripResult.status === 'rejected') sourceErrors.value.push({ title: '用车计划加载失败', details: getApiErrorDetails(tripResult.reason, '用车计划加载失败') }) } catch (error) { if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败') } finally { if (request === calendarRequest) loading.value = false } @@ -353,7 +375,16 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
未定时安排({{ unscheduledTotal }})

{{ pickupNotice }}

- +

{{ tripNotice }}

+
+ 待核实地点的车次({{ unresolvedTrips.length }}) +

检索窗口内的车次,尚无法确定当地日期。原计划时间保留 UTC,不用浏览器时区换算。

+ + + + +
+
diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md index e0954359..383e7487 100644 --- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md +++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md @@ -19,9 +19,10 @@ | 在途跨客户合批安排 | 美国预约统一方案的同起运仓、同目的仓合批 | 操作列后选择同起运仓及同目的仓,跨页累计最多100票;批量核对后在1080px弹框填写与入仓入口共用的安排表单。未知箱数保留待核实;暂扣、多目的地和已有预约不可选入。保存必须重新核对,未知结果保留同一命令供重试 | | 未定时安排 | 总览不得伪造时段 | 日历上方独立折叠分页列表;日期未定不补零点或09点,继续编辑仍操作原系统单号;仅有计划日期时日历显示日期,不生成确认时段 | | 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 | +| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 | | 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 | -结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。 +结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜、车次来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。 本轮视觉验收:待人工1920×1080截图确认。 diff --git a/src/modules/appointment-calendar/tripCalendar.test.ts b/src/modules/appointment-calendar/tripCalendar.test.ts new file mode 100644 index 00000000..1b8822ef --- /dev/null +++ b/src/modules/appointment-calendar/tripCalendar.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalendarEntry } from './tripCalendar' +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: [] } +} +describe('location-based trip calendar', () => { + 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'}) + vi.mocked(http.get).mockResolvedValue({data:{records:[],unresolvedRecords:[]}}) + await listTripCalendar(localRange) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/calendar',{params:tripQueryWindow(localRange),signal:undefined}) + expect(() => tripQueryWindow({from:'2026-10-01',to:'2026-11-20'})).toThrow() + }) + 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']]) + }) + 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']) + }) + it('retains original instants through DST overlap and does not replace the plan date with actual departure', () => { + 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 + expect(events.map(event=>event.time)).toEqual(['01:30','01:30']) + expect(events[0]).toMatchObject({date:'2026-11-01',subtitle:'运输计划,不代表收货方预约已确认'}) + 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站'}) + }) +}) diff --git a/src/modules/appointment-calendar/tripCalendar.ts b/src/modules/appointment-calendar/tripCalendar.ts new file mode 100644 index 00000000..be200057 --- /dev/null +++ b/src/modules/appointment-calendar/tripCalendar.ts @@ -0,0 +1,83 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { CalendarEvent } from './AppointmentCalendarPanel.vue' + +export interface TripCalendarEntry { + sourceType: 'TRIP_PLAN' + tripNo: string + status: string + originWarehouseId: string | null + originWarehouseCode: string | null + originWarehouseName: string | null + originCountryCode: string | null + originTimezone: string | null + originWarehouseStatus: string | null + plannedDepartureAt: string | null + expectedDriverEndAt: string | null + departedAt: string | null + completedAt: string | null + stops: Array<{ name: string; address?: string | null; destinationKey?: string | null }> + incompleteReasons: string[] +} +export interface TripCalendarResult { records: TripCalendarEntry[]; unresolvedRecords: TripCalendarEntry[] } +export const tripIssueLabels: Record = { + ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实', + ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整', + LOCAL_DATE_UNRESOLVED: '无法确定当地日期', +} +export function tripIssues(row: TripCalendarEntry) { + return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';') +} +export function tripQueryWindow(range: { from: string; to: string }) { + const start = Date.parse(`${range.from}T00:00:00Z`), end = Date.parse(`${range.to}T00:00:00Z`) + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end - start > 42 * 86400000) throw new Error('日历日期范围无效') + // Query all possible location dates, then place each record using its actual origin zone. + return { from: new Date(start - 14 * 3600000).toISOString(), to: new Date(end + 14 * 3600000).toISOString() } +} +export async function listTripCalendar(range: { from: string; to: string }, signal?: AbortSignal) { + return unwrapApiResponse(await http.get, ApiEnvelope>( + '/platform/v1/last-leg/fleet/trips/calendar', { params: tripQueryWindow(range), signal }, + )) +} +const formatters = new Map() +function localStamp(value: string | null, zone: string | null) { + if (!value || !/(Z|[+-]\d{2}:\d{2})$/.test(value) || !zone?.includes('/') || /^(Etc|SystemV)\//.test(zone)) return null + const instant = new Date(value) + if (!Number.isFinite(instant.getTime())) return null + try { + let formatter = formatters.get(zone) + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + formatters.set(zone, formatter) + } + const parts = Object.fromEntries(formatter.formatToParts(instant).map(part => [part.type, part.value])) + return { date: `${parts.year}-${parts.month}-${parts.day}`, time: `${parts.hour}:${parts.minute}` } + } catch { return null } +} +export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) { + const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords] + const labels: Record = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' } + for (const row of result.records) { + const planned = localStamp(row.plannedDepartureAt, row.originTimezone) + if (row.originCountryCode !== 'US' || !planned) { + unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'LOCAL_DATE_UNRESOLVED'] }); continue + } + if (planned.date < range.from || planned.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: planned.date, time: planned.time, + title: `${row.tripNo} 用车计划`, subtitle: '运输计划,不代表收货方预约已确认', + sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`, + fields: [ + { label: '车次状态', value: labels[row.status] || '待核实' }, + { label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode }, + { label: '地点时区', value: row.originTimezone }, + { 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 ? '时刻待核实' : '尚未记录' }, + { label: '预计司机结束', value: driverEnd ? `${driverEnd.date} ${driverEnd.time}` : '待核实' }, + { label: '资料提示', value: tripIssues(row) }, + ].filter(field => field.value), + }) + } + return { events, unresolved } +}