diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts
index 59095641..790c6a8f 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
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('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
-vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } }))
+vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '', mounted: () => { carrierMounts.value += 1 } } }))
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', () => ({
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: 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 }],
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) {
- accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage'))
+function mountView(customs = true, outbound = true) {
+ 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] } })
}
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', () => {
beforeEach(() => {
vi.clearAllMocks()
+ carrierMounts.value = 0
vi.mocked(getContainer).mockResolvedValue(detail)
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValue(null)
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
vi.mocked(uploadContainerDocument).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 () => {
@@ -77,4 +94,62 @@ describe('ContainerDetailView', () => {
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
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()
+ })
})
diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue
index 74af5e49..796cda0d 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.vue
+++ b/src/modules/container-loading/pages/ContainerDetailView.vue
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
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 { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
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 {
cancelContainerException,
@@ -32,6 +34,8 @@ const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
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 detail = ref(null)
const overview = ref(null)
@@ -54,11 +58,21 @@ const exceptionOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref(null)
+const outboundLoading = ref(false)
+const outboundConfirming = ref(false)
+const outboundDetail = ref(null)
+const outboundError = ref(null)
+const carrierRefreshKey = ref(0)
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 title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
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 formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
@@ -69,10 +83,47 @@ async function load() {
try {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
detail.value = resource; overview.value = aggregate
+ if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
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)[status || ''] || status || '—'
+}
+
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); 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
运输与单证快照
- 真实订舱
- {{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}
- 异常状态
- {{ detail.exceptionActive ? '异常' : '正常' }}
- 目的仓
- {{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}
- 船公司
- {{ detail.shippingCompanyName || '—' }}
- 船名 / 航次
- {{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
- 拖车公司
- {{ detail.terminalTruckingCompany || '—' }}
- 预计开船
- {{ formatTime(detail.estimatedSailingAt) }}
- 预计到港
- {{ formatTime(detail.estimatedPortArrivalAt) }}
- 卸柜完成
- {{ formatTime(detail.unloadingCompletedAt) }}
- 卸柜照片上传
- {{ formatTime(detail.unloadingPhotoUploadedAt) }}
- 预计报空
- {{ formatTime(detail.estimatedEmptyReportedAt) }}
- 实际报空
- {{ formatTime(detail.actualEmptyReportedAt) }}
-
+
+
+
+
+
+ 以下任一阻断项处理完成后,重新加载门禁即可确认离仓。
+
+
+ {{ row.passed ? '通过' : '阻断' }}{{ row.name }}{{ row.inboundCode }}{{ row.actual || '暂无事实' }}要求:{{ row.requirement || '—' }}
+
+
+
+
+
选择文件{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}
@@ -169,5 +233,5 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
diff --git a/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts b/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts
index 2bfdc8fe..d0ede23c 100644
--- a/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts
+++ b/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts
@@ -1,5 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch'
-vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } })
-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) } }) }) })
+import {
+ changeOutboundDispatch,
+ checkContainerOutboundGate,
+ confirmContainerOutboundDispatch,
+ getContainerOutboundDispatch,
+ listOutboundDispatches,
+} from './platformOutboundDispatch'
+
+vi.mock('@/shared/api/http', async () => {
+ const actual = await vi.importActual('@/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')
+ })
+})
diff --git a/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts b/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts
index b8719a68..8506de19 100644
--- a/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts
+++ b/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts
@@ -1,5 +1,16 @@
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
function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record; 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)}` }
@@ -7,3 +18,70 @@ export async function listOutboundDispatches(query: OutboundDispatchQuery) { con
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get, ApiEnvelope>(`${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>(`${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>(`${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>(
+ `${basePath}/${encodeURIComponent(containerBookingId)}`,
+ )
+ return containerDetail(unwrapApiResponse(response))
+}
+
+export async function checkContainerOutboundGate(containerBookingId: string) {
+ const response = await http.get, ApiEnvelope>(
+ `${basePath}/${encodeURIComponent(containerBookingId)}/gate`,
+ )
+ return containerGate(unwrapApiResponse(response))
+}
+
+export async function confirmContainerOutboundDispatch(containerBookingId: string, request: ContainerOutboundActionRequest) {
+ const response = await http.post, ApiEnvelope>(
+ `${basePath}/${encodeURIComponent(containerBookingId)}/confirm`,
+ request,
+ { headers: { 'Idempotency-Key': idem() } },
+ )
+ return containerDetail(unwrapApiResponse(response))
+}
diff --git a/src/modules/outbound-dispatch/types.ts b/src/modules/outbound-dispatch/types.ts
index ff2de734..e38c1775 100644
--- a/src/modules/outbound-dispatch/types.ts
+++ b/src/modules/outbound-dispatch/types.ts
@@ -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 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 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
+}