diff --git a/AGENTS.md b/AGENTS.md
index 26e35755..aba261cf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -60,7 +60,7 @@
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
-| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,列表下方展开只读明细并提供打印;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
+| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts
index 51914352..878b744b 100644
--- a/src/core/navigation/adminNavigation.test.ts
+++ b/src/core/navigation/adminNavigation.test.ts
@@ -21,7 +21,7 @@ describe('adminNavigation', () => {
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
- { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] },
+ { title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '客户服务', children: ['异常订单'] },
@@ -30,7 +30,7 @@ describe('adminNavigation', () => {
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(11)
- expect(visible.flatMap(node => node.children ?? [])).toHaveLength(48)
+ expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts
index b7e114d3..93a6a991 100644
--- a/src/core/navigation/adminNavigation.ts
+++ b/src/core/navigation/adminNavigation.ts
@@ -107,14 +107,15 @@ export const adminNavigation: AdminNavigationNode[] = [
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
+ { key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' },
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
- { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list', hidden: true },
- { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list', hidden: true },
+ { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
+ { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
],
},
{
diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts
index 650f0e5a..4d416f86 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts
@@ -17,7 +17,17 @@ const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
-vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '
' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '{{ detail?.planNo }}
' }, PalletLabelPrintDialog: { template: '' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
+vi.mock('@/modules/loading-lists', async (importOriginal) => {
+ const actual = await importOriginal()
+ const api = { getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }
+ return {
+ ...api,
+ useLoadingListDetail: () => actual.useLoadingListDetail(api),
+ ContainerFeesPanel: { template: '' },
+ LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded', 'dense', 'backLabel'], template: '{{ detail?.planNo }}
' },
+ PalletLabelPrintDialog: { template: '' },
+ }
+})
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '' } }))
@@ -61,7 +71,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
- vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', items: [] } as never)
+ vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
})
@@ -133,6 +143,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainerOverview).mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
vi.mocked(getConfirmedLoadingList).mockResolvedValue({
planNo: 'LP-1',
+ summary: { status: 'COMPLETED' },
items: [{ destinationWarehouse: 'ABE4' }, { destinationWarehouse: 'ABE4' }],
} as never)
@@ -158,7 +169,7 @@ describe('ContainerDetailView empty return', () => {
describe('domestic container loading list and booking link', () => {
const domestic = { ...base, containerNo: null, warehouseId: '8', channelCode: 'ZBYHK', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null }
- const loadingDetail = { planNo: 'LP-1', items: [] }
+ const loadingDetail = { planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] }
beforeEach(() => {
routeState.query = {}
@@ -212,7 +223,8 @@ describe('domestic container loading list and booking link', () => {
if (permitted) {
const drawer = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(drawer.props('mode')).toBe(planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
- expect(drawer.props('embedded')).not.toBeUndefined()
+ expect(drawer.props('embedded')).toBeUndefined()
+ expect(drawer.props('backLabel')).toBe('返回货柜概览')
}
wrapper.unmount()
}
diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts
index 9d3c153f..b692f379 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts
@@ -30,12 +30,12 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain(':sections="[\'documents\']"')
expect(source).toContain(':sections="[\'pda\']"')
expect(source).toContain('(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')")
- expect(source).toContain(' {
expect(navigationSource).toContain("title: '装柜清单'")
expect(source).toContain('(null)
const outboundError = ref(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
-const loadingDetail = ref(null)
-const loadingDetailLoading = ref(false)
-const loadingDetailError = ref(null)
+const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
const emptyReturnOpen = ref(false)
const emptyReturnAt = ref('')
const emptyReturnReason = ref('')
@@ -295,6 +293,7 @@ async function confirmEmptyReturn() {
async function load() {
if (!containerId.value) return
+ resetLoadingDetail()
loading.value = true; error.value = null; overviewError.value = null
try {
const [resource, aggregate, arrivals] = await Promise.all([
@@ -331,23 +330,12 @@ async function loadOutboundDispatch() {
}
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
- if (loadingDetail.value || loadingDetailLoading.value) return
- loadingDetail.value = null; loadingDetailError.value = null
- if (!aggregate.loadingPlanId || !resource.warehouseId) return
- loadingDetailLoading.value = true
- try {
- if (resource.planStatus === 'COMPLETED') {
- if (!canViewLoadingList.value) return
- loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)
- return
- }
- if (!canViewPreload.value) return
- loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
- } catch (cause) {
- loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败')
- } finally {
- loadingDetailLoading.value = false
+ const mode = resource.planStatus === 'COMPLETED' ? 'confirmed' : 'preload'
+ if (!(mode === 'confirmed' ? canViewLoadingList.value : canViewPreload.value)) {
+ resetLoadingDetail()
+ return
}
+ await loadListDetail(mode, aggregate.loadingPlanId || '', resource.warehouseId || '')
}
async function refreshAfterCustoms() {
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
@@ -518,7 +506,6 @@ async function completeClearance() {
}
function goBack() { void router.push({ name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
-function printLoadingList() { window.print() }
onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') })
@@ -588,10 +575,10 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
diff --git a/src/modules/downloads/api/platformExportTasks.ts b/src/modules/downloads/api/platformExportTasks.ts
index c76fa186..101305ec 100644
--- a/src/modules/downloads/api/platformExportTasks.ts
+++ b/src/modules/downloads/api/platformExportTasks.ts
@@ -3,6 +3,14 @@ import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/s
export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
export type KnownExportTaskType =
+ | 'LAST_MILE_ORDER_LIST'
+ | 'OVERSEAS_INVENTORY_BALANCE'
+ | 'OVERSEAS_INVENTORY_LEDGER'
+ | 'OVERSEAS_QUOTE_LIST'
+ | 'MONTHLY_EXCHANGE_RATE_LIST'
+ | 'VEHICLE_LIST'
+ | 'FLEET_QUOTE_LIST'
+ | 'TRIP_OPERATIONS_LIST'
| 'CUSTOMER_LIST'
| 'DOMESTIC_WAREHOUSE_ORDER_LIST'
| 'FINANCE_CUSTOMER_ACCOUNT_LIST'
diff --git a/src/modules/downloads/index.ts b/src/modules/downloads/index.ts
index f62f5ff1..7b2ee337 100644
--- a/src/modules/downloads/index.ts
+++ b/src/modules/downloads/index.ts
@@ -1 +1,2 @@
export * from './api/platformExportTasks'
+export { useExportTask } from './useExportTask'
diff --git a/src/modules/downloads/pages/DownloadCenterView.vue b/src/modules/downloads/pages/DownloadCenterView.vue
index a2a74adc..888d5541 100644
--- a/src/modules/downloads/pages/DownloadCenterView.vue
+++ b/src/modules/downloads/pages/DownloadCenterView.vue
@@ -26,6 +26,16 @@ type DownloadFilters = QueryModel & {
}
const exportTypeMeta: Record = {
+ LAST_MILE_ORDER_LIST: { label: '尾程订单', retryPermission: 'platform:overseas-warehouse:last-mile-order:export' },
+ OVERSEAS_INVENTORY_BALANCE: { label: '库存余额', retryPermission: 'platform:overseas-warehouse:inventory:export' },
+ OVERSEAS_INVENTORY_LEDGER: { label: '进销存记录', retryPermission: 'platform:overseas-warehouse:inventory:export' },
+ OVERSEAS_QUOTE_LIST: { label: '海外报价', retryPermission: 'platform:overseas-master-data:quote:export' },
+ MONTHLY_EXCHANGE_RATE_LIST: { label: '月度汇率', retryPermission: 'platform:overseas-master-data:exchange-rate:export' },
+ VEHICLE_LIST: { label: '车辆', retryPermission: 'platform:overseas-master-data:vehicle:export' },
+ FLEET_QUOTE_LIST: { label: '车队报价', retryPermission: 'platform:fleet:quote:export' },
+ TRIP_OPERATIONS_LIST: { label: '出车记录', retryPermission: 'platform:fleet:operations:export' },
+ FINANCE_RECEIVABLE_STATISTICS: { label: '应收统计', retryPermission: 'platform:finance:receivable-statistics:export' },
+ LOADING_LIST_BUY_DECLARATION_ITEMS: { label: '买单报关明细', retryPermission: 'platform:firstleg:customs:manage' },
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' },
FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' },
diff --git a/src/modules/downloads/useExportTask.test.ts b/src/modules/downloads/useExportTask.test.ts
new file mode 100644
index 00000000..1cf0a9ae
--- /dev/null
+++ b/src/modules/downloads/useExportTask.test.ts
@@ -0,0 +1,48 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { createPlatformExportTask } from './api/platformExportTasks'
+import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
+import { useExportTask } from './useExportTask'
+
+const { push } = vi.hoisted(() => ({ push: vi.fn() }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
+vi.mock('./api/platformExportTasks', () => ({ createPlatformExportTask: vi.fn() }))
+vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(createPlatformExportTask).mockResolvedValue({ taskNo: 'EXP-1' } as Awaited>)
+ vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(false)
+})
+
+describe('download center export submission', () => {
+ it('exports all matching rows and preserves false, zero, stage and business filters', async () => {
+ const filters = { page: 3, size: 20, detained: false, count: 0, orderNo: '', warehouse: undefined, sailingStage: 'BEFORE_DEPARTURE', relabelNo: 'RL-1' }
+ await useExportTask()('LAST_MILE_ORDER_LIST', '尾程订单', filters)
+ expect(createPlatformExportTask).toHaveBeenCalledWith({ exportType: 'LAST_MILE_ORDER_LIST', taskName: '尾程订单', format: 'XLSX',
+ parameters: { detained: false, count: 0, sailingStage: 'BEFORE_DEPARTURE', relabelNo: 'RL-1' } })
+ expect(filters.page).toBe(3)
+ expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '尾程订单', taskNo: 'EXP-1', dense: true })
+ expect(push).not.toHaveBeenCalled()
+ })
+ it('opens the download center only after the user chooses it', async () => {
+ vi.mocked(showExportTaskCreatedDialog).mockResolvedValue(true)
+ await useExportTask()('VEHICLE_LIST', '车辆', {})
+ expect(push).toHaveBeenCalledWith({ name: 'download-center' })
+ })
+ it('preserves submission failure diagnostics without a success prompt or navigation', async () => {
+ const error = { message: '无导出权限', requestId: 'export-request' }
+ vi.mocked(createPlatformExportTask).mockRejectedValue(error)
+ await expect(useExportTask()('VEHICLE_LIST', '车辆', {})).rejects.toBe(error)
+ expect(showExportTaskCreatedDialog).not.toHaveBeenCalled()
+ expect(push).not.toHaveBeenCalled()
+ })
+ it.each([["overseas-warehouse/pages/LastMileOrdersView.vue","LAST_MILE_ORDER_LIST","platform:overseas-warehouse:last-mile-order:export"],["overseas-warehouse/pages/InventoryView.vue","OVERSEAS_INVENTORY_BALANCE","platform:overseas-warehouse:inventory:export"],["overseas-warehouse/pages/InventoryTransactionsView.vue","OVERSEAS_INVENTORY_LEDGER","platform:overseas-warehouse:inventory:export"],["overseas-master-data/pages/OverseasQuotesView.vue","OVERSEAS_QUOTE_LIST","platform:overseas-master-data:quote:export"],["overseas-master-data/pages/ExchangeRatesView.vue","MONTHLY_EXCHANGE_RATE_LIST","platform:overseas-master-data:exchange-rate:export"],["overseas-master-data/pages/VehiclesView.vue","VEHICLE_LIST","platform:overseas-master-data:vehicle:export"],["overseas-master-data/pages/FleetQuotesView.vue","FLEET_QUOTE_LIST","platform:fleet:quote:export"],["overseas-master-data/pages/TripOperationsView.vue","TRIP_OPERATIONS_LIST","platform:fleet:operations:export"]])('connects %s with the correct type and download-center retry permission', (path, type, permission) => {
+ const page = readFileSync(new URL('../' + path, import.meta.url), 'utf8')
+ expect(page).toContain("await submitExport('" + type + "'")
+ expect(page).not.toMatch(/saveCsv\(|saveFleetCsv\(/)
+ const center = readFileSync('src/modules/downloads/pages/DownloadCenterView.vue', 'utf8')
+ expect(center).toContain(type + ": { label:")
+ expect(center).toContain("retryPermission: '" + permission + "'")
+ })
+})
+
diff --git a/src/modules/downloads/useExportTask.ts b/src/modules/downloads/useExportTask.ts
new file mode 100644
index 00000000..01911168
--- /dev/null
+++ b/src/modules/downloads/useExportTask.ts
@@ -0,0 +1,16 @@
+import { useRouter } from 'vue-router'
+import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
+import { createPlatformExportTask, type CreatePlatformExportTaskRequest, type KnownExportTaskType } from './api/platformExportTasks'
+
+export function useExportTask() {
+ const router = useRouter()
+ return async (exportType: KnownExportTaskType, taskName: string, query: object) => {
+ const parameters = Object.fromEntries(Object.entries(query).filter(([key, value]) =>
+ key !== 'page' && key !== 'size' && value !== undefined && value !== null && value !== '',
+ )) as CreatePlatformExportTaskRequest['parameters']
+ const task = await createPlatformExportTask({ exportType, taskName, format: 'XLSX', parameters })
+ if (await showExportTaskCreatedDialog({ taskName, taskNo: task.taskNo, dense: true })) {
+ await router.push({ name: 'download-center' })
+ }
+ }
+}
diff --git a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
index ef30633b..26804546 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementDialog.vue
@@ -104,7 +104,7 @@ function handleSubmitted() {