From 7b0df1db3787756035b2b737ebe50ede1bffd585 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 8 Oct 2026 17:08:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- ...nerDetailView.emptyReturn.behavior.test.ts | 20 ++- .../pages/ContainerDetailView.test.ts | 10 +- .../pages/ContainerDetailView.vue | 33 ++--- .../downloads/api/platformExportTasks.ts | 8 + src/modules/downloads/index.ts | 1 + .../downloads/pages/DownloadCenterView.vue | 10 ++ src/modules/downloads/useExportTask.test.ts | 48 ++++++ src/modules/downloads/useExportTask.ts | 16 ++ .../measurement/InboundMeasurementDialog.vue | 11 +- .../InboundMeasurementPanel.test.ts | 93 ++++++++++-- .../measurement/InboundMeasurementPanel.vue | 138 ++++++++++-------- .../measurement/measurementDensity.css | 35 ++++- .../LoadingListDetailDrawer.behavior.test.ts | 27 ++++ .../components/LoadingListDetailDrawer.vue | 9 +- .../components/LoadingListsView.test.ts | 11 +- src/modules/loading-lists/index.ts | 1 + .../ConfirmedLoadingListDetailView.test.ts | 2 +- .../pages/ConfirmedLoadingListDetailView.vue | 60 ++------ src/modules/loading-lists/printLoadingList.ts | 6 + .../useLoadingListDetail.test.ts | 67 +++++++++ .../loading-lists/useLoadingListDetail.ts | 53 +++++++ src/modules/overseas-master-data/AGENTS.md | 4 +- .../api/fleetQuotes.test.ts | 10 +- .../overseas-master-data/api/fleetQuotes.ts | 15 -- .../api/overseasMasterData.test.ts | 20 +-- .../api/overseasMasterData.ts | 58 +------- .../api/tripOperations.ts | 3 +- .../pages/ExchangeRatesView.vue | 9 +- .../pages/FleetQuotesView.vue | 8 +- .../pages/OverseasMasterDataViews.test.ts | 8 +- .../pages/OverseasQuotesView.vue | 9 +- .../pages/TripOperationsView.vue | 9 +- .../pages/VehiclesView.vue | 8 +- src/modules/overseas-warehouse/AGENTS.md | 2 + .../overseas-warehouse/api/inventory.test.ts | 17 +-- .../overseas-warehouse/api/inventory.ts | 7 - .../api/lastMileOrders.test.ts | 31 +--- .../overseas-warehouse/api/lastMileOrders.ts | 24 +-- .../pages/InventoryTransactionsView.vue | 9 +- .../pages/InventoryView.vue | 9 +- .../pages/LastMileOrdersView.behavior.test.ts | 6 +- .../pages/LastMileOrdersView.vue | 10 +- src/styles/adminDensity.test.ts | 42 ++++++ 44 files changed, 574 insertions(+), 405 deletions(-) create mode 100644 src/modules/downloads/useExportTask.test.ts create mode 100644 src/modules/downloads/useExportTask.ts create mode 100644 src/modules/loading-lists/components/LoadingListDetailDrawer.behavior.test.ts create mode 100644 src/modules/loading-lists/printLoadingList.ts create mode 100644 src/modules/loading-lists/useLoadingListDetail.test.ts create mode 100644 src/modules/loading-lists/useLoadingListDetail.ts diff --git a/AGENTS.md b/AGENTS.md index 45816340..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,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;货柜列表经 `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/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
-

{{ loadingListHeading }}

{{ loadingListDescription }}

打印装柜清单
+

{{ loadingListHeading }}

{{ loadingListDescription }}

- +
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() {
diff --git a/src/modules/loading-lists/printLoadingList.ts b/src/modules/loading-lists/printLoadingList.ts new file mode 100644 index 00000000..52b0181f --- /dev/null +++ b/src/modules/loading-lists/printLoadingList.ts @@ -0,0 +1,6 @@ +import { nextTick } from 'vue' + +export async function printLoadingList() { + await nextTick() + window.print() +} diff --git a/src/modules/loading-lists/useLoadingListDetail.test.ts b/src/modules/loading-lists/useLoadingListDetail.test.ts new file mode 100644 index 00000000..a1068414 --- /dev/null +++ b/src/modules/loading-lists/useLoadingListDetail.test.ts @@ -0,0 +1,67 @@ +import { effectScope } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getConfirmedLoadingList, getPreloadPlan } from './api/platformLoadingLists' +import { useLoadingListDetail } from './useLoadingListDetail' + +vi.mock('./api/platformLoadingLists', () => ({ getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() })) +const scopes: ReturnType[] = [] +function createLoader() { + const scope = effectScope() + scopes.push(scope) + return scope.run(useLoadingListDetail)! +} +const result = (status = 'COMPLETED') => ({ summary: { planId: '1', status }, items: [], histories: [] }) + +beforeEach(() => vi.resetAllMocks()) +afterEach(() => { scopes.splice(0).forEach(scope => scope.stop()) }) + +describe('shared loading list data', () => { + it('uses the same confirmed data once per plan and warehouse', async () => { + vi.mocked(getConfirmedLoadingList).mockResolvedValue(result() as never) + const state = createLoader() + await state.load('confirmed', '1', '11') + await state.load('confirmed', '1', '11') + expect(getConfirmedLoadingList).toHaveBeenCalledExactlyOnceWith('1', '11') + expect(state.detail.value).toEqual(result()) + expect(state.loading.value).toBe(false) + }) + it('switches APIs when a preload becomes confirmed and rejects an uncompleted manifest', async () => { + vi.mocked(getPreloadPlan).mockResolvedValue(result('LOADING') as never) + vi.mocked(getConfirmedLoadingList).mockResolvedValue(result('LOADING') as never) + const state = createLoader() + await state.load('preload', '1', '11') + expect(getPreloadPlan).toHaveBeenCalledExactlyOnceWith('1', '11') + await state.load('confirmed', '1', '11') + expect(state.detail.value).toBeNull() + expect(state.error.value?.code).toBe('MODE_STATUS_MISMATCH') + }) + it('does not request detail without warehouse context', async () => { + const state = createLoader() + await state.load('confirmed', '1', '') + expect(state.error.value?.code).toBe('MISSING_WAREHOUSE_CONTEXT') + expect(getConfirmedLoadingList).not.toHaveBeenCalled() + }) + it('supports retry after failure and reload after reset', async () => { + vi.mocked(getConfirmedLoadingList).mockRejectedValueOnce(new Error('failure')).mockResolvedValue(result() as never) + const state = createLoader() + await state.load('confirmed', '1', '11') + expect(state.error.value).not.toBeNull() + await state.load('confirmed', '1', '11') + expect(state.error.value).toBeNull() + expect(state.detail.value).toEqual(result()) + state.reset() + await state.load('confirmed', '1', '11') + expect(getConfirmedLoadingList).toHaveBeenCalledTimes(3) + }) + it('does not reuse an old plan when its request finishes after switching', async () => { + let resolveFirst!: (value: never) => void + vi.mocked(getConfirmedLoadingList).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve })) + .mockResolvedValueOnce({ ...result(), summary: { planId: '2', status: 'COMPLETED' } } as never) + const state = createLoader() + const first = state.load('confirmed', '1', '11') + await state.load('confirmed', '2', '22') + resolveFirst(result() as never) + await first + expect(state.detail.value?.summary.planId).toBe('2') + }) +}) diff --git a/src/modules/loading-lists/useLoadingListDetail.ts b/src/modules/loading-lists/useLoadingListDetail.ts new file mode 100644 index 00000000..35c0e388 --- /dev/null +++ b/src/modules/loading-lists/useLoadingListDetail.ts @@ -0,0 +1,53 @@ +import { onScopeDispose, ref } from 'vue' +import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import { getConfirmedLoadingList, getPreloadPlan, type LoadingDetail, type LoadingListMode } from './api/platformLoadingLists' + +export function useLoadingListDetail(api = { getConfirmedLoadingList, getPreloadPlan }) { + const detail = ref(null) + const loading = ref(false) + const error = ref(null) + let sequence = 0 + let currentKey = '' + + function reset() { + ++sequence + currentKey = '' + detail.value = null + loading.value = false + error.value = null + } + + async function load(mode: LoadingListMode, planId: string, warehouseId: string) { + const key = `${mode}:${planId}:${warehouseId}` + if (key === currentKey && (detail.value || loading.value)) return detail.value + const request = ++sequence + currentKey = key + detail.value = null + error.value = null + loading.value = true + try { + if (!planId || !warehouseId) { + error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少计划或所属仓库信息,无法查询详情,请返回重新进入。' } + return null + } + const result = mode === 'confirmed' + ? await api.getConfirmedLoadingList(planId, warehouseId) + : await api.getPreloadPlan(planId, warehouseId) + if (request !== sequence) return null + if (mode === 'confirmed' && result.summary.status !== 'COMPLETED') { + error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' } + return null + } + detail.value = result + return detail.value + } catch (cause) { + if (request === sequence) error.value = getApiErrorDetails(cause, '装柜清单加载失败') + return null + } finally { + if (request === sequence) loading.value = false + } + } + + onScopeDispose(reset) + return { detail, loading, error, load, reset } +} diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md index 7ffd8cf7..f61dd646 100644 --- a/src/modules/overseas-master-data/AGENTS.md +++ b/src/modules/overseas-master-data/AGENTS.md @@ -16,7 +16,7 @@ - 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。 - 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。 - 用户已于2026-10-02确认首版车次交互:`pages/TripsView.vue`、`pages/TripDetailView.vue` 为独立路由;`TripPlanDialog.vue` 1080px居中计划弹框,`TripAssignmentPanel.vue`、`TripLoadingPanel.vue` 在详情分区。列表一次分页请求,历史/装载/结案检查进入对应页签才读取;候选聚焦或搜索时调用车辆/司机目录,不逐行读取详情。先分配资源、关联排车及匹配卸货站,每张排车单调度审核通过后由PDA拣货、平台确认实测装载、整车理货审核通过后PDA整车发车;平台不提供替代PDA的发车按钮。审核使用独立trip:dispatch-review/trip:loading-review权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。 -- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,CSV使用车牌业务标识并包含容量字段。 +- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,下载中心 XLSX 导出使用车牌业务标识并包含容量字段。 - 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。 - 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。 - 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。 @@ -33,3 +33,5 @@ - `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。 - 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。 + +- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。 diff --git a/src/modules/overseas-master-data/api/fleetQuotes.test.ts b/src/modules/overseas-master-data/api/fleetQuotes.test.ts index 2b366b3a..329e3248 100644 --- a/src/modules/overseas-master-data/api/fleetQuotes.test.ts +++ b/src/modules/overseas-master-data/api/fleetQuotes.test.ts @@ -1,6 +1,6 @@ import { beforeEach, expect, it, vi } from 'vitest' import { http, ApiResponseError } from '@/shared/api/http' -import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes' +import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes' vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) const details: FleetQuoteDraft = { kind: 'SALE', partyId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', distanceMiles: 10, selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 2, validFrom: '2026-01-01', validTo: '2026-12-31', remark: null } const quote = { quoteNo: 'FQ-ONE', details: { ...details, selfPickupPrice: '0', looseDeliveryPrice: '2', distanceMiles: '10' }, partyName: '委托企业', status: 'ACTIVE', recordVersion: '90071992547409931', updatedAt: '2026-10-01T00:00:00Z' } @@ -20,14 +20,6 @@ it('sends stable creation and batch keys without converting business references' vi.mocked(http.post).mockResolvedValue(ok({ importedRows: 1 })); await importFleetQuotes('batch-key', [details]) expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/quotes/import', { idempotencyKey: 'batch-key', rows: [details] }) }) -it('does not download a business error JSON as a CSV file', async () => { - vi.mocked(http.get).mockResolvedValue(new TextEncoder().encode('{"code":"CONFLICT","message":"缩小范围","requestId":"r1"}').buffer) - await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '缩小范围', requestId: 'r1' }) -}) -it('preserves diagnostics from a non-200 export response', async () => { - vi.mocked(http.get).mockRejectedValue({ isAxiosError: true, response: { status: 403, data: new TextEncoder().encode('{"code":"403","message":"无导出权限","traceId":"t1"}').buffer } }) - await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '无导出权限', status: 403, traceId: 't1' }) -}) it('rejects lossy money and numeric versions instead of rounding them', () => { expect(() => fleetMoney('99999999999999999.01')).toThrow('精确展示') expect(() => fleetVersion(9007199254740992)).toThrow('版本无效') diff --git a/src/modules/overseas-master-data/api/fleetQuotes.ts b/src/modules/overseas-master-data/api/fleetQuotes.ts index 08db06e8..cf55e5fb 100644 --- a/src/modules/overseas-master-data/api/fleetQuotes.ts +++ b/src/modules/overseas-master-data/api/fleetQuotes.ts @@ -1,5 +1,4 @@ import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' -import axios from 'axios' import type { PageResult } from './overseasMasterData' export type FleetQuoteKind = 'PURCHASE' | 'SALE' @@ -54,18 +53,4 @@ export async function fleetQuoteSuppliers(keyword: string) { const result = unwrapApiResponse(await http.get>, ApiEnvelope>>('/platform/v1/suppliers', { params: { page: 1, size: 30, keyword, status: 'ACTIVE', category: 'TRUCKING', truckingScope: 'OVERSEAS' } })) return result.records.map(row => ({ ...row, id: String(row.id) })) } -export async function fleetCsv(path: string, params: object): Promise { - function check(bytes: ArrayBuffer, status: number) { - const text = new TextDecoder().decode(bytes) - if (!/^\s*[{[]/.test(text)) return - let error: ApiEnvelope - try { error = JSON.parse(text) as ApiEnvelope } catch { throw new ApiResponseError('导出接口返回了无效响应', 'INVALID_EXPORT_RESPONSE', status) } - throw new ApiResponseError(error.message || '导出失败', error.code || 'EXPORT_FAILED', status, error.data, error.requestId, error.traceId) - } - try { - const bytes = await http.get(path, { params, responseType: 'arraybuffer' }); check(bytes, 200) - return new Blob([bytes], { type: 'text/csv;charset=utf-8' }) - } catch (e) { if (axios.isAxiosError(e) && Object.prototype.toString.call(e.response?.data) === '[object ArrayBuffer]') check(e.response!.data as ArrayBuffer, e.response!.status); throw e } -} -export const exportFleetQuotes = (query: FleetQuoteQuery) => fleetCsv(`${base}/export`, query) export function saveFleetCsv(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } diff --git a/src/modules/overseas-master-data/api/overseasMasterData.test.ts b/src/modules/overseas-master-data/api/overseasMasterData.test.ts index 53dad4ad..23416ea7 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.test.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.test.ts @@ -3,7 +3,7 @@ import { ApiResponseError, http } from '@/shared/api/http' import { changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle, - exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle, + getExchangeRate, getExchangeRateSummary, getQuote, getVehicle, listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle, type QuoteCreate, type QuoteUpdate, } from './overseasMasterData' @@ -124,22 +124,4 @@ describe('overseas master data API', () => { expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' }) }) - it('downloads real CSV Blobs and rejects JSON error Blobs with trace metadata', async () => { - const csv = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' }) - vi.mocked(http.get) - .mockResolvedValueOnce(csv) - .mockResolvedValueOnce(csv) - .mockResolvedValueOnce(csv) - .mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0409', message: '导出条件已变化', data: null, requestId: 'request-export' })], { type: 'application/json' })) - - await expect(exportQuotes({ status: 'ACTIVE' })).resolves.toBe(csv) - await expect(exportExchangeRates({})).resolves.toBe(csv) - await expect(exportVehicles({ ownership: 'OWNED' })).resolves.toBe(csv) - const error = await exportVehicles({}).catch((reason: unknown) => reason) - expect(error).toBeInstanceOf(ApiResponseError) - expect(error).toMatchObject({ code: 'A0409', message: '导出条件已变化', requestId: 'request-export' }) - expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'arraybuffer' }) - expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' }) - expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' }) - }) }) diff --git a/src/modules/overseas-master-data/api/overseasMasterData.ts b/src/modules/overseas-master-data/api/overseasMasterData.ts index 1046c3b2..63102745 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.ts @@ -1,5 +1,4 @@ -import axios from 'axios' -import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption, @@ -34,58 +33,6 @@ const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDet const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) }) const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion), maxPalletSpaces: nullableNumeric(value.maxPalletSpaces), maxLoadKg: nullableNumeric(value.maxLoadKg), maxVolumeCbm: nullableNumeric(value.maxVolumeCbm) }) -type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string - -async function downloadText(payload: DownloadPayload) { - if (typeof payload === 'string') return payload - if (payload instanceof Blob) return payload.text() - const bytes = payload instanceof ArrayBuffer - ? new Uint8Array(payload) - : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) - return new TextDecoder().decode(bytes) -} - -async function csvError(payload: DownloadPayload, status: number) { - const contentType = payload instanceof Blob ? payload.type.toLowerCase() : '' - const text = await downloadText(payload) - if (!contentType.includes('json') && !/^\s*[\[{]/.test(text)) return null - try { - const envelope = JSON.parse(text) as Partial> - if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error('invalid envelope') - return new ApiResponseError(envelope.message || 'CSV 导出失败', envelope.code || 'DOWNLOAD_FAILED', status, - envelope.data, envelope.requestId, envelope.traceId) - } catch (error) { - if (error instanceof ApiResponseError) return error - return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status) - } -} -function csvBlob(payload: DownloadPayload) { - if (payload instanceof Blob) return payload - if (typeof payload === 'string') return new Blob([payload], { type: 'text/csv;charset=utf-8' }) - const bytes = payload instanceof ArrayBuffer - ? new Uint8Array(payload) - : new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength) - const copy = new Uint8Array(bytes.byteLength) - copy.set(bytes) - return new Blob([copy.buffer], { type: 'text/csv;charset=utf-8' }) -} -async function download(path: string, params: object) { - try { - const payload = await http.get(path, { params, responseType: 'arraybuffer' }) - const error = await csvError(payload, 200) - if (error) throw error - return csvBlob(payload) - } catch (error) { - const axiosError = axios.isAxiosError(error) ? error : null - const responsePayload = axiosError?.response?.data as DownloadPayload | undefined - if (responsePayload !== undefined) { - const responseError = await csvError(responsePayload, axiosError?.response?.status || 0) - if (responseError) throw responseError - } - throw error - } -} - export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus } type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] } type QuoteEditableFields = Pick @@ -98,7 +45,6 @@ export async function listQuoteWarehouseOptions() { return unwrapApiResponse(awa export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/overseas-quotes`, payload))) } export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await http.put, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) } export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await http.patch, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } -export const exportQuotes = (query: Omit) => download(`${base}/overseas-quotes/export`, query) export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus } export const listExchangeRates = (query: ExchangeRateQuery) => getPage(`${base}/exchange-rates`, query, normalizeExchangeRate) @@ -110,7 +56,6 @@ export async function getExchangeRateSummary(): Promise { export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/exchange-rates`, payload))) } export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.put, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) } export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await http.patch, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) } -export const exportExchangeRates = (query: Omit) => download(`${base}/exchange-rates/export`, query) export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus } type VehicleEditableFields = Omit @@ -121,4 +66,3 @@ export async function getVehicle(id: string) { return normalizeVehicle(unwrapApi export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/vehicles`, payload))) } export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await http.put, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) } export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await http.patch, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } -export const exportVehicles = (query: Omit) => download(`${base}/vehicles/export`, query) diff --git a/src/modules/overseas-master-data/api/tripOperations.ts b/src/modules/overseas-master-data/api/tripOperations.ts index 9c7aa87d..3b740d1e 100644 --- a/src/modules/overseas-master-data/api/tripOperations.ts +++ b/src/modules/overseas-master-data/api/tripOperations.ts @@ -1,7 +1,7 @@ import axios from 'axios' import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' import type { PageResult } from './overseasMasterData' -import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes' +import { fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes' import type { CostSummary, CostTotal } from './tripCosts' export interface TripOperationsQuery { page?: number; size?: number; from: string; to: string; keyword?: string; warehouseId?: string; sourceType?: 'OWN' | 'EXTERNAL'; status?: string } export interface TripOperationsMoney { currency: string; amount: number } @@ -42,7 +42,6 @@ export async function listTripOperations(query: TripOperationsQuery): Promise; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult; summary: WireSummary; charts: WireCharts }>>('/platform/v1/last-leg/fleet/operations', { params: query })) return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } } } -export const exportTripOperations = (query: TripOperationsQuery) => fleetCsv('/platform/v1/last-leg/fleet/operations/export', query) export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null } export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: string | null } export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured } diff --git a/src/modules/overseas-master-data/pages/ExchangeRatesView.vue b/src/modules/overseas-master-data/pages/ExchangeRatesView.vue index 4bbbcfa6..57c70496 100644 --- a/src/modules/overseas-master-data/pages/ExchangeRatesView.vue +++ b/src/modules/overseas-master-data/pages/ExchangeRatesView.vue @@ -1,4 +1,5 @@ @@ -55,7 +56,7 @@ watch([page, pageSize], load); onMounted(load) - + diff --git a/src/modules/overseas-master-data/pages/FleetQuotesView.vue b/src/modules/overseas-master-data/pages/FleetQuotesView.vue index bf0e2475..396b20c2 100644 --- a/src/modules/overseas-master-data/pages/FleetQuotesView.vue +++ b/src/modules/overseas-master-data/pages/FleetQuotesView.vue @@ -1,4 +1,5 @@