From ec6de70e670fb92238b2b321cb358fbfc3083677 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 18:55:19 +0800 Subject: [PATCH] feat(admin): add sailing calendar management --- src/app/router/index.ts | 2 + src/core/navigation/adminNavigation.test.ts | 4 + src/core/navigation/adminNavigation.ts | 2 + .../api/platformSailingCalendar.ts | 199 ++++++++++ src/modules/sailing-calendar/index.ts | 2 + .../pages/SailingCalendarView.vue | 361 ++++++++++++++++++ src/modules/sailing-calendar/routes.ts | 8 + src/modules/sailing-calendar/types.ts | 295 ++++++++++++++ 8 files changed, 873 insertions(+) create mode 100644 src/modules/sailing-calendar/api/platformSailingCalendar.ts create mode 100644 src/modules/sailing-calendar/index.ts create mode 100644 src/modules/sailing-calendar/pages/SailingCalendarView.vue create mode 100644 src/modules/sailing-calendar/routes.ts create mode 100644 src/modules/sailing-calendar/types.ts diff --git a/src/app/router/index.ts b/src/app/router/index.ts index e64799da..de5e877a 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -32,6 +32,7 @@ import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes' import { overseasBusinessRoutes } from '@/modules/overseas-business/routes' import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes' import { pdaReleaseRoutes } from '@/modules/pda-releases/routes' +import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes' const router = createRouter({ history: createWebHistory(), @@ -77,6 +78,7 @@ const router = createRouter({ ...downloadRoutes, ...iamRoutes, ...pdaReleaseRoutes, + ...sailingCalendarRoutes, { path: '403', name: 'forbidden', diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 79180a7d..ec14da23 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -35,6 +35,7 @@ describe('adminNavigation', () => { '库位管理', '托盘管理', '整柜业务', + '船期日历', ]) expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访']) expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined() @@ -47,6 +48,7 @@ describe('adminNavigation', () => { expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ '尾程订单', '入库单', + '库位管理', '排车列表', '出库单', '派送单列表', @@ -68,6 +70,7 @@ describe('adminNavigation', () => { '船期价格表', '车辆列表', '下载中心', + '称重机器', ]) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ @@ -76,6 +79,7 @@ describe('adminNavigation', () => { '租户管理', '品牌设置', '菜单权限', + 'PDA版本', ]) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 7f18d240..91e825cd 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -1,6 +1,7 @@ import type { Component } from 'vue' import { Building2, + CalendarDays, ContactRound, ClipboardList, CircleDollarSign, @@ -70,6 +71,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' }, { key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' }, { key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' }, + { key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, ], }, { diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts new file mode 100644 index 00000000..010b2f78 --- /dev/null +++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts @@ -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 | 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>(`${basePath}/calendar`, { params: query }) + return calendar(unwrapApiResponse(response)) +} + +export async function getSailing(id: string) { + const response = await http.get, ApiEnvelope>(`${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>(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>(`${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>(`${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>(`${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>(`${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>(`${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>(`${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>(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>(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>(`${channelPath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-channel') } }) + return channel(unwrapApiResponse(response)) +} +export async function listSailingTemplates() { + const response = await http.get, ApiEnvelope>(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>(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>(`${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>(`${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>(`${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>(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>(`${basePath}/${encodeURIComponent(id)}/exception-reassign`, payload, { headers: { 'Idempotency-Key': idempotencyKey('sailing-exception-reassign') } }) + return unwrapApiResponse(response) +} diff --git a/src/modules/sailing-calendar/index.ts b/src/modules/sailing-calendar/index.ts new file mode 100644 index 00000000..d46da374 --- /dev/null +++ b/src/modules/sailing-calendar/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './api/platformSailingCalendar' diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue new file mode 100644 index 00000000..3e62ef19 --- /dev/null +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/src/modules/sailing-calendar/routes.ts b/src/modules/sailing-calendar/routes.ts new file mode 100644 index 00000000..39dbbc1b --- /dev/null +++ b/src/modules/sailing-calendar/routes.ts @@ -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[] diff --git a/src/modules/sailing-calendar/types.ts b/src/modules/sailing-calendar/types.ts new file mode 100644 index 00000000..a47a1fda --- /dev/null +++ b/src/modules/sailing-calendar/types.ts @@ -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 + +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 +}