优化
This commit is contained in:
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('queries visible containers and updates the whole-container expected arrival', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never)
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
|
||||
|
||||
await listContainerArrivalTimes(['9001'])
|
||||
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
|
||||
@@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => {
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } })
|
||||
})
|
||||
|
||||
it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => {
|
||||
const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] })
|
||||
expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }])
|
||||
for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] })
|
||||
await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface ContainerArrivalTime {
|
||||
estimatedWarehouseArrivalAt: string | null
|
||||
actualWarehouseArrivalAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
}
|
||||
|
||||
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
|
||||
if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
const count = Number(item.planCount)
|
||||
if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
return { ...item, version: Number(item.version), planCount: count }
|
||||
}
|
||||
|
||||
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
|
||||
@@ -20,7 +28,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
|
||||
queryPath,
|
||||
{ sourceContainerBookingIds },
|
||||
)
|
||||
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
|
||||
return (unwrapApiResponse(response) || []).map(normalizeArrival)
|
||||
}
|
||||
|
||||
export async function updateContainerExpectedArrival(
|
||||
@@ -33,5 +41,5 @@ export async function updateContainerExpectedArrival(
|
||||
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, version: Number(result.version || 0) }
|
||||
return normalizeArrival(result)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './api/platformContainerLoading'
|
||||
export * from './types'
|
||||
export { projectWarehouseArrival } from './warehouseArrival'
|
||||
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
||||
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
||||
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
|
||||
|
||||
+9
-1
@@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) {
|
||||
}
|
||||
|
||||
describe('ContainerDetailView empty return', () => {
|
||||
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
|
||||
const wrapper = await mountDetail()
|
||||
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
|
||||
expect(value).toBe('2026-10-23(默认推算)')
|
||||
expect(value).not.toContain('00:00')
|
||||
})
|
||||
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' })
|
||||
@@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
||||
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
|
||||
})
|
||||
|
||||
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from '../api/platformContainerLoading'
|
||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
|
||||
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||
@@ -91,6 +92,14 @@ async function rememberWorkbenchTab(tabName: string | number) {
|
||||
}
|
||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||
const arrivalTime = ref<ContainerArrivalTime | null>(null)
|
||||
const expectedArrivalText = computed(() => {
|
||||
const row = workbenchDetail.value
|
||||
const arrival = arrivalTime.value
|
||||
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
|
||||
: projected.date ? `${projected.date}(默认推算)` : '—'
|
||||
})
|
||||
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
||||
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
|
||||
@@ -726,7 +735,7 @@ onMounted(async () => {
|
||||
|
||||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
</section>
|
||||
|
||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
|
||||
|
||||
@@ -74,8 +74,8 @@ describe('ContainersView', () => {
|
||||
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
|
||||
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
|
||||
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
|
||||
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 })
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
|
||||
@@ -584,6 +584,30 @@ describe('ContainersView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the calendar default from actual port arrival without submitting an invented time', async () => {
|
||||
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row,
|
||||
estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' },
|
||||
})) })
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
|
||||
const wrapper = await mountOverseasRoute()
|
||||
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
|
||||
expect(picker).toBeDefined()
|
||||
expect(picker.props('modelValue')).toBe('')
|
||||
expect(updateContainerExpectedArrival).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a frozen missing arrival undated and prevents editing it', async () => {
|
||||
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, estimatedPortArrivalAt: '2026-10-20T10:00:00' })) })
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 1 }])
|
||||
const wrapper = await mountOverseasRoute()
|
||||
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '选择预计到仓')!
|
||||
expect(picker.props('disabled')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
|
||||
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type TerminalDrayageView,
|
||||
} from '../api/terminalDrayages'
|
||||
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
@@ -285,6 +286,12 @@ function optional(value: string | null) { return value?.trim() || null }
|
||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
|
||||
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
function defaultArrivalDate(row: ContainerWorkbenchRow) {
|
||||
const arrival = arrivalTimes.value[row.id]
|
||||
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected.isDefault ? projected.date : null
|
||||
}
|
||||
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
|
||||
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
|
||||
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
|
||||
@@ -577,7 +584,7 @@ async function saveDrayageAction() {
|
||||
}
|
||||
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
|
||||
const current = arrivalTimes.value[row.id]
|
||||
if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return
|
||||
if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return
|
||||
arrivalSavingIds.value.push(row.id)
|
||||
try {
|
||||
const updated = await updateContainerExpectedArrival(row.id, {
|
||||
@@ -760,7 +767,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
|
||||
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" placeholder="选择预计到仓" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="defaultArrivalDate(row) ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="defaultArrivalDate(row) ? `${defaultArrivalDate(row)}(默认)` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
|
||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
||||
@@ -772,7 +779,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
|
||||
</DataTable></div></section>
|
||||
</ListPageLayout>
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary {
|
||||
}
|
||||
|
||||
export interface ContainerWorkbenchActualFacts {
|
||||
arrivedDestinationPortAt?: string | null
|
||||
actualSailingAt?: string | null
|
||||
actualPortArrivalAt?: string | null
|
||||
actualWarehouseArrivalAt?: string | null
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectWarehouseArrival } from './warehouseArrival'
|
||||
|
||||
describe('shared warehouse arrival projection', () => {
|
||||
it.each([
|
||||
['2026-10-21T15:49:00', '2026-10-23'],
|
||||
['2026-10-30T23:30:00-07:00', '2026-11-01'],
|
||||
['2026-12-31T23:30:00', '2027-01-02'],
|
||||
['2026-03-07T23:30:00-08:00', '2026-03-09'],
|
||||
])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => {
|
||||
expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true })
|
||||
})
|
||||
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
|
||||
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
}
|
||||
})
|
||||
it('does not replace inconsistent or frozen missing dates with defaults', () => {
|
||||
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
|
||||
}
|
||||
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface WarehouseArrivalPlan {
|
||||
plannedAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
}
|
||||
|
||||
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
|
||||
export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) {
|
||||
const canDefault = !plan || plan.version === 0 && plan.planCount === 0
|
||||
let date = plan?.plannedAt?.slice(0, 10) || null
|
||||
if (!date && canDefault && arrivalAt) {
|
||||
const day = arrivalAt.slice(0, 10)
|
||||
const calendarDate = new Date(`${day}T12:00:00Z`)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) {
|
||||
calendarDate.setUTCDate(calendarDate.getUTCDate() + 2)
|
||||
date = calendarDate.toISOString().slice(0, 10)
|
||||
}
|
||||
}
|
||||
return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault }
|
||||
}
|
||||
Reference in New Issue
Block a user