feat(admin): add sailing calendar management

This commit is contained in:
2026-09-27 18:55:19 +08:00
parent 4a0dd3b597
commit ec6de70e67
8 changed files with 873 additions and 0 deletions
@@ -0,0 +1,199 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { SailingAssignment, SailingBookingAdvice, SailingBookingLinkRequest, SailingCalendarEntry, SailingCalendarQuery, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingDetail, SailingExceptionPage, SailingExceptionQuery, SailingExceptionRecord, SailingExceptionReassignRequest, SailingExceptionReassignResult, SailingGenerationPreview, SailingGenerationResult, SailingSaveRequest, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types'
const basePath = '/platform/v1/sailings'
function number(value: unknown, fallback = 0) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function entry(raw: SailingCalendarEntry): SailingCalendarEntry {
return {
...raw,
id: String(raw.id),
sailingNo: String(raw.sailingNo ?? ''),
warehouseId: String(raw.warehouseId ?? ''),
channelCode: String(raw.channelCode ?? ''),
channelName: String(raw.channelName ?? raw.channelCode ?? ''),
volumeSummary: summary(raw.volumeSummary),
version: number(raw.version),
}
}
function summary(raw?: Partial<SailingCalendarEntry['volumeSummary']> | null) {
return {
actualReceivedCbm: number(raw?.actualReceivedCbm),
catchableForecastCbm: number(raw?.catchableForecastCbm),
riskUnmatchedCbm: number(raw?.riskUnmatchedCbm),
bookedContainerCount: number(raw?.bookedContainerCount),
recommendedContainerCount: number(raw?.recommendedContainerCount),
pendingContainerCount: number(raw?.pendingContainerCount),
lateBatchCount: number(raw?.lateBatchCount),
overCapacity: Boolean(raw?.overCapacity),
planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm),
bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm),
catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm),
lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm),
snapshotAt: raw?.snapshotAt ?? null,
}
}
function assignment(raw: SailingAssignment): SailingAssignment {
return {
...raw,
id: String(raw.id),
volumeCbm: number(raw.volumeCbm),
assignmentVersion: raw.assignmentVersion == null ? null : number(raw.assignmentVersion),
}
}
function history(raw: SailingDetail['histories'][number]): SailingDetail['histories'][number] {
return { ...raw, id: String(raw.id) }
}
function calendar(raw: SailingCalendarResponse): SailingCalendarResponse {
const source = raw || {} as SailingCalendarResponse
const rawSummary = source.summary || {} as SailingCalendarResponse['summary']
return {
...source,
records: (source.records || []).map(entry),
summary: { ...summary(rawSummary), sailingCount: number(rawSummary.sailingCount) },
filterOptions: {
warehouses: source.filterOptions?.warehouses || [],
channels: source.filterOptions?.channels || [],
departurePorts: source.filterOptions?.departurePorts || [],
destinationPorts: source.filterOptions?.destinationPorts || [],
},
}
}
export async function listSailingCalendar(query: SailingCalendarQuery) {
const response = await http.get<ApiEnvelope<SailingCalendarResponse>, ApiEnvelope<SailingCalendarResponse>>(`${basePath}/calendar`, { params: query })
return calendar(unwrapApiResponse(response))
}
export async function getSailing(id: string) {
const response = await http.get<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}`)
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
function idempotencyKey(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createSailing(payload: SailingSaveRequest) {
const response = await http.post<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function updateSailing(id: string, payload: SailingSaveRequest) {
const response = await http.put<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function cancelSailing(id: string, expectedVersion: number, reason: string) {
const response = await http.post<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}/cancel`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': idempotencyKey('sailing-cancel') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function copySailing(id: string, payload: { etdAt: string; cutoffAt: string }) {
const response = await http.post<ApiEnvelope<SailingDetail>, ApiEnvelope<SailingDetail>>(`${basePath}/${encodeURIComponent(id)}/copy`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-copy') } })
const result = unwrapApiResponse(response)
return { ...result, sailing: entry(result.sailing), histories: (result.histories || []).map(history) }
}
export async function getSailingVolumeDetail(id: string) {
const response = await http.get<ApiEnvelope<SailingVolumeDetail>, ApiEnvelope<SailingVolumeDetail>>(`${basePath}/${encodeURIComponent(id)}/volume-summary`)
const result = unwrapApiResponse(response)
return { ...result, summary: summary(result.summary), assignments: (result.assignments || []).map(assignment) }
}
export async function getSailingBookingAdvice(id: string) {
const response = await http.get<ApiEnvelope<SailingBookingAdvice>, ApiEnvelope<SailingBookingAdvice>>(`${basePath}/${encodeURIComponent(id)}/booking-advice`)
const result = unwrapApiResponse(response)
return {
...result,
actualReceivedCbm: number(result.actualReceivedCbm),
catchableTotalCbm: number(result.catchableTotalCbm),
planningCapacityCbm: result.planningCapacityCbm == null ? null : number(result.planningCapacityCbm),
bookedContainerCount: number(result.bookedContainerCount),
recommendedContainerCount: number(result.recommendedContainerCount),
pendingContainerCount: number(result.pendingContainerCount),
catchableReceivedCbm: result.catchableReceivedCbm == null ? null : number(result.catchableReceivedCbm),
lateReceivedCbm: result.lateReceivedCbm == null ? null : number(result.lateReceivedCbm),
scenarios: (result.scenarios || []).map((scenario) => ({ ...scenario, capacityCbm: number(scenario.capacityCbm), recommendedContainerCount: number(scenario.recommendedContainerCount), pendingContainerCount: number(scenario.pendingContainerCount) })),
}
}
export async function createSailingBookingLink(id: string, payload: SailingBookingLinkRequest) {
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`${basePath}/${encodeURIComponent(id)}/booking-links`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-booking-link') } })
return unwrapApiResponse(response)
}
const channelPath = '/platform/v1/sailing-channels'
const templatePath = '/platform/v1/sailing-templates'
const exceptionPath = '/platform/v1/sailing-assignments/exceptions'
function channel(raw: SailingChannelResource): SailingChannelResource { return { ...raw, id: String(raw.id), defaultCutoffDays: number(raw.defaultCutoffDays), version: number(raw.version) } }
function template(raw: SailingTemplateResource): SailingTemplateResource { return { ...raw, id: String(raw.id), channelId: String(raw.channelId), warehouseId: String(raw.warehouseId), weekdays: (raw.weekdays || []).map((value) => number(value)), cutoffDays: number(raw.cutoffDays), version: number(raw.version) } }
export async function listSailingChannels() {
const response = await http.get<ApiEnvelope<SailingChannelResource[]>, ApiEnvelope<SailingChannelResource[]>>(channelPath)
const result = unwrapApiResponse(response) as SailingChannelResource[] | { records?: SailingChannelResource[] } | null
return (Array.isArray(result) ? result : result?.records || []).map(channel)
}
export async function createSailingChannel(payload: SailingChannelSaveRequest) {
const response = await http.post<ApiEnvelope<SailingChannelResource>, ApiEnvelope<SailingChannelResource>>(channelPath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } })
return channel(unwrapApiResponse(response))
}
export async function updateSailingChannel(id: string, payload: SailingChannelSaveRequest) {
const response = await http.put<ApiEnvelope<SailingChannelResource>, ApiEnvelope<SailingChannelResource>>(`${channelPath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } })
return channel(unwrapApiResponse(response))
}
export async function listSailingTemplates() {
const response = await http.get<ApiEnvelope<SailingTemplateResource[]>, ApiEnvelope<SailingTemplateResource[]>>(templatePath)
const result = unwrapApiResponse(response) as SailingTemplateResource[] | { records?: SailingTemplateResource[] } | null
return (Array.isArray(result) ? result : result?.records || []).map(template)
}
export async function createSailingTemplate(payload: SailingTemplateSaveRequest) {
const response = await http.post<ApiEnvelope<SailingTemplateResource>, ApiEnvelope<SailingTemplateResource>>(templatePath, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template') } })
return template(unwrapApiResponse(response))
}
export async function updateSailingTemplate(id: string, payload: SailingTemplateSaveRequest) {
const response = await http.put<ApiEnvelope<SailingTemplateResource>, ApiEnvelope<SailingTemplateResource>>(`${templatePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template') } })
return template(unwrapApiResponse(response))
}
export async function previewSailingTemplateGeneration(id: string, payload: { from: string; to: string }) {
const response = await http.post<ApiEnvelope<SailingGenerationPreview>, ApiEnvelope<SailingGenerationPreview>>(`${templatePath}/${encodeURIComponent(id)}/generation-preview`, payload)
return unwrapApiResponse(response)
}
export async function generateSailingTemplate(id: string, payload: { from: string; to: string }) {
const response = await http.post<ApiEnvelope<SailingGenerationResult>, ApiEnvelope<SailingGenerationResult>>(`${templatePath}/${encodeURIComponent(id)}/generate`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-template-generate') } })
return unwrapApiResponse(response)
}
function exceptionRecord(raw: SailingExceptionRecord): SailingExceptionRecord {
return {
...raw,
assignmentId: String(raw.assignmentId),
volumeCbm: number(raw.volumeCbm),
waitingDays: raw.waitingDays == null ? null : number(raw.waitingDays),
assignmentVersion: raw.assignmentVersion == null ? null : number(raw.assignmentVersion),
}
}
export async function listSailingExceptions(query: SailingExceptionQuery = {}) {
const response = await http.get<ApiEnvelope<SailingExceptionPage>, ApiEnvelope<SailingExceptionPage>>(exceptionPath, { params: query })
const raw = unwrapApiResponse(response) as SailingExceptionPage | SailingExceptionRecord[] | null
if (Array.isArray(raw)) return { records: raw.map(exceptionRecord), page: 1, size: raw.length, total: raw.length, pages: raw.length ? 1 : 0 }
return { ...(raw || { records: [], page: 1, size: 20, total: 0, pages: 0 }), records: (raw?.records || []).map(exceptionRecord) }
}
/** Restricted exception reassignment; the server validates cutoff, route and warehouse scope. */
export async function reassignSailingAssignment(id: string, payload: SailingExceptionReassignRequest) {
const response = await http.post<ApiEnvelope<SailingExceptionReassignResult>, ApiEnvelope<SailingExceptionReassignResult>>(`${basePath}/${encodeURIComponent(id)}/exception-reassign`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-exception-reassign') } })
return unwrapApiResponse(response)
}
+2
View File
@@ -0,0 +1,2 @@
export * from './types'
export * from './api/platformSailingCalendar'
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
import type { RouteRecordRaw } from 'vue-router'
export const sailingCalendarRoutes = [{
path: 'sailing-calendar',
name: 'sailing-calendar',
component: () => import('./pages/SailingCalendarView.vue'),
meta: { title: '船期日历', description: '维护计划船期、查看收货体积和订柜建议', permission: 'platform:firstleg:sailing-calendar:list' },
}] satisfies RouteRecordRaw[]
+295
View File
@@ -0,0 +1,295 @@
export type SailingStatus = 'DRAFT' | 'OPEN' | 'CLOSED' | 'DEPARTED' | 'CANCELLED' | 'COMPLETED'
export type SailingAssignmentStatus = 'FORECAST_MATCHED' | 'RECEIVED' | 'LATE' | 'UNMATCHED' | 'SUPERSEDED'
export interface SailingVolumeSummary {
actualReceivedCbm: number
catchableForecastCbm: number
riskUnmatchedCbm: number
bookedContainerCount: number
recommendedContainerCount: number
pendingContainerCount: number
lateBatchCount: number
overCapacity: boolean
planningCapacityCbm?: number | null
bookedPlanningCapacityCbm?: number | null
catchableReceivedCbm?: number | null
lateReceivedCbm?: number | null
snapshotAt?: string | null
}
export interface SailingCalendarEntry {
id: string
sailingNo: string
warehouseId: string
warehouseName: string
channelCode: string
channelName: string
vesselName: string
voyageNo: string
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
destinationPortName?: string | null
etdAt: string
cutoffAt: string
status: SailingStatus
volumeSummary: SailingVolumeSummary
version: number
remark?: string | null
}
export interface SailingFilterOption {
value: string
label: string
}
export interface SailingCalendarFilterOptions {
warehouses: SailingFilterOption[]
channels: SailingFilterOption[]
departurePorts: SailingFilterOption[]
destinationPorts: SailingFilterOption[]
}
export interface SailingCalendarSummary extends SailingVolumeSummary {
sailingCount: number
}
export interface SailingCalendarResponse {
month: string
timezone: string
records: SailingCalendarEntry[]
summary: SailingCalendarSummary
filterOptions: SailingCalendarFilterOptions
}
export interface SailingCalendarQuery {
month: string
warehouseId?: string
channelCode?: string
departurePortCode?: string
destinationPortCode?: string
keyword?: string
status?: SailingStatus
}
export interface SailingSaveRequest {
templateId?: string | null
channelId?: string | null
warehouseId: string
warehouseName?: string | null
channelCode: string
channelName: string
vesselName: string
voyageNo: string
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
destinationPortName?: string | null
etdAt: string
etaAt?: string | null
cutoffAt: string
sourceType?: string | null
plannedCapacityCbm?: number | null
remark?: string | null
expectedVersion?: number
}
export interface SailingDetail {
sailing: SailingCalendarEntry
histories: SailingHistory[]
}
export interface SailingHistory {
id: string
action: string
fromStatus?: string | null
toStatus?: string | null
reason?: string | null
operatorName?: string | null
createdAt: string
}
export interface SailingVolumeDetail {
summary: SailingVolumeSummary
assignments: SailingAssignment[]
}
export interface SailingAssignment {
id: string
receiptBatchId?: string | null
forecastId?: string | null
inboundCode?: string | null
receivedAt?: string | null
volumeCbm: number
status: SailingAssignmentStatus
assignmentSource: 'AUTO' | 'MANUAL' | 'ROLLOVER'
assignmentVersion?: number | null
reason?: string | null
}
export interface SailingExceptionReassignRequest {
assignmentId: string
targetSailingId: string
expectedAssignmentVersion: number
expectedTargetVersion: number
reason: string
}
export interface SailingExceptionReassignResult {
oldAssignmentId: string
newAssignmentId: string
sourceSailingId: string
targetSailingId: string
assignmentVersion: number
matchStatus: SailingAssignmentStatus
volumeCbm?: number | null
forecastVolumeCbm?: number | null
reason?: string | null
}
export type SailingExceptionStatus = Extract<SailingAssignmentStatus, 'LATE' | 'UNMATCHED'>
export interface SailingExceptionRecord {
assignmentId: string
inboundCode?: string | null
receiptBatchId?: string | null
forecastId?: string | null
warehouseId: string
warehouseName?: string | null
channelCode?: string | null
channelName?: string | null
sourceSailingNo?: string | null
sourceVoyageNo?: string | null
status: SailingExceptionStatus
volumeCbm: number
receivedAt?: string | null
expectedArrivalAt?: string | null
waitingDays?: number | null
reason?: string | null
assignmentVersion?: number | null
}
export interface SailingExceptionPage {
records: SailingExceptionRecord[]
page: number
size: number
total: number
pages: number
}
export interface SailingExceptionQuery {
warehouseId?: string
channelCode?: string
status?: SailingExceptionStatus
page?: number
size?: number
}
export interface SailingBookingAdvice {
actualReceivedCbm: number
catchableTotalCbm: number
planningCapacityCbm?: number | null
bookedContainerCount: number
recommendedContainerCount: number
pendingContainerCount: number
scenarios: Array<{
containerTypeCode: string
containerTypeName: string
capacityCbm: number
recommendedContainerCount: number
pendingContainerCount: number
}>
calculatedAt: string
catchableReceivedCbm?: number | null
lateReceivedCbm?: number | null
}
export interface SailingBookingLinkRequest {
oceanBookingId: string
expectedVersion?: number
}
export type SailingChannelStatus = 'ACTIVE' | 'INACTIVE'
export type SailingChannelSourceType = 'CATALOG' | 'SCHEDULE_ONLY'
export interface SailingChannelResource {
id: string
channelCode: string
channelName: string
sourceType: SailingChannelSourceType
mappedChannelCode?: string | null
defaultDeparturePortCode?: string | null
defaultDestinationPortCode?: string | null
defaultCutoffDays: number
status: SailingChannelStatus
remark?: string | null
version: number
}
export interface SailingChannelSaveRequest {
channelCode: string
channelName: string
sourceType: SailingChannelSourceType
mappedChannelCode?: string | null
defaultDeparturePortCode?: string | null
defaultDestinationPortCode?: string | null
defaultCutoffDays: number
remark?: string | null
expectedVersion?: number
status?: SailingChannelStatus
}
export type SailingTemplateStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE'
export interface SailingTemplateResource {
id: string
templateNo: string
channelId: string
channelName: string
warehouseId: string
warehouseName?: string | null
weekdays: number[]
templateName?: string | null
effectiveFrom: string
effectiveTo?: string | null
departureTime: string
cutoffDays: number
cutoffLocalTime: string
voyageRule?: string | null
vesselName?: string | null
departurePortCode?: string | null
destinationPortCode?: string | null
remark?: string | null
status: SailingTemplateStatus
version: number
}
export interface SailingTemplateSaveRequest {
templateName?: string | null
channelId: string
warehouseId: string
warehouseName?: string | null
weekdays: number[]
effectiveFrom: string
effectiveTo?: string | null
departureTime: string
cutoffDays: number
cutoffLocalTime: string
voyageRule?: string | null
vesselName?: string | null
departurePortCode?: string | null
destinationPortCode?: string | null
remark?: string | null
expectedVersion?: number
}
export interface SailingGenerationPreview {
templateId: string
records: Array<{ date?: string; etdAt: string; cutoffAt: string; voyageNo?: string | null; sailingNo?: string | null; conflict?: boolean; conflictReason?: string | null }>
}
export interface SailingGenerationResult extends SailingGenerationPreview {
generatedCount: number
skippedCount: number
}