97 lines
5.7 KiB
TypeScript
97 lines
5.7 KiB
TypeScript
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||
import type { ScheduleEvent } from './schedule'
|
||
|
||
export interface TripCalendarEntry {
|
||
sourceType: 'DISPATCH'
|
||
dispatchNo: string
|
||
containerNumbers: 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<string, string> = {
|
||
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(';')
|
||
}
|
||
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<TripCalendarResult>, ApiEnvelope<TripCalendarResult>>(
|
||
'/platform/v1/last-leg/fleet/trips/calendar', { params: tripQueryWindow(range), signal },
|
||
))
|
||
}
|
||
const formatters = new Map<string, Intl.DateTimeFormat>()
|
||
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: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
|
||
const seen = new Set<string>()
|
||
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)
|
||
if (row.originCountryCode !== 'US' || !occurrence) {
|
||
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'LOCAL_DATE_UNRESOLVED'] }); continue
|
||
}
|
||
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 ? '按起运仓当地实际发车时间展示' : '按起运仓当地计划发车时间展示',
|
||
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 ? '时刻待核实' : '尚未记录' },
|
||
{ label: '预计司机结束', value: driverEnd ? `${driverEnd.date} ${driverEnd.time}` : '待核实' },
|
||
{ label: '资料提示', value: tripIssues(row) },
|
||
].filter(field => field.value),
|
||
})
|
||
}
|
||
return { events, unresolved }
|
||
}
|