Files
admin-web/src/modules/appointment-calendar/tripCalendar.ts
T
2026-10-20 16:45:09 +08:00

93 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry {
sourceType: 'DISPATCH'
dispatchNo: 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 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/客户仓的车辆安排',
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
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 }
}