feat(admin): confirm container departure from detail
This commit is contained in:
@@ -1,15 +1,18 @@
|
|||||||
import ElementPlus from 'element-plus'
|
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import ContainerDetailView from './ContainerDetailView.vue'
|
import ContainerDetailView from './ContainerDetailView.vue'
|
||||||
import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||||
|
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
||||||
|
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
|
||||||
|
|
||||||
const { accessCan, routerPush } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn() }))
|
const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn(), carrierMounts: { value: 0 } }))
|
||||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
|
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />' } }))
|
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />', mounted: () => { carrierMounts.value += 1 } } }))
|
||||||
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() }))
|
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() }))
|
||||||
|
vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
|
||||||
vi.mock('../api/platformContainerLoading', () => ({
|
vi.mock('../api/platformContainerLoading', () => ({
|
||||||
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
|
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
|
||||||
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
|
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
|
||||||
@@ -23,9 +26,20 @@ const overview = {
|
|||||||
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
|
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
|
||||||
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
|
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
|
||||||
}
|
}
|
||||||
|
const outboundDetail = {
|
||||||
|
aggregate: {
|
||||||
|
containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
|
||||||
|
loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
|
||||||
|
inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
|
||||||
|
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
|
||||||
|
},
|
||||||
|
timeline: [],
|
||||||
|
} as ContainerOutboundDispatchDetail
|
||||||
|
|
||||||
function mountView(customs = true) {
|
function mountView(customs = true, outbound = true) {
|
||||||
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage'))
|
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|
||||||
|
|| (customs && permission === 'platform:firstleg:customs:manage')
|
||||||
|
|| (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
|
||||||
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
|
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
|
||||||
}
|
}
|
||||||
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! }
|
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! }
|
||||||
@@ -33,12 +47,15 @@ function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.fi
|
|||||||
describe('ContainerDetailView', () => {
|
describe('ContainerDetailView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
carrierMounts.value = 0
|
||||||
vi.mocked(getContainer).mockResolvedValue(detail)
|
vi.mocked(getContainer).mockResolvedValue(detail)
|
||||||
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
|
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
|
||||||
vi.mocked(getContainerClearance).mockResolvedValue(null)
|
vi.mocked(getContainerClearance).mockResolvedValue(null)
|
||||||
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
|
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
|
||||||
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
|
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
|
||||||
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
|
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
|
||||||
|
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
|
||||||
|
vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
|
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
|
||||||
@@ -77,4 +94,62 @@ describe('ContainerDetailView', () => {
|
|||||||
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
|
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads and displays the whole-container gate without querying an inbound id', async () => {
|
||||||
|
const wrapper = mountView(); await flushPromises()
|
||||||
|
expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
|
||||||
|
expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
|
||||||
|
expect(wrapper.text()).toContain('国内出仓门禁')
|
||||||
|
expect(wrapper.text()).toContain('报关协同完成')
|
||||||
|
expect(wrapper.text()).toContain('IN-1')
|
||||||
|
expect(wrapper.text()).toContain('确认离仓')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => {
|
||||||
|
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
|
||||||
|
const wrapper = mountView(); await flushPromises()
|
||||||
|
await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises()
|
||||||
|
expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce()
|
||||||
|
expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
|
||||||
|
expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
|
||||||
|
expect(getContainerOverview).toHaveBeenCalledTimes(2)
|
||||||
|
expect(carrierMounts.value).toBe(2)
|
||||||
|
expect(wrapper.text()).toContain('已确认离仓')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents a repeated confirmation wave while the first prompt is open', async () => {
|
||||||
|
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never)
|
||||||
|
const wrapper = mountView(); await flushPromises()
|
||||||
|
const button = wrapper.find('.outbound-panel__actions button')
|
||||||
|
await button.trigger('click')
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
||||||
|
expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the outbound gate and never calls its API without view permission', async () => {
|
||||||
|
const wrapper = mountView(true, false); await flushPromises()
|
||||||
|
expect(wrapper.text()).not.toContain('国内出仓门禁')
|
||||||
|
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => {
|
||||||
|
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
||||||
|
...outboundDetail,
|
||||||
|
aggregate: {
|
||||||
|
...outboundDetail.aggregate,
|
||||||
|
gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }] } }] },
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
const wrapper = mountView(); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('关联拖车已离仓')
|
||||||
|
expect(wrapper.text()).toContain('ARRIVED')
|
||||||
|
expect(wrapper.text()).toContain('DEPARTED或COMPLETED')
|
||||||
|
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
import { ArrowLeft, FileUp } from '@lucide/vue'
|
||||||
import { ElMessage, type UploadFile } from 'element-plus'
|
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
@@ -12,6 +12,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||||
|
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
||||||
|
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
|
||||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||||
import {
|
import {
|
||||||
cancelContainerException,
|
cancelContainerException,
|
||||||
@@ -32,6 +34,8 @@ const access = useAccessPreviewStore()
|
|||||||
const containerId = computed(() => String(route.params.containerId || ''))
|
const containerId = computed(() => String(route.params.containerId || ''))
|
||||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||||
|
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||||||
|
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detail = ref<ContainerBookingResource | null>(null)
|
const detail = ref<ContainerBookingResource | null>(null)
|
||||||
const overview = ref<ContainerOverview | null>(null)
|
const overview = ref<ContainerOverview | null>(null)
|
||||||
@@ -54,11 +58,21 @@ const exceptionOpen = ref(false)
|
|||||||
const exceptionReason = ref('')
|
const exceptionReason = ref('')
|
||||||
const exceptionSaving = ref(false)
|
const exceptionSaving = ref(false)
|
||||||
const exceptionError = ref<ApiErrorDetails | null>(null)
|
const exceptionError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const outboundLoading = ref(false)
|
||||||
|
const outboundConfirming = ref(false)
|
||||||
|
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
|
||||||
|
const outboundError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const carrierRefreshKey = ref(0)
|
||||||
|
|
||||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||||
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
|
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
|
||||||
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
||||||
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||||
|
const outboundGateRows = computed(() => (outboundDetail.value?.aggregate.gate.inbounds || []).flatMap((inbound) =>
|
||||||
|
(inbound.gate.items || []).map((item) => ({ ...item, inboundId: inbound.inboundId, inboundCode: inbound.inboundCode }))))
|
||||||
|
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
|
||||||
|
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
|
||||||
|
&& outboundDetail.value.aggregate.gate.passed)
|
||||||
|
|
||||||
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
|
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
|
||||||
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||||
@@ -69,10 +83,47 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
||||||
detail.value = resource; overview.value = aggregate
|
detail.value = resource; overview.value = aggregate
|
||||||
|
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOutboundDispatch() {
|
||||||
|
if (!containerId.value || !canViewOutboundDispatch.value) return
|
||||||
|
outboundLoading.value = true; outboundError.value = null
|
||||||
|
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
|
||||||
|
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
|
||||||
|
finally { outboundLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeparture() {
|
||||||
|
const aggregate = outboundDetail.value?.aggregate
|
||||||
|
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
||||||
|
outboundConfirming.value = true
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { type: 'warning', confirmButtonText: '确认离仓' })
|
||||||
|
} catch { outboundConfirming.value = false; return }
|
||||||
|
outboundError.value = null
|
||||||
|
try {
|
||||||
|
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
|
||||||
|
expectedVersion: aggregate.version,
|
||||||
|
remark: '货柜详情确认国内出仓',
|
||||||
|
})
|
||||||
|
carrierRefreshKey.value += 1
|
||||||
|
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
|
||||||
|
try { overview.value = await getContainerOverview(containerId.value) }
|
||||||
|
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
|
||||||
|
} catch (cause) {
|
||||||
|
const details = getApiErrorDetails(cause, '确认国内出仓失败')
|
||||||
|
if (details.code === 'A0409') await loadOutboundDispatch()
|
||||||
|
outboundError.value = details
|
||||||
|
} finally { outboundConfirming.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function outboundStatusText(status?: string | null) {
|
||||||
|
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
|
||||||
|
}
|
||||||
|
|
||||||
function openDocument(type: string) {
|
function openDocument(type: string) {
|
||||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
||||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
|
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
|
||||||
@@ -159,7 +210,20 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
|||||||
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><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>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
|
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><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>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
|
||||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" />
|
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" />
|
||||||
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" /></div>
|
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
|
||||||
|
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundDetail.aggregate.gate.passed ? 'success' : 'danger'">{{ outboundDetail.aggregate.gate.passed ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||||
|
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
||||||
|
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
||||||
|
<template v-else-if="outboundDetail">
|
||||||
|
<p v-if="!outboundDetail.aggregate.gate.passed" class="outbound-panel__hint">以下任一阻断项处理完成后,重新加载门禁即可确认离仓。</p>
|
||||||
|
<div class="gate-list">
|
||||||
|
<article v-for="row in outboundGateRows" :key="`${row.inboundId}-${row.code}`" class="gate-row">
|
||||||
|
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span>{{ row.inboundCode }}</span><span>{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" /></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false" /></AppDialog>
|
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false" /></AppDialog>
|
||||||
@@ -169,5 +233,5 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:12px 16px; }.detail-fields h2 { margin:0 0 10px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.carrier-panel{padding:12px 16px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:12px 16px; }.detail-fields h2 { margin:0 0 10px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 16px}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:10px}.gate-row{display:grid;grid-template-columns:auto minmax(110px,.8fr) minmax(85px,.6fr) minmax(100px,1fr);align-items:center;gap:8px;min-width:0;padding:7px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row span,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.carrier-panel{padding:12px 16px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions{flex-wrap:wrap}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row span,.gate-row small{grid-column:2}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,96 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch'
|
import {
|
||||||
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() } } })
|
changeOutboundDispatch,
|
||||||
describe('platformOutboundDispatch', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes aggregate ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }); const result = await listOutboundDispatches({}); expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) }); it('posts action with version and idempotency', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }); await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) })
|
checkContainerOutboundGate,
|
||||||
|
confirmContainerOutboundDispatch,
|
||||||
|
getContainerOutboundDispatch,
|
||||||
|
listOutboundDispatches,
|
||||||
|
} from './platformOutboundDispatch'
|
||||||
|
|
||||||
|
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() } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const gate = {
|
||||||
|
passed: true,
|
||||||
|
checkedAt: '2026-08-26T12:00:00',
|
||||||
|
inbounds: [{
|
||||||
|
inboundId: '2092438843630391501',
|
||||||
|
inboundCode: 'IN-1',
|
||||||
|
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [] },
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
const aggregate = {
|
||||||
|
containerBookingId: '2092438843630391297',
|
||||||
|
cabinetNo: 'CAB-1',
|
||||||
|
containerNo: 'C11111111',
|
||||||
|
loadingPlanId: '2092438843630391401',
|
||||||
|
loadingPlanStatus: 'COMPLETED',
|
||||||
|
oceanBookingId: '2092438843630391601',
|
||||||
|
truckAppointmentId: null,
|
||||||
|
dispatchStatus: 'READY_FOR_DISPATCH',
|
||||||
|
version: '4',
|
||||||
|
inbounds: [{
|
||||||
|
orderId: '2092438843630391701',
|
||||||
|
orderNo: 'O1',
|
||||||
|
inboundId: '2092438843630391501',
|
||||||
|
inboundCode: 'IN-1',
|
||||||
|
tenantId: '2092438843630391801',
|
||||||
|
customerId: '2092438843630391901',
|
||||||
|
inboundStatus: 'LOADED',
|
||||||
|
pickTaskId: null,
|
||||||
|
}],
|
||||||
|
gate,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('platformOutboundDispatch', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('normalizes aggregate ids', async () => {
|
||||||
|
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] })
|
||||||
|
const result = await listOutboundDispatches({})
|
||||||
|
expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts legacy action with version and idempotency', async () => {
|
||||||
|
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } })
|
||||||
|
await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 })
|
||||||
|
expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 })
|
||||||
|
expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gets exact container-level paths and normalizes all ids', async () => {
|
||||||
|
vi.mocked(http.get)
|
||||||
|
.mockResolvedValueOnce({ code: '200', data: { aggregate, timeline: [{ module: 'OUTBOUND', action: 'READY', operatorId: 9, occurredAt: '2026-08-26T12:00:00' }] } })
|
||||||
|
.mockResolvedValueOnce({ code: '200', data: gate })
|
||||||
|
|
||||||
|
const detail = await getContainerOutboundDispatch('2092438843630391297')
|
||||||
|
const resultGate = await checkContainerOutboundGate('2092438843630391297')
|
||||||
|
|
||||||
|
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297')
|
||||||
|
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297/gate')
|
||||||
|
expect(detail.aggregate).toMatchObject({
|
||||||
|
containerBookingId: String(aggregate.containerBookingId),
|
||||||
|
loadingPlanId: String(aggregate.loadingPlanId),
|
||||||
|
oceanBookingId: String(aggregate.oceanBookingId),
|
||||||
|
version: 4,
|
||||||
|
inbounds: [{ orderId: String(aggregate.inbounds[0].orderId), inboundId: String(aggregate.inbounds[0].inboundId), tenantId: String(aggregate.inbounds[0].tenantId), customerId: String(aggregate.inbounds[0].customerId) }],
|
||||||
|
})
|
||||||
|
expect(detail.timeline[0]?.operatorId).toBe('9')
|
||||||
|
expect(resultGate.inbounds[0]?.inboundId).toBe(String(gate.inbounds[0].inboundId))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms once at container level without inboundId and includes idempotency', async () => {
|
||||||
|
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate, timeline: [] } })
|
||||||
|
await confirmContainerOutboundDispatch('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
|
||||||
|
|
||||||
|
expect(http.post).toHaveBeenCalledWith(
|
||||||
|
'/platform/v1/first-leg/outbound-dispatches/2092438843630391297/confirm',
|
||||||
|
{ expectedVersion: 4, remark: '货柜详情确认国内出仓' },
|
||||||
|
{ headers: { 'Idempotency-Key': expect.any(String) } },
|
||||||
|
)
|
||||||
|
expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types'
|
import type {
|
||||||
|
ContainerOutboundActionRequest,
|
||||||
|
ContainerOutboundAggregate,
|
||||||
|
ContainerOutboundDispatchDetail,
|
||||||
|
ContainerOutboundGateResult,
|
||||||
|
ContainerOutboundInboundBinding,
|
||||||
|
ContainerOutboundInboundGate,
|
||||||
|
OutboundDispatchDetail,
|
||||||
|
OutboundDispatchQuery,
|
||||||
|
OutboundDispatchResource,
|
||||||
|
OutboundGateResult,
|
||||||
|
} from '../types'
|
||||||
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
|
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
|
||||||
function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
|
function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
|
||||||
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||||
@@ -7,3 +18,70 @@ export async function listOutboundDispatches(query: OutboundDispatchQuery) { con
|
|||||||
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
|
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
|
||||||
export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundGateResult>, ApiEnvelope<OutboundGateResult>>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) }
|
export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundGateResult>, ApiEnvelope<OutboundGateResult>>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) }
|
||||||
export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
|
export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
|
||||||
|
|
||||||
|
function optionalId(value: unknown): string | null {
|
||||||
|
return value == null ? null : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inboundBinding(raw: ContainerOutboundInboundBinding): ContainerOutboundInboundBinding {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
orderId: String(raw.orderId),
|
||||||
|
inboundId: String(raw.inboundId),
|
||||||
|
tenantId: String(raw.tenantId),
|
||||||
|
customerId: optionalId(raw.customerId),
|
||||||
|
pickTaskId: optionalId(raw.pickTaskId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inboundGate(raw: ContainerOutboundInboundGate): ContainerOutboundInboundGate {
|
||||||
|
return { ...raw, inboundId: String(raw.inboundId), gate: { ...raw.gate, items: raw.gate?.items || [] } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerGate(raw: ContainerOutboundGateResult): ContainerOutboundGateResult {
|
||||||
|
return { ...raw, inbounds: (raw.inbounds || []).map(inboundGate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerAggregate(raw: ContainerOutboundAggregate): ContainerOutboundAggregate {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
containerBookingId: String(raw.containerBookingId),
|
||||||
|
loadingPlanId: String(raw.loadingPlanId),
|
||||||
|
oceanBookingId: optionalId(raw.oceanBookingId),
|
||||||
|
truckAppointmentId: optionalId(raw.truckAppointmentId),
|
||||||
|
version: Number(raw.version ?? 0),
|
||||||
|
inbounds: (raw.inbounds || []).map(inboundBinding),
|
||||||
|
gate: containerGate(raw.gate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerDetail(raw: ContainerOutboundDispatchDetail): ContainerOutboundDispatchDetail {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
aggregate: containerAggregate(raw.aggregate),
|
||||||
|
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContainerOutboundDispatch(containerBookingId: string) {
|
||||||
|
const response = await http.get<ApiEnvelope<ContainerOutboundDispatchDetail>, ApiEnvelope<ContainerOutboundDispatchDetail>>(
|
||||||
|
`${basePath}/${encodeURIComponent(containerBookingId)}`,
|
||||||
|
)
|
||||||
|
return containerDetail(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkContainerOutboundGate(containerBookingId: string) {
|
||||||
|
const response = await http.get<ApiEnvelope<ContainerOutboundGateResult>, ApiEnvelope<ContainerOutboundGateResult>>(
|
||||||
|
`${basePath}/${encodeURIComponent(containerBookingId)}/gate`,
|
||||||
|
)
|
||||||
|
return containerGate(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmContainerOutboundDispatch(containerBookingId: string, request: ContainerOutboundActionRequest) {
|
||||||
|
const response = await http.post<ApiEnvelope<ContainerOutboundDispatchDetail>, ApiEnvelope<ContainerOutboundDispatchDetail>>(
|
||||||
|
`${basePath}/${encodeURIComponent(containerBookingId)}/confirm`,
|
||||||
|
request,
|
||||||
|
{ headers: { 'Idempotency-Key': idem() } },
|
||||||
|
)
|
||||||
|
return containerDetail(unwrapApiResponse(response))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,3 +3,75 @@ export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]
|
|||||||
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; cabinetNo?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
|
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; cabinetNo?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
|
||||||
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
|
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
|
||||||
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
|
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
|
||||||
|
|
||||||
|
export interface ContainerOutboundGateItem {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
passed: boolean
|
||||||
|
actual?: string | null
|
||||||
|
requirement?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerOutboundInboundGate {
|
||||||
|
inboundId: string
|
||||||
|
inboundCode: string
|
||||||
|
gate: {
|
||||||
|
passed: boolean
|
||||||
|
items: ContainerOutboundGateItem[]
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerOutboundGateResult {
|
||||||
|
passed: boolean
|
||||||
|
inbounds: ContainerOutboundInboundGate[]
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerOutboundInboundBinding {
|
||||||
|
orderId: string
|
||||||
|
orderNo: string
|
||||||
|
inboundId: string
|
||||||
|
inboundCode: string
|
||||||
|
tenantId: string
|
||||||
|
customerId?: string | null
|
||||||
|
customerName?: string | null
|
||||||
|
inboundStatus: string
|
||||||
|
pickTaskId?: string | null
|
||||||
|
pickTaskStatus?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerOutboundAggregate {
|
||||||
|
containerBookingId: string
|
||||||
|
cabinetNo: string
|
||||||
|
containerNo?: string | null
|
||||||
|
loadingPlanId: string
|
||||||
|
loadingPlanStatus: string
|
||||||
|
oceanBookingId?: string | null
|
||||||
|
oceanBookingStatus?: string | null
|
||||||
|
truckAppointmentId?: string | null
|
||||||
|
truckAppointmentStatus?: string | null
|
||||||
|
dispatchStatus: string
|
||||||
|
version: number
|
||||||
|
inbounds: ContainerOutboundInboundBinding[]
|
||||||
|
gate: ContainerOutboundGateResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerOutboundDispatchDetail {
|
||||||
|
aggregate: ContainerOutboundAggregate
|
||||||
|
timeline: Array<{
|
||||||
|
module: string
|
||||||
|
action: string
|
||||||
|
fromStatus?: string | null
|
||||||
|
toStatus?: string | null
|
||||||
|
operatorId?: string | null
|
||||||
|
operatorName?: string | null
|
||||||
|
remark?: string | null
|
||||||
|
occurredAt: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerOutboundActionRequest {
|
||||||
|
expectedVersion: number
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user