From e8c41bca2def65ada704bff51a8de4c7ad37db0e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:04:50 +0800 Subject: [PATCH] feat(admin): confirm container departure from detail --- .../pages/ContainerDetailView.test.ts | 85 +++++++++++++++- .../pages/ContainerDetailView.vue | 70 ++++++++++++- .../api/platformOutboundDispatch.test.ts | 97 ++++++++++++++++++- .../api/platformOutboundDispatch.ts | 80 ++++++++++++++- src/modules/outbound-dispatch/types.ts | 72 ++++++++++++++ 5 files changed, 392 insertions(+), 12 deletions(-) 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) }}
-
+
+

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundDetail.aggregate.gate.passed ? '门禁通过' : '门禁阻断' }}确认离仓
+ +
+ +
+
选择文件 @@ -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 +}