fix: restore original-container sailing schedule editing

This commit is contained in:
2026-10-03 11:14:11 +08:00
parent 1a52cc0817
commit 8bae21dea2
9 changed files with 252 additions and 6 deletions
@@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ p
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, PalletLabelPrintDialog: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' } }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '<div />' } }))
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() }))
vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() }))
@@ -44,6 +44,16 @@ function accessibleText(wrapper: { element: Element } | undefined) {
}
describe('ContainerDetailView empty return', () => {
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
expect(panel.props('container').id).toBe('9001')
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never)
panel.vm.$emit('saved'); await flushPromises()
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('REASSIGNED / V2')
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1')
})
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
@@ -11,7 +11,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
@@ -549,6 +549,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<ContainerPortSchedulePanel :container="detail" @saved="load" />
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
+1
View File
@@ -16,6 +16,7 @@ export interface ContainerBookingResource {
channelCode: string | null
channelName: string | null
oceanBookingId?: string | null
currentPortScheduleId?: string | null
oceanBookingRequirementId?: string | null
customerId?: string | null
customerName?: string | null
@@ -4,3 +4,16 @@ import { createPortSchedule, listPortSchedules, updatePortSchedule } from './pla
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } } })
const data = { customerId: '1', oceanBookingId: '2', sourceType: 'MANUAL', sourceName: '美森', sourceRef: 'web', vesselName: 'MATSON', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', observedAt: '2026-08-11T10:00:00', expiresAt: '2026-08-14T10:00:00' }
describe('platformPortSchedules', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes long ids in pages', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ schedule: { ...data, id: 9, scheduleNo: 'PS1', customerId: 1, oceanBookingId: 2, version: '3' }, expired: false }], page: 1, size: 20, total: 1, pages: 1 } }); const result = await listPortSchedules({ page: 1, size: 20 }); expect(result.records[0]?.schedule).toMatchObject({ id: '9', customerId: '1', oceanBookingId: '2', version: 3 }) }); it('uses idempotency and expectedVersion for writes', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 0 }, expired: false }, histories: [] } }); vi.mocked(http.put).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 2 }, expired: false }, histories: [] } }); await createPortSchedule(data); await updatePortSchedule('9', { expectedVersion: 1, data }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.put).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 1 }) }) })
it('passes exact container filters and caller-frozen keys and creation reason', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: 1, size: 1, total: 0, pages: 0 } })
const query = { page: 1, size: 1, customerId: '1', oceanBookingId: '2', containerBookingId: '3' }
await listPortSchedules(query)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/port-schedules', { params: query })
const response = { code: '200', data: { portSchedule: { schedule: { ...data, id: '9', scheduleNo: 'PS1', version: 1 }, expired: false }, histories: [] } }
vi.mocked(http.post).mockResolvedValue(response); vi.mocked(http.put).mockResolvedValue(response)
await createPortSchedule({ ...data, containerBookingId: '3' }, 'create-frozen', '人工确认')
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/port-schedules', { expectedVersion: 0, data: { ...data, containerBookingId: '3' }, reason: '人工确认' }, { headers: { 'Idempotency-Key': 'create-frozen' } })
await updatePortSchedule('9', { expectedVersion: 1, data, reason: '甩柜改配' }, 'edit-frozen')
expect(http.put).toHaveBeenLastCalledWith('/platform/v1/port-schedules/9', { expectedVersion: 1, data, reason: '甩柜改配' }, { headers: { 'Idempotency-Key': 'edit-frozen' } })
})
@@ -20,5 +20,5 @@ export async function listPortSchedules(query: PortScheduleQuery) {
return { ...data, records: (data.records || []).map(view), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
}
export async function getPortSchedule(id: string) { const response = await http.get<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
export async function createPortSchedule(data: PortScheduleData) { const response = await http.post<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(basePath, { expectedVersion: 0, data }, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
export async function updatePortSchedule(id: string, request: PortScheduleSaveRequest) { const response = await http.put<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(`${basePath}/${encodeURIComponent(id)}`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
export async function createPortSchedule(data: PortScheduleData, idempotencyKey = idem(), reason?: string) { const response = await http.post<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(basePath, { expectedVersion: 0, data, ...(reason ? { reason } : {}) }, { headers: { 'Idempotency-Key': idempotencyKey } }); return detail(unwrapApiResponse(response)) }
export async function updatePortSchedule(id: string, request: PortScheduleSaveRequest, idempotencyKey = idem()) { const response = await http.put<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(`${basePath}/${encodeURIComponent(id)}`, request, { headers: { 'Idempotency-Key': idempotencyKey } }); return detail(unwrapApiResponse(response)) }
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import Panel from './ContainerPortSchedulePanel.vue'
import { getPortSchedule, createPortSchedule, updatePortSchedule } from '../api/platformPortSchedules'
import { ApiResponseError } from '@/shared/api/http'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn(), createPortSchedule: vi.fn(), updatePortSchedule: vi.fn() }))
const container = { id: 'C1', customerId: '0', customerName: '平台订舱', oceanBookingId: 'B1', currentPortScheduleId: 'S1', bookingNo: 'BOOK1', cabinetNo: 'D1', containerNo: 'TEST', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', estimatedSailingAt: '2026-10-04T01:00:00', estimatedPortArrivalAt: '2026-10-20T01:00:00' }
const original = { id: 'S1', scheduleNo: 'PS1', version: 5, customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: container.estimatedSailingAt, etaAt: container.estimatedPortArrivalAt, observedAt: '2026-10-01T00:00:00', expiresAt: '2026-10-10T00:00:00', portCutoffAt: '2026-10-03T00:00:00', remark: '原备注' }
const result = () => ({ portSchedule: { schedule: { ...original }, expired: false }, histories: [] })
interface VM { begin(): Promise<void>; save(): Promise<void>; form: Record<string, string>; error: string; open: boolean; schedule: ReturnType<typeof result> | null }
function setup(value = container) {
const wrapper = mount(Panel, { props: { container: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' } } } })
return { wrapper, vm: wrapper.vm as unknown as VM }
}
describe('original container schedule', () => {
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getPortSchedule).mockResolvedValue(result()); vi.mocked(updatePortSchedule).mockResolvedValue(result()); vi.mocked(createPortSchedule).mockResolvedValue(result()) })
it('loads only the server selected ID, preserves original fields, and keeps key/body after failure', async () => {
const { wrapper, vm } = setup(); await vm.begin()
expect(getPortSchedule).toHaveBeenCalledExactlyOnceWith('S1')
vm.form.vesselName = 'NEW'; vm.form.voyageNo = 'V2'; vm.form.reason = '原船满载改配'
vi.mocked(updatePortSchedule).mockRejectedValueOnce(new Error('timeout'))
await vm.save(); expect(vm.form.vesselName).toBe('NEW'); expect(vm.error).toContain('timeout')
await vm.save()
expect(vi.mocked(updatePortSchedule).mock.calls[1]).toEqual(vi.mocked(updatePortSchedule).mock.calls[0])
expect(updatePortSchedule).toHaveBeenLastCalledWith('S1', { expectedVersion: 5, reason: '原船满载改配', data: expect.objectContaining({ vesselName: 'NEW', voyageNo: 'V2', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', observedAt: original.observedAt, expiresAt: original.expiresAt, portCutoffAt: original.portCutoffAt, remark: '原备注', containerBookingId: 'C1' }) }, expect.any(String))
expect(createPortSchedule).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not edit a shared booking schedule or another container even when ID is supplied', async () => {
vi.mocked(getPortSchedule).mockResolvedValue({ ...result(), portSchedule: { schedule: { ...original, containerBookingId: 'C2' }, expired: false } })
const { vm, wrapper } = setup(); await vm.begin(); vm.form.reason = '改配'; await vm.save()
expect(vm.error).toContain('不能修改共享或其他柜船期'); expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount()
})
it('list-only account can see four-field history but cannot write', async () => {
can.mockImplementation((...args: unknown[]) => args[0] === 'platform:firstleg:port-schedule:list')
vi.mocked(getPortSchedule).mockResolvedValue({ ...result(), histories: [{ id: 'H1', action: 'UPDATE', reason: '改配', operatorName: '操作员', createdAt: '2026-10-03T00:00:00', beforeSnapshot: JSON.stringify(original), afterSnapshot: JSON.stringify({ ...original, vesselName: 'NEW' }) }] })
const { wrapper, vm } = setup(); await vm.begin(); await flushPromises(); await vm.save()
expect(wrapper.text()).toContain('NEW'); expect(wrapper.text()).toContain('操作员'); expect(wrapper.text()).toContain('改配')
expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount()
})
it('creation needs explicit source and dates and locks original ownership', async () => {
const { wrapper, vm } = setup({ ...container, currentPortScheduleId: '' }); await vm.begin(); vm.form.reason = '首次登记'
await vm.save(); expect(createPortSchedule).not.toHaveBeenCalled(); expect(vm.error).toContain('来源')
Object.assign(vm.form, { sourceName: '平台人工确认', sourceRef: '船司邮件号', observedAt: '2026-10-03T00:00:00', expiresAt: '2026-10-05T00:00:00', portCutoffAt: '2026-10-03T01:00:00' })
await vm.save(); expect(getPortSchedule).not.toHaveBeenCalled()
expect(createPortSchedule).toHaveBeenCalledWith(expect.objectContaining({ customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'MANUAL', sourceRef: '船司邮件号', expiresAt: '2026-10-05T00:00:00', portCutoffAt: '2026-10-03T01:00:00' }), expect.any(String), '首次登记')
wrapper.unmount()
})
it('missing original ownership blocks initialization and late read cannot populate another container', async () => {
const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).toContain('不能维护船期'); first.wrapper.unmount()
let resolve!: (value: ReturnType<typeof result>) => void
vi.mocked(getPortSchedule).mockReturnValue(new Promise(done => { resolve = done }))
const { wrapper, vm } = setup(); const pending = vm.begin()
await wrapper.setProps({ container: { ...container, id: 'C2', currentPortScheduleId: 'S2' } }); resolve(result()); await pending
expect(vm.open).toBe(false); expect(vm.schedule).toBe(null); wrapper.unmount()
})
it('retains edited input and original version on conflict and does not silently refresh it', async () => {
const { wrapper, vm } = setup(); await vm.begin(); vm.form.vesselName = 'CONFLICT'; vm.form.reason = '改配'
vi.mocked(updatePortSchedule).mockRejectedValue(new ApiResponseError('版本冲突', 'A0409'))
await vm.save(); expect(vm.form.vesselName).toBe('CONFLICT'); expect(vm.error).toContain('输入已保留')
expect(getPortSchedule).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
})
it('separates create and edit permissions and rejects missing reason or impossible dates', async () => {
can.mockImplementation((...args: unknown[]) => args[0] !== 'platform:firstleg:port-schedule:create')
const first = setup({ ...container, currentPortScheduleId: '' }); await first.vm.begin(); await first.vm.save(); expect(createPortSchedule).not.toHaveBeenCalled(); first.wrapper.unmount()
const { wrapper, vm } = setup(); await vm.begin(); await vm.save(); expect(updatePortSchedule).not.toHaveBeenCalled()
vm.form.reason = '改配'; vm.form.etaAt = '2026-01-01T00:00:00'; await vm.save(); expect(vm.error).toContain('不能早于'); expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount()
})
it('ignores late save after closing and renders no internal IDs in snapshots', async () => {
let resolve!: (value: ReturnType<typeof result>) => void
vi.mocked(updatePortSchedule).mockReturnValue(new Promise(done => { resolve = done }))
const { wrapper, vm } = setup(); await vm.begin(); vm.form.vesselName = 'NEW'; vm.form.reason = '改配'; const saved = vm.save()
vm.open = false; await flushPromises(); resolve(result()); await saved
expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
})
it('does not send another update when transport values have not changed', async () => {
const { wrapper, vm } = setup(); await vm.begin(); vm.form.reason = '重复点击'
await vm.save(); expect(updatePortSchedule).not.toHaveBeenCalled(); expect(vm.error).toContain('未变更')
expect(wrapper.text()).not.toContain('(UTC)'); wrapper.unmount()
})
})
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { createPortSchedule, getPortSchedule, updatePortSchedule } from '../api/platformPortSchedules'
import type { PortScheduleData, PortScheduleDetail, PortScheduleSaveRequest } from '../types'
const props = defineProps<{ container: {
id: string; currentPortScheduleId?: string | null; customerId?: string | null; customerName?: string | null
oceanBookingId?: string | null; bookingNo: string; cabinetNo: string; containerNo: string | null
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName?: string | null
destinationPortCode: string | null; destinationPortName?: string | null
estimatedSailingAt: string | null; estimatedPortArrivalAt: string | null
} }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can('platform:firstleg:port-schedule:list'))
const canWrite = computed(() => access.can(`platform:firstleg:port-schedule:${props.container.currentPortScheduleId || schedule.value ? 'edit' : 'create'}`))
const open = ref(false), busy = ref(false), loading = ref(false), error = ref('')
const schedule = ref<PortScheduleDetail | null>(null)
const pending = ref<{ id: string | null; key: string; request: PortScheduleSaveRequest } | null>(null)
const form = reactive({ vesselName: '', voyageNo: '', etdAt: '', etaAt: '', reason: '', sourceName: '', sourceRef: '', observedAt: '', expiresAt: '', portCutoffAt: '', bookingCutoffAt: '', customsCutoffAt: '' })
let generation = 0
const scope = computed(() => JSON.stringify([props.container.id, props.container.customerId, props.container.oceanBookingId, props.container.currentPortScheduleId]))
const prerequisite = computed(() => {
const c = props.container
if (!c.oceanBookingId || c.customerId == null || c.customerId === '') return '尚未取得原订舱及客户归属,不能维护船期。'
if (!c.currentPortScheduleId && (!c.departurePortCode || !c.destinationPortCode)) return '原订舱起运港或目的港缺失,不能登记船期。'
return ''
})
const dates = ['etdAt', 'etaAt'] as const
const extraDates = ['observedAt', 'expiresAt', 'portCutoffAt', 'bookingCutoffAt', 'customsCutoffAt'] as const
const dateLabels = { etdAt: '预计开船 ETD', etaAt: '预计到港 ETA', observedAt: '信息确认时间', expiresAt: '信息有效至', portCutoffAt: '截港时间', bookingCutoffAt: '截单时间(选填)', customsCutoffAt: '截关时间(选填)' }
function time(value?: string | null) { return value ? value.replace('T', ' ') : '—' }
function snapshot(raw?: string | null) {
if (!raw) return '—'
try { const value = JSON.parse(raw) as PortScheduleData; return `${value.vesselName || '—'} / ${value.voyageNo || '—'};ETD ${time(value.etdAt)};ETA ${time(value.etaAt)}` }
catch { return '历史快照无法读取' }
}
async function begin() {
if (!canRead.value || prerequisite.value) return
open.value = true; loading.value = true; error.value = ''; schedule.value = null; pending.value = null
const current = ++generation, c = props.container
Object.assign(form, { vesselName: c.vesselName || '', voyageNo: c.voyageNo || '', etdAt: c.estimatedSailingAt || '', etaAt: c.estimatedPortArrivalAt || '', reason: '', sourceName: '', sourceRef: '', observedAt: '', expiresAt: '', portCutoffAt: '', bookingCutoffAt: '', customsCutoffAt: '' })
try {
if (c.currentPortScheduleId) {
const result = await getPortSchedule(c.currentPortScheduleId)
if (current !== generation || !open.value) return
const value = result.portSchedule.schedule
if (value.id !== c.currentPortScheduleId || value.containerBookingId !== c.id || value.oceanBookingId !== c.oceanBookingId || value.customerId !== c.customerId) throw new Error('当前船期不属于该柜的原客户和订舱,不能修改共享或其他柜船期。')
schedule.value = result
Object.assign(form, { vesselName: value.vesselName, voyageNo: value.voyageNo, etdAt: value.etdAt || '', etaAt: value.etaAt || '' })
}
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '船期读取失败').message }
finally { if (current === generation) loading.value = false }
}
async function save() {
if (!open.value || loading.value || busy.value || !canRead.value || !canWrite.value || prerequisite.value) return
if (props.container.currentPortScheduleId && !schedule.value) return
if (!pending.value) {
const original = schedule.value?.portSchedule.schedule
if (original && form.vesselName.trim() === original.vesselName && form.voyageNo.trim() === original.voyageNo
&& form.etdAt === (original.etdAt || '') && form.etaAt === (original.etaAt || '')) {
error.value = '船期信息未变更,无需重复保存。'; return
}
if (!form.vesselName.trim() || !form.voyageNo.trim() || !form.etdAt || !form.etaAt || !form.reason.trim()) { error.value = '请填写船名、航次、预计开船/到港时间和修改原因。'; return }
if (form.etaAt < form.etdAt) { error.value = '预计到港时间不能早于预计开船时间。'; return }
let data: PortScheduleData
if (schedule.value) {
const { id: _id, scheduleNo: _no, version: _version, updatedAt: _updatedAt, ...original } = schedule.value.portSchedule.schedule
data = { ...original, vesselName: form.vesselName.trim(), voyageNo: form.voyageNo.trim(), etdAt: form.etdAt, etaAt: form.etaAt }
} else {
if (!form.sourceName.trim() || !form.sourceRef.trim() || !form.observedAt || !form.expiresAt || !form.portCutoffAt) { error.value = '首次登记须填写来源名、来源参考、信息确认时间、信息有效至和截港时间。'; return }
if (form.expiresAt <= form.observedAt || form.portCutoffAt > form.etdAt || (form.bookingCutoffAt && form.bookingCutoffAt > form.etdAt) || (form.customsCutoffAt && form.customsCutoffAt > form.etdAt)) { error.value = '有效至须晚于确认时间,各截点不得晚于预计开船。'; return }
const c = props.container
data = { customerId: c.customerId!, oceanBookingId: c.oceanBookingId!, containerBookingId: c.id, sourceType: 'MANUAL', sourceName: form.sourceName.trim(), sourceRef: form.sourceRef.trim(), vesselName: form.vesselName.trim(), voyageNo: form.voyageNo.trim(), departurePortCode: c.departurePortCode!, departurePortName: c.departurePortName, destinationPortCode: c.destinationPortCode!, destinationPortName: c.destinationPortName, etdAt: form.etdAt, etaAt: form.etaAt, observedAt: form.observedAt, expiresAt: form.expiresAt, portCutoffAt: form.portCutoffAt, bookingCutoffAt: form.bookingCutoffAt || null, customsCutoffAt: form.customsCutoffAt || null }
}
pending.value = { id: schedule.value?.portSchedule.schedule.id || null, key: crypto.randomUUID(), request: { expectedVersion: schedule.value?.portSchedule.schedule.version ?? 0, data, reason: form.reason.trim() } }
}
const current = generation, command = pending.value
busy.value = true; error.value = ''
try {
const result = command.id ? await updatePortSchedule(command.id, command.request, command.key) : await createPortSchedule(command.request.data, command.key, command.request.reason || '')
if (current !== generation || !open.value) return
schedule.value = result; pending.value = null; emit('saved')
} catch (cause) {
if (current === generation) {
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '船期保存失败').message
if (cause instanceof ApiResponseError && cause.code === 'A0409') error.value += ';输入已保留,请关闭后重新打开核对最新版本。'
}
} finally { if (current === generation) busy.value = false }
}
watch(open, value => { if (!value) { generation++; loading.value = false; busy.value = false } })
watch(scope, () => { open.value = false; generation++; schedule.value = null; pending.value = null })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<div class="schedule-actions">
<el-button v-if="canRead" :disabled="!!prerequisite" @click="begin">船期维护与历史</el-button>
<span v-if="prerequisite">{{ prerequisite }}</span>
<span v-else-if="!canRead">当前账号无船期读取权限。</span>
</div>
<AppDialog v-model="open" class="admin-density-dialog" :title="container.currentPortScheduleId ? '原柜船期与修改历史' : '登记原柜船期'" :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" :confirm-text="pending ? '重试原请求' : '保存船期'" @confirm="save">
<div v-loading="loading" class="schedule-body">
<p>{{ container.cabinetNo || '—' }} · {{ container.containerNo || '—' }} · 订舱 {{ container.bookingNo || '—' }} · {{ container.customerName || '—' }}</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<p v-if="!container.currentPortScheduleId">请登记本柜的船名、航次和预计时间,并按实际信息填写来源及有效期。</p>
<el-form label-position="top" class="density-form" :disabled="!canWrite || busy || !!pending || loading">
<el-row :gutter="16">
<el-col :span="container.currentPortScheduleId ? 12 : 8"><el-form-item label="船名" required><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></el-col>
<el-col :span="container.currentPortScheduleId ? 12 : 8"><el-form-item label="航次" required><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></el-col>
<el-col v-for="key in dates" :key="key" :span="container.currentPortScheduleId ? 12 : 8"><el-form-item :label="dateLabels[key]" required><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></el-col>
<template v-if="!container.currentPortScheduleId">
<el-col :span="8"><el-form-item label="信息来源(人工登记)" required><el-input v-model="form.sourceName" maxlength="128" placeholder="请输入信息来源" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="来源参考号或链接" required><el-input v-model="form.sourceRef" maxlength="255" placeholder="请输入来源参考号或链接" /></el-form-item></el-col>
<el-col v-for="key in extraDates" :key="key" :span="8"><el-form-item :label="dateLabels[key]" :required="!['bookingCutoffAt', 'customsCutoffAt'].includes(key)"><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></el-col>
</template>
<el-col :span="24"><el-form-item label="修改原因" required><el-input v-model="form.reason" type="textarea" maxlength="1000" placeholder="请说明本次修改原因" /></el-form-item></el-col>
</el-row>
</el-form>
<h3>修改历史</h3>
<el-table :data="schedule?.histories || []" empty-text="暂无修改历史">
<el-table-column label="修改时间" min-width="170"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column>
<el-table-column prop="operatorName" label="修改人" width="100" />
<el-table-column prop="reason" label="原因" min-width="150" />
<el-table-column label="修改前" min-width="240"><template #default="{ row }">{{ snapshot(row.beforeSnapshot) }}</template></el-table-column>
<el-table-column label="修改后" min-width="240"><template #default="{ row }">{{ snapshot(row.afterSnapshot) }}</template></el-table-column>
</el-table>
</div>
</AppDialog>
</template>
<style scoped>
.schedule-actions { display:flex; align-items:center; gap:12px; margin:8px 0; }
.schedule-body { max-height:65vh; overflow:auto; }
.schedule-body :deep(.el-date-editor) { width:100%; }
</style>
+1
View File
@@ -1,3 +1,4 @@
export { portScheduleRoutes } from './routes'
export { default as CarrierScheduleQueryPanel } from './components/CarrierScheduleQueryPanel.vue'
export { default as ContainerPortSchedulePanel } from './components/ContainerPortSchedulePanel.vue'
export * from './types'
+2 -2
View File
@@ -26,10 +26,10 @@ export interface PortScheduleResource {
}
export interface PortScheduleView { schedule: PortScheduleResource; expired: boolean }
export interface PortScheduleHistory { id: string; action: string; fromVersion?: number | null; toVersion?: number | null; reason?: string | null; operatorName?: string | null; createdAt: string }
export interface PortScheduleHistory { id: string; action: string; fromVersion?: number | null; toVersion?: number | null; reason?: string | null; operatorName?: string | null; createdAt: string; beforeSnapshot?: string | null; afterSnapshot?: string | null }
export interface PortScheduleDetail { portSchedule: PortScheduleView; histories: PortScheduleHistory[] }
export interface PortSchedulePage { records: PortScheduleView[]; page: number; size: number; total: number; pages: number }
export interface PortScheduleQuery { page: number; size: number; customerId?: string; keyword?: string; expired?: boolean }
export interface PortScheduleQuery { page: number; size: number; customerId?: string; oceanBookingId?: string; containerBookingId?: string; keyword?: string; expired?: boolean }
export type PortScheduleData = Omit<PortScheduleResource, 'id' | 'scheduleNo' | 'version' | 'updatedAt'>
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }