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
{{ pickupNotice }}
-
+ {{ tripNotice }}
+
+
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 }
+}