diff --git a/AGENTS.md b/AGENTS.md index e74dbaa3..d5f9bc0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ | 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` | | 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | | 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` | -| 货柜、排柜、预装/装柜清单与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。整柜报关按 `loadingPlanId` 展示,逐资料完成,全买单柜使用 `complete-all-buy`;Backend `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` | +| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | | 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | diff --git a/src/app/DomesticOutboundListLayout.test.ts b/src/app/DomesticOutboundListLayout.test.ts index 888cdd21..56b5c209 100644 --- a/src/app/DomesticOutboundListLayout.test.ts +++ b/src/app/DomesticOutboundListLayout.test.ts @@ -42,7 +42,8 @@ describe('domestic outbound list responsive contracts', () => { const listLayout = source('src/shared/components/ListPageLayout.vue') expect(queryToolbar).toContain('@media (max-width: 720px)') - expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; }') + expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }') + expect(queryToolbar).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }') expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))') expect(dataTable).toContain('@media (max-width: 720px)') expect(dataTable).toContain('flex-direction: column') diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index eed3a8cc..ee88a86c 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -98,6 +98,10 @@ describe('admin auth session', () => { await auth.login('platform_admin', 'password') + expect(mocks.post).toHaveBeenCalledWith('/auth/login', { + loginId: 'platform_admin', + password: 'password', + }) expect(auth.isAuthenticated).toBe(true) expect(auth.refreshToken).toBe('refresh-domestic') }) diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index cbcb13e1..05f266a1 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { http } from '@/shared/api/http' import { - adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, - listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingGroup, removeSmartLoadingItem, + adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, + listContainers, listSmartLoadingCandidates, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getCustomerContainerTransitSummary, @@ -66,28 +66,23 @@ describe('platform container loading API', () => { it('normalizes every long id in candidates and plans', async () => { vi.mocked(http.get) .mockResolvedValueOnce({ code: '200', data: container }) - .mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] }) + .mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] }) expect((await getContainer('9')).id).toBe(String(container.id)) - expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }]) + expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }]) expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } }) }) - it('sends plan and item versions plus operation and idempotency keys', async () => { + it('sends plan and item versions plus operation identifiers', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan }) - const normalized = await createSmartLoadingPlan('7', null) await assignSmartLoadingItem('10', { inboundId: '2', packageCount: 4, expectedVersion: 5 }) await adjustSmartLoadingItem('10', '11', 6, 7, 8) await removeSmartLoadingItem('10', '11', 9, 10) - await pushSmartLoadingPreload('10', 11) - expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null }) - expect(normalized).toMatchObject({ id: String(plan.id), version: 3, canAssign: true, canEdit: true, pdaTaskId: String(plan.pdaTaskId), pdaTaskVersion: 4, items: [{ version: 1, pickedPackageCount: 2, minPackageCount: 2, canAdjust: true, canRemove: false }] }) - expect(vi.mocked(http.post).mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/smart-loading/plans/10/inbound-assignments') - expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-inbound-') }) + expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/smart-loading/plans/10/inbound-assignments') + expect(vi.mocked(http.post).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-inbound-') }) expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') }) expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } }) - expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } }) }) it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => { diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 91cffa06..322775a0 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -252,11 +252,6 @@ export async function getSmartLoadingPlan(planId: string) { return plan(unwrapApiResponse(response)) } -export async function createSmartLoadingPlan(bookingId: string, remark: string | null) { - const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans`, { bookingId, remark }) - return plan(unwrapApiResponse(response)) -} - export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; packageCount: number; expectedVersion: number }) { const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/inbound-assignments`, { ...payload, operationId: operationId('assign-inbound') }) return plan(unwrapApiResponse(response)) @@ -269,7 +264,6 @@ export async function assignSmartLoadingGroup(planId: string, inboundId: string, ) return plan(unwrapApiResponse(response)) } - export async function recommendSmartLoading(planId: string, expectedVersion: number) { const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') }) return plan(unwrapApiResponse(response)) @@ -293,7 +287,3 @@ export async function removeSmartLoadingGroup(planId: string, groupId: string, e return plan(unwrapApiResponse(response)) } -export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) { - const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } }) - return plan(unwrapApiResponse(response)) -} diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index 3166eac1..f55bcad7 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -69,27 +69,30 @@ describe('ContainerCustomsPanel', () => { const wrapper = await render('READY_TO_START') expect(wrapper.text()).toContain('开始报关') expect(wrapper.text()).toContain('客户退税资料.pdf') - expect(wrapper.find('[aria-label="下载源资料 客户退税资料.pdf"]').exists()).toBe(true) - expect(buttonTexts(wrapper)).not.toContain('上传完成文件') - expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件') + expect(wrapper.text()).toContain('客户退税资料') + expect(wrapper.text()).toContain('报关完成文件') + expect(wrapper.text()).not.toContain('客户报关资料') + expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true) + expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件') + expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件') expect(buttonTexts(wrapper)).not.toContain('提交') }) it('allows completion upload and overwrite only while DECLARING with manage permission', async () => { let wrapper = await render('DECLARING', true, false) - expect(wrapper.text()).toContain('上传完成文件') + expect(wrapper.text()).toContain('上传报关完成文件') expect(wrapper.text()).toContain('提交') wrapper.unmount() wrapper = await render('DECLARING', true, true) - expect(wrapper.text()).toContain('覆盖完成文件') + expect(wrapper.text()).toContain('覆盖报关完成文件') expect(wrapper.text()).toContain('报关完成文件.pdf') wrapper.unmount() wrapper = await render('DECLARING', false, true) expect(wrapper.text()).toContain('只读') - expect(buttonTexts(wrapper)).not.toContain('上传完成文件') - expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件') + expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件') + expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件') expect(buttonTexts(wrapper)).not.toContain('提交') }) @@ -98,19 +101,19 @@ describe('ContainerCustomsPanel', () => { expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('报关完成文件.pdf') expect(wrapper.text()).toContain('报关已完成,待其他离仓条件') - expect(buttonTexts(wrapper)).not.toContain('上传完成文件') - expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件') + expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件') + expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件') expect(buttonTexts(wrapper)).not.toContain('提交') }) it('routes source and completion downloads independently by the long document id and original names', async () => { const wrapper = await render('COMPLETED', false, true) - await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click') + await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click') await flushPromises() expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407') expect(api.downloadCompletion).not.toHaveBeenCalled() - await wrapper.get('[aria-label="下载完成文件 报关完成文件.pdf"]').trigger('click') + await wrapper.get('[aria-label="下载报关完成文件 报关完成文件.pdf"]').trigger('click') await flushPromises() expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407') expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2) @@ -122,7 +125,7 @@ describe('ContainerCustomsPanel', () => { api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download')) const wrapper = await render('COMPLETED', false, true) - await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click') + await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click') await flushPromises() expect(wrapper.text()).toContain('文件不可用') diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 40847c08..3bb09b2d 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -40,7 +40,7 @@ const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefun const allBuy = computed(() => customs.value?.taxRefundCount === 0) const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0) const statusText: Record = { - WAITING_CUSTOMER_DOCUMENTS: '待客户补齐资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成', + WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成', } function documentCustomers(document: ContainerCustomsDocument) { @@ -64,7 +64,7 @@ async function load() { async function start() { if (!customs.value || customs.value.status !== 'READY_TO_START') return try { - await ElMessageBox.confirm('开始后将冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' }) + await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' }) } catch { return } actionLoading.value = true; actionError.value = null try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') } @@ -75,11 +75,11 @@ async function start() { async function uploadCompletion(document: ContainerCustomsDocument) { if (!props.canManage || customs.value?.status !== 'DECLARING') return const selected = completionFiles[document.documentId] - if (!selected) { ElMessage.warning('请选择对应完成文件'); return } + if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return } const finalConfirmation = !document.completion && missingCompletionCount.value === 1 if (finalConfirmation) { try { - await ElMessageBox.confirm('这是最后一份待上传完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' }) + await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' }) } catch { return } } actionLoading.value = true; actionError.value = null @@ -88,8 +88,8 @@ async function uploadCompletion(document: ContainerCustomsDocument) { completionFiles[document.documentId] = null await load() emit('refreshed') - ElMessage.success(finalConfirmation ? '完成文件已上传,整柜报关已完成' : '完成文件已上传') - } catch (error) { actionError.value = getApiErrorDetails(error, '完成文件上传失败') } + ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传') + } catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') } finally { actionLoading.value = false } } @@ -113,9 +113,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | ? await downloadContainerCustomsDocument(document.documentId) : await downloadContainerCustomsCompletion(document.documentId) saveBlob(blob, target.fileName) - ElMessage.success(`${kind === 'source' ? '源资料' : '完成文件'}下载已开始`) + ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`) } catch (error) { - actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '源资料' : '完成文件'}下载失败`) + actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`) } finally { downloadingKey.value = '' } @@ -168,17 +168,17 @@ onMounted(load) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 329a672c..61ed3153 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -363,7 +363,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
返回列表

{{ title }}

柜次资料、履约轨迹、PDA 进度与单证

-
柜次号{{ detail.cabinetNo || '—' }}
集装箱号{{ detail.containerNo || '待录入' }}
装柜清单号{{ detail.planNo || '—' }}
柜型{{ detail.containerType || '—' }}
装柜仓库{{ detail.warehouseName || '—' }}
+
柜次号{{ detail.cabinetNo || '—' }}
集装箱号{{ detail.containerNo || '待补录' }}
装柜清单号{{ detail.planNo || '—' }}
柜型{{ detail.containerType || '—' }}
装柜仓库{{ detail.warehouseName || '—' }}