This commit is contained in:
2026-10-08 17:08:03 +08:00
parent cc37a42298
commit 7b0df1db37
44 changed files with 574 additions and 405 deletions
+1 -1
View File
@@ -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/` |
@@ -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: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, PalletLabelPrintDialog: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('@/modules/loading-lists', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/modules/loading-lists')>()
const api = { getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }
return {
...api,
useLoadingListDetail: () => actual.useLoadingListDetail(api),
ContainerFeesPanel: { template: '<div />' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded', 'dense', 'backLabel'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' },
PalletLabelPrintDialog: { template: '<div />' },
}
})
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: '<div />' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '<div />' } }))
@@ -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()
}
@@ -30,12 +30,12 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain(':sections="[\'documents\']"')
expect(source).toContain(':sections="[\'pda\']"')
expect(source).toContain('<LoadingListDetailDrawer')
expect(source).toContain('getConfirmedLoadingList')
expect(source).toContain('getPreloadPlan')
expect(source).toContain('useLoadingListDetail')
expect(source).toContain('await loadListDetail(mode, aggregate.loadingPlanId')
expect(source).toContain('label="装柜清单"')
expect(source).toContain('打印装柜清单')
expect(source).not.toContain('function printLoadingList')
expect(source).toContain("const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')")
expect(source).toContain('<LoadingListDetailDrawer dense :mode="loadingListMode" embedded')
expect(source).toContain('<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览"')
expect(source).toContain(':sections="[\'pda\']"')
})
@@ -46,7 +46,7 @@ describe('ContainerDetailView customs integration', () => {
expect(navigationSource).toContain("title: '装柜清单'")
expect(source).toContain('<el-tabs v-model="activeTab"')
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
expect(source).toContain("await loadListDetail(mode, aggregate.loadingPlanId || '', resource.warehouseId || '')")
expect(source).toContain('getContainer(containerId.value, props.workbench)')
expect(source).toContain('getContainerOverview(containerId.value)')
expect(source).toContain(':can-edit="false"')
@@ -12,7 +12,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail, type LoadingListMode } from '@/modules/loading-lists'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
@@ -144,9 +144,7 @@ const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const loadingDetail = ref<LoadingDetail | null>(null)
const loadingDetailLoading = ref(false)
const loadingDetailError = ref<ApiErrorDetails | null>(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') })
</script>
@@ -588,10 +575,10 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div><el-button v-if="loadingDetail && loadingListMode === 'confirmed'" @click="printLoadingList">打印装柜清单</el-button></header>
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer dense :mode="loadingListMode" embedded :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
@@ -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'
+1
View File
@@ -1 +1,2 @@
export * from './api/platformExportTasks'
export { useExportTask } from './useExportTask'
@@ -26,6 +26,16 @@ type DownloadFilters = QueryModel & {
}
const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = {
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' },
@@ -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<ReturnType<typeof createPlatformExportTask>>)
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 + "'")
})
})
+16
View File
@@ -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' })
}
}
}
@@ -104,7 +104,7 @@ function handleSubmitted() {
</div>
<template #footer>
<div class="measurement-dialog-footer">
<span v-if="measurementPanelRef?.submissionBlockReason" class="measurement-dialog-footer__hint">
<span v-if="measurementPanelRef?.submissionBlockReason" class="measurement-dialog-footer__hint" role="status">
{{ measurementPanelRef.submissionBlockReason }}
</span>
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
@@ -147,15 +147,6 @@ function handleSubmitted() {
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
.inbound-measurement-workspace-dialog .measurement-summary-section { margin: 0 -12px; padding: 10px 12px 13px; border: 1px solid #cce8df; border-left: 4px solid #20a477; border-radius: 8px; background: #f4fbf8; }
.inbound-measurement-workspace-dialog .measurement-summary-context { min-height: auto; }
.inbound-measurement-workspace-dialog .measurement-subheading { color: #18795d; font-size: 15px; }
.inbound-measurement-workspace-dialog .measurement-metrics { padding: 0; border: 1px solid #cce8df; border-radius: 8px; background: #fff; overflow: hidden; }
.inbound-measurement-workspace-dialog .measurement-metrics > div { min-height: 58px; padding: 9px 14px; border-left: 1px solid #e8ecf2; }
.inbound-measurement-workspace-dialog .measurement-metrics > div:first-child { border-left: 0; }
.inbound-measurement-workspace-dialog .measurement-declaration { margin-top: 12px; padding: 10px 12px 0; border: 1px solid #d9e2fa; border-left: 4px solid #647fec; border-radius: 8px; background: #f7f9ff; }
.inbound-measurement-workspace-dialog .measurement-items-section { margin-top: 12px; padding: 10px 12px 13px; border: 1px solid #eadfc9; border-left: 4px solid #c79643; border-radius: 8px; background: #fffbf4; }
.inbound-measurement-workspace-dialog .measurement-items-section .measurement-section__heading { margin-bottom: 8px; }
@media (max-width: 720px) {
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
@@ -266,7 +266,7 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
const text = wrapper.text()
expect(text).toContain('实测箱数')
expect(text).toContain('箱数')
expect(text).toContain('货物净重')
expect(text).toContain('总体积')
expect(text).toContain('材积重')
@@ -274,7 +274,7 @@ describe('InboundMeasurementPanel', () => {
expect(text).toContain('平台实测')
expect(text).toContain('差异 +1.5 kg')
expect(text).toContain('差异 -0.2 m³')
expect(text).toContain('箱规明细')
expect(text).toContain('平台实测箱规')
expect(text).not.toContain('租户')
expect(text).not.toContain('数据版本')
@@ -287,27 +287,58 @@ describe('InboundMeasurementPanel', () => {
expect(source).toContain('<section class="measurement-declaration"')
expect(source).not.toContain('<el-collapse-item name="declaration"')
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
expect(source).toContain('.measurement-table { width: 100%; font-size: var(--admin-font-caption, 12px); }')
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
})
it('aligns declared and measured values and keeps the declaration outside the measured section', async () => {
const wrapper = await mountPanel()
const rows = wrapper.findAll('.measurement-comparison tbody tr')
expect(rows.map(row => row.findAll('td').map(cell => cell.text()))).toEqual([
['10', '10', '一致'], ['118.5', '120', '差异 +1.5 kg'], ['0.8', '0.6', '差异 -0.2 m³'],
])
expect(wrapper.find('.measurement-summary-section .measurement-declaration').exists()).toBe(false)
expect(wrapper.find('.measurement-declaration').element.parentElement).toBe(wrapper.find('.measurement-summary-section').element.parentElement)
})
it('shows pending comparison rather than a negative count when no boxes have been measured', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
items: [], actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
}))
const wrapper = await mountPanel()
const rows = wrapper.findAll('.measurement-comparison tbody tr')
for (const row of rows) {
expect(row.findAll('td')[1].text()).toBe('未测量')
expect(row.findAll('td')[2].text()).toBe('待测量')
}
})
it('groups titles and related fields into shared header rows without duplicating submission warnings', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ measurementId: null, items: [], weightResults: [] }))
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-summary-header h4').text()).toBe('申报与实测对比')
expect(wrapper.find('.measurement-declaration-header').text()).toContain('DOC-001')
expect(wrapper.find('.measurement-section__heading .measurement-goods-name input').exists()).toBe(true)
expect(wrapper.find('.measurement-section__heading .measurement-section-actions').text()).toContain('手动录入箱规')
expect(wrapper.findAll('.el-alert').some(alert => alert.text().includes('请先完成箱规测量'))).toBe(false)
expect(wrapper.find('.measurement-panel__footer').text()).toContain('请先完成箱规测量')
})
it('renders customer declaration facts as plain read-only values', async () => {
const wrapper = await mountPanel()
const declaration = wrapper.find('.measurement-declaration')
expect(declaration.text()).toContain('申报箱数')
expect(declaration.text()).toContain('10')
expect(declaration.text()).toContain('申报毛重(kg)')
expect(declaration.text()).toContain('118.5')
expect(declaration.text()).toContain('申报体积(m³)')
expect(declaration.text()).toContain('0.8')
const comparison = wrapper.find('.measurement-comparison')
expect(comparison.text()).toContain('118.5')
expect(comparison.text()).toContain('0.8')
expect(comparison.findAll('tbody tr')[0].text()).toContain('10')
expect(declaration.text()).toContain('原单据号码')
expect(declaration.text()).toContain('DOC-001')
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(4)
expect(declaration.findAll('input')).toHaveLength(1)
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1)
expect(declaration.findAll('input')).toHaveLength(0)
expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true)
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱')
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
})
@@ -315,7 +346,7 @@ describe('InboundMeasurementPanel', () => {
it('does not render differences when the corresponding declaration is unavailable', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ originalGrossWeightKg: null, originalVolumeCbm: null }))
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-metrics').text()).not.toContain('差异')
expect(wrapper.findAll('.measurement-comparison tbody tr').slice(1).map(row => row.findAll('td')[2].text())).toEqual(['—', '—'])
})
it('shows all declaration fields as plain text when measurement is not editable', async () => {
@@ -325,7 +356,8 @@ describe('InboundMeasurementPanel', () => {
expect(declaration.find('input').exists()).toBe(false)
expect(declaration.text()).toContain('蓝牙音箱')
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(5)
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1)
expect(wrapper.find('.measurement-items-section .measurement-goods-name').text()).toContain('蓝牙音箱')
})
it('saves the corrected actual goods name without overriding declaration facts', async () => {
@@ -369,8 +401,8 @@ describe('InboundMeasurementPanel', () => {
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
await flushPromises()
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
expect(wrapper.find('.measurement-metrics').text()).toContain('115.75')
expect(wrapper.find('.measurement-summary-section').text()).toContain('125.75')
expect(wrapper.find('.measurement-summary-section').text()).toContain('115.75')
expect((countInput.element as HTMLInputElement).value).toBe('9')
})
@@ -515,6 +547,35 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('explains missing measurement prerequisites and avoids duplicate empty source status', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
measurementId: null, items: [], sourceType: null, weightResults: [],
}))
const wrapper = await mountPanel()
expect(wrapper.vm.maySubmit).toBe(false)
expect(wrapper.vm.submissionBlockReason).toContain('请先完成箱规测量')
expect(wrapper.vm.submissionBlockReason).toContain('称重图片')
expect(wrapper.find('.measurement-summary-status').text()).not.toContain('尚未测量')
expect(wrapper.find('.measurement-panel__footer [role="status"]').text()).toContain('请先完成箱规测量')
})
it('explains order review and submitted-state blockers before measurement prerequisites', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ processStatus: 'PREPARING', measurementId: null }))
const pendingOrder = await mountPanel()
expect(pendingOrder.vm.submissionBlockReason).toContain('订单尚待客服审核')
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ processStatus: 'MEASURE_PENDING' }))
const pendingMeasurement = await mountPanel()
expect(pendingMeasurement.vm.submissionBlockReason).toContain('不能提交测量审核')
})
it('keeps dropship pallet edits in the platform section and declaration facts read-only', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ fulfillmentType: 'ONE_CLICK_DROPSHIP', expectedPalletCount: 2 }))
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-declaration input').exists()).toBe(false)
expect(wrapper.find('.measurement-items-section .measurement-expected-pallets input').exists()).toBe(true)
expect(wrapper.find('.measurement-items-section .measurement-goods-name input').exists()).toBe(true)
})
it('requires at least one confirmed weight image before submitting for review', async () => {
const messageSpy = vi.spyOn(ElMessage, 'warning')
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ weightResults: [] }))
@@ -119,6 +119,13 @@ const mayMaintain = computed(() => canEdit.value && countEditableProcess.value &
const manualEntryEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayCreateManualEntry = computed(() => canManualEntry.value && manualEntryEditableProcess.value && !loading.value && !saving.value && !submitting.value && !isDirty.value)
const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => sum + (Number.isFinite(item.packageCount) ? item.packageCount : 0), 0))
// No scan rows means no measured count yet, rather than a measured zero.
const comparisonMetrics = computed(() => [
{ label: '箱数', declared: detail.value?.originalPackageCount, actual: form.items.length ? confirmedPackageCount.value : null, digits: 0, unit: '' },
{ label: '毛重(kg)', declared: detail.value?.originalGrossWeightKg, actual: detail.value?.actualGrossWeightKg, digits: 3, unit: ' kg' },
{ label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: detail.value?.actualVolumeCbm, digits: 4, unit: ' m³' },
])
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
@@ -127,7 +134,10 @@ const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_D
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
const submissionBlockReason = computed(() => {
if (!canSubmit.value || !submitEditableProcess.value || !form.measurementId) return ''
if (!canSubmit.value || !detail.value || loading.value) return ''
if (detail.value.processStatus === 'PREPARING') return '订单尚待客服审核,审核通过后才能提交测量审核'
if (!submitEditableProcess.value) return `当前${processStatusLabel(detail.value.processStatus)},不能提交测量审核`
if (!form.measurementId) return hasConfirmedWeightResult.value ? '请先完成箱规测量并保存测量数据' : '请先完成箱规测量,并上传确认至少一张称重图片'
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
if (!expectedPalletCountValid.value) return '请先填写并保存有效的预计托数后再提交审核'
@@ -675,15 +685,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
<el-alert
v-if="submissionBlockReason && !hasConfirmedWeightResult"
type="warning"
:closable="false"
show-icon
class="measurement-alert"
:title="submissionBlockReason"
/>
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
<template #title>数据版本已变化,当前修改尚未丢失</template>
<p>{{ operationError?.message }}</p>
@@ -699,13 +700,14 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<legend class="sr-only">测量草稿</legend>
<section class="measurement-section measurement-summary-section">
<header class="measurement-summary-header">
<h4 class="measurement-subheading">申报与实测对比</h4>
<div class="measurement-summary-context">
<p><span>客户</span>{{ displayCustomerName }}</p>
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
</div>
<div class="measurement-summary-status">
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
<span>{{ sourceLabel }}</span>
<span v-if="detail.sourceType && form.items.length">{{ sourceLabel }}</span>
</div>
</header>
@@ -720,57 +722,66 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
</el-alert>
<h4 class="measurement-subheading">平台实测</h4>
<dl class="measurement-metrics" aria-label="平台实测结果汇总">
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
<dt>实测箱数</dt>
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small v-if="formatDifference(confirmedPackageCount, expectedPackageCount, 0)">/ {{ formatDifference(confirmedPackageCount, expectedPackageCount, 0) }}</small></dd>
</div>
<div><dt>实测毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg<span v-if="formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg')"> · {{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</span></small></dd></div>
<div class="measurement-metric--primary"><dt>货物净重</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³<span v-if="formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³')"> · {{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</span></small></dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
<table class="measurement-comparison" aria-label="客户申报与平台实测对比">
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
<tbody>
<tr v-for="metric in comparisonMetrics" :key="metric.label">
<th scope="row">{{ metric.label }}</th>
<td>{{ formatNumber(metric.declared, metric.digits) }}</td>
<td>{{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits) }}</td>
<td :class="{ 'measurement-comparison__difference': metric.actual != null && metric.declared != null && metric.actual !== metric.declared }">
{{ metric.actual == null ? '待测量' : metric.declared == null ? '—' : formatDifference(metric.actual, metric.declared, metric.digits, metric.unit) || '一致' }}
</td>
</tr>
</tbody>
</table>
<div class="measurement-extra-facts">
<span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
<span>材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span>
<el-tooltip content="净重、材积重为平台数据,不与客户申报毛重比较"><span tabindex="0">平台补充数据</span></el-tooltip>
</div>
</section>
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
<h4 id="measurement-declaration-title">客户申报</h4>
<div class="measurement-data-grid">
<div class="measurement-readonly-field"><span>申报箱数</span><strong>{{ formatNumber(expectedPackageCount, 0) }}</strong></div>
<div class="measurement-readonly-field"><span>申报毛重(kg)</span><strong>{{ formatNumber(detail.originalGrossWeightKg) }}</strong></div>
<div class="measurement-readonly-field"><span>申报体积(m³)</span><strong>{{ formatNumber(detail.originalVolumeCbm, 4) }}</strong></div>
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="255" /></label>
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
<label v-if="isDropship && mayMaintain"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
<div v-else-if="isDropship" class="measurement-readonly-field"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
</div>
<el-table
v-if="detail.latestDeclaration?.items.length"
:data="detail.latestDeclaration.items"
border
stripe
size="small"
class="measurement-declaration-table"
aria-label="客户申报箱规明细"
>
<el-table-column prop="itemNo" label="#" width="70" />
<el-table-column prop="cargoName" label="货物品名" min-width="150" show-overflow-tooltip />
<el-table-column prop="packageCount" label="申报箱数" width="100" />
<el-table-column label="申报尺寸 长×宽×高(cm)" min-width="190">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
</el-table>
</section>
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
<header class="measurement-declaration-header">
<h4 id="measurement-declaration-title">客户申报明细</h4>
<div class="measurement-readonly-field measurement-document-number"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
</header>
<el-table
v-if="detail.latestDeclaration?.items.length"
:data="detail.latestDeclaration.items"
border
stripe
size="small"
class="measurement-declaration-table"
aria-label="客户申报箱规明细"
>
<el-table-column prop="itemNo" label="#" width="70" />
<el-table-column prop="cargoName" label="货物品名" min-width="150" show-overflow-tooltip />
<el-table-column prop="packageCount" label="申报箱数" width="100" />
<el-table-column label="申报尺寸 长×宽×高(cm)" min-width="190">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
</el-table>
</section>
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>箱规明细</h3></div>
<div><h3>平台实测箱规</h3></div>
<div class="measurement-data-grid measurement-platform-fields">
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="255" /></label>
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
<label v-if="isDropship && mayMaintain" class="measurement-expected-pallets"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
<div v-else-if="isDropship" class="measurement-readonly-field measurement-expected-pallets"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
</div>
<div class="measurement-section-actions">
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
</div>
<el-table
v-if="form.items.length"
:data="form.items"
@@ -854,7 +865,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</form>
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
<span v-if="!hasConfirmedWeightResult"><AlertTriangle :size="15" />至少上传并确认一张称重图片后才能提交审核</span>
<span v-if="submissionBlockReason" role="status"><AlertTriangle :size="15" />{{ submissionBlockReason }}</span>
<span v-else-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
<span v-else>所有修改已保存</span>
<div>
@@ -906,6 +917,14 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</template>
<style scoped>
.measurement-comparison { width: 100%; border-collapse: collapse; table-layout: fixed; background: var(--admin-surface, #fff); font-size: var(--admin-font-body, 13px); }
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.measurement-comparison thead th { background: #f8fafc; }
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
.measurement-comparison thead th:nth-child(3) { background: #f4fbf8; color: #18795d; }
.measurement-comparison__difference { color: var(--el-color-warning-dark-2); font-weight: 600; }
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-extra-facts strong { color: var(--cc-text); }
.measurement-panel { overflow: hidden; }
.measurement-panel__heading > div:first-child { display: flex; min-width: 0; align-items: flex-start; gap: 11px; }
.measurement-panel__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef4ff; color: var(--cc-primary); }
@@ -914,6 +933,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.measurement-panel h3 { font-size: var(--admin-font-section, 14px); }
.measurement-panel__heading p, .measurement-section__heading p { margin: var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); line-height: 1.55; }
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: var(--admin-space-3, 8px); }
.measurement-platform-fields { margin-bottom: var(--admin-space-5, 12px); }
.measurement-declaration-table { margin-top: var(--admin-space-5, 12px); }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
.measurement-skeleton { padding: var(--admin-space-7, 24px); }
@@ -931,15 +951,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.measurement-deviation-alert p { margin: 3px 0 0; line-height: 1.55; }
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2, 6px); margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; }
.measurement-subheading { margin: 0; color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 700; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 58px; align-content: center; gap: var(--admin-space-1, 4px); padding: 5px 18px; border-left: 1px solid var(--cc-border); }
.measurement-metrics > div:first-child { padding-left: 0; border-left: 0; }
.measurement-metrics dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-metrics dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 18px; font-weight: 700; overflow-wrap: anywhere; }
.measurement-metrics small { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); font-weight: 500; }
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 20px; }
.measurement-metric--warning dd { color: var(--el-color-warning-dark-2); }
.measurement-declaration { padding-top: var(--admin-space-5, 12px); border-top: 1px solid var(--cc-border); }
.measurement-declaration h4 { margin: 0 0 var(--admin-space-4, 10px); color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 700; }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: var(--admin-space-6, 16px); }
@@ -947,7 +959,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.measurement-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--admin-space-3, 8px); }.measurement-section-actions :deep(.el-button) { margin-left: 0; }
.warehouse-suggestion-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }.warehouse-suggestion-form :deep(.el-select),.warehouse-suggestion-form :deep(.el-input-number) { width: 100%; }.warehouse-suggestion-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.suggestion-preview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: var(--admin-space-1, 4px) 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: var(--admin-space-5, 12px); border-right: 1px solid var(--cc-border); }.suggestion-preview div:last-child { border-right: 0; }.suggestion-preview dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 650; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--admin-space-5, 12px); }
.measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-5, 12px); }
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 650; }
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
.measurement-data-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 600; }
@@ -982,8 +994,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.measurement-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics > div:nth-child(3) { padding-left: 0; border-left: 0; }
}
@media (max-width: 720px) {
@@ -1,15 +1,38 @@
/* Measurement workspace: keep all sections inside the panel's content bounds. */
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid #cce8df; border-left: 4px solid #20a477; border-radius: 8px; background: #f4fbf8; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid #eadfc9; border-left: 4px solid #c79643; border-radius: 8px; background: #fffbf4; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid #d9e2fa; border-left: 4px solid #647fec; border-radius: 8px; background: #f7f9ff; }
@media (min-width: 961px) {
.inbound-density.admin-density-dialog .measurement-dialog-weight { padding-bottom: var(--admin-space-4, 10px); }
.inbound-density.admin-density-dialog .measurement-section { padding: var(--admin-space-4, 10px) 0; }
.inbound-density.admin-density-dialog .measurement-section { padding-block: var(--admin-space-4, 10px); }
.inbound-density.admin-density-dialog .measurement-summary-section { gap: var(--admin-space-3, 8px); }
.inbound-density.admin-density-dialog .measurement-section__heading { align-items: center; margin-bottom: var(--admin-space-3, 8px); }
.inbound-density.admin-density-dialog .measurement-metrics { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.inbound-density.admin-density-dialog .measurement-metrics > div { min-height: var(--admin-row-height, 36px); padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); display: flex; align-items: baseline; gap: var(--admin-space-3, 8px); }
.inbound-density.admin-density-dialog .measurement-metrics dd { font-size: var(--admin-font-section, 14px); }
.inbound-density.admin-density-dialog .measurement-metrics small { font-size: var(--admin-font-caption, 12px); }
.inbound-density.admin-density-dialog .measurement-data-grid { gap: var(--admin-space-3, 8px) var(--admin-space-5, 12px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header h4,
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header h4 { flex-shrink: 0; margin: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration-header { display: flex; align-items: baseline; gap: var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number { display: flex; flex: 1; align-items: baseline; gap: var(--admin-space-3, 8px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-document-number strong { min-width: 0; min-height: 0; padding: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section .measurement-section__heading { flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields { display: flex; flex: 1 1 520px; align-items: center; flex-wrap: wrap; margin: 0; gap: var(--admin-space-5, 12px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > label,
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > .measurement-readonly-field { display: flex; flex-direction: row; align-items: center; gap: var(--admin-space-3, 8px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > .measurement-goods-name { flex: 1 1 340px; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > .measurement-expected-pallets { flex: 0 0 180px; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields > * > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields .el-input,
.inbound-measurement-workspace-dialog.inbound-density .measurement-platform-fields .el-input-number { min-width: 0; flex: 1; width: auto; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-section-actions { margin-left: auto; }
.inbound-density.admin-density-dialog .measurement-declaration { padding-top: var(--admin-space-3, 8px); }
.inbound-density.admin-density-dialog .measurement-declaration h4 { margin-bottom: var(--admin-space-2, 6px); font-size: var(--admin-font-body, 13px); }
.inbound-density.admin-density-dialog .measurement-declaration h4 { font-size: var(--admin-font-body, 13px); }
.inbound-density.admin-density-dialog .measurement-panel > .el-alert { margin: var(--admin-space-3, 8px) 0; }
.inbound-density.admin-density-dialog .measurement-summary-header span { font-size: var(--admin-font-caption, 12px); }
.inbound-density.admin-density-dialog .measurement-table .el-table__header th { padding: var(--admin-space-1, 4px) 0; font-size: var(--admin-font-body, 13px); }
@@ -0,0 +1,27 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
import type { LoadingDetail } from '../api/platformLoadingLists'
afterEach(() => vi.restoreAllMocks())
describe('shared manifest presentation', () => {
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const wrapper = shallowMount(LoadingListDetailDrawer, {
props: { mode: 'confirmed', dense: true, backLabel, canEdit: false, loading: false, error: null,
detail: { summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [], histories: [] } as unknown as LoadingDetail },
global: { directives: { loading: {} }, stubs: { ElButton: { template: '<button><slot /></button>' } } },
})
expect(wrapper.classes()).toContain('loading-density')
expect(wrapper.get('h1').text()).toBe('装柜清单 · LP-TEST')
const button = wrapper.get('.detail-page-header__actions button')
expect(button.text()).toBe('打印装柜清单')
await button.trigger('click'); await flushPromises()
expect(print).toHaveBeenCalledTimes(1)
expect(wrapper.get('.detail-page-header__back').text()).toBe(backLabel || '返回列表')
await wrapper.get('.detail-page-header__back').trigger('click')
expect(wrapper.emitted('back')).toHaveLength(1)
wrapper.unmount()
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, Check, ChevronRight, Pencil } from '@lucide/vue'
import { ArrowLeft, Check, ChevronRight, Pencil, Printer } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -9,6 +9,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
import { loadingListStatusLabel } from '../status'
import { printLoadingList } from '../printLoadingList'
import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
@@ -22,6 +23,7 @@ const props = defineProps<{
pdaAssigneeSelected?: boolean
dense?: boolean
embedded?: boolean
backLabel?: string
canEditTransport?: boolean
confirmSaving?: boolean
}>()
@@ -161,12 +163,13 @@ function formatNumber(value?: number | null, digits = 3) {
<section v-loading="loading" class="loading-detail-content" :class="{ 'admin-density loading-density': mode === 'preload' || dense }" :aria-label="title" :aria-busy="loading">
<section class="detail-overview" aria-label="清单概览">
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
<div v-if="!embedded" class="detail-page-header__identity">
<h1>{{ title }}</h1>
<p v-if="mode !== 'preload'">查看已完成装柜明细和柜次费用</p>
<p v-if="mode !== 'preload'">查看已完成装柜明细和操作历史</p>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
<template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
@@ -6,6 +6,7 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/co
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
const preloadDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
const confirmedDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/ConfirmedLoadingListDetailView.vue'), 'utf8')
const sharedLoaderSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/useLoadingListDetail.ts'), 'utf8')
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8')
describe('LoadingListsView', () => {
@@ -31,7 +32,7 @@ describe('LoadingListsView', () => {
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
expect(source).not.toContain("'请先选择仓库后查询'")
expect(preloadDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
expect(confirmedDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
expect(sharedLoaderSource).toContain('MISSING_WAREHOUSE_CONTEXT')
})
it('uses the shared query and table components while keeping details on route pages', () => {
@@ -63,7 +64,7 @@ describe('LoadingListsView', () => {
expect(source).not.toContain('confirmed-inline-detail')
expect(preloadDetailSource).toContain('返回列表')
expect(preloadDetailSource).toContain('router.back()')
expect(confirmedDetailSource).toContain('返回列表')
expect(detailSource).toContain("backLabel || '返回列表'")
expect(confirmedDetailSource).toContain('router.back()')
expect(confirmedDetailSource).toContain('@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }')
expect(routesSource).toContain("path: 'loading-lists/:planId'")
@@ -112,10 +113,10 @@ describe('LoadingListsView', () => {
expect(source).toContain('route.query.planId')
expect(source).toContain('route.query.warehouseId')
expect(source).toContain("await router.replace({ name: 'loading-list-detail'")
expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'")
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
expect(sharedLoaderSource).toContain("result.summary.status !== 'COMPLETED'")
expect(sharedLoaderSource).toContain('MODE_STATUS_MISMATCH')
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
expect(confirmedDetailSource).toContain('if (sequence !== loadSequence) return')
expect(sharedLoaderSource).toContain('if (request !== sequence) return null')
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => {')
})
+1
View File
@@ -11,3 +11,4 @@ export { loadingListStatusLabel } from './status'
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
export { default as PalletLabelPrintDialog } from './components/PalletLabelPrintDialog.vue'
export { useLoadingListDetail } from './useLoadingListDetail'
@@ -5,7 +5,7 @@ import ConfirmedLoadingListDetailView from './ConfirmedLoadingListDetailView.vue
const mocks = vi.hoisted(() => ({ get: vi.fn(), push: vi.fn(), replace: vi.fn(), back: vi.fn(), route: { params: { planId: '1' }, query: { warehouseId: '11' } as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => mocks }))
vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get }))
vi.mock('../api/platformLoadingLists', () => ({ getConfirmedLoadingList: mocks.get, getPreloadPlan: vi.fn() }))
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
function render() {
@@ -1,57 +1,23 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import {
getConfirmedLoadingList,
type ConfirmedSummary,
type LoadingDetail,
} from '../api/platformLoadingLists'
import { useLoadingListDetail } from '../useLoadingListDetail'
import { printLoadingList } from '../printLoadingList'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
const error = ref<ApiErrorDetails | null>(null)
let loadSequence = 0
const { detail, loading, error, load } = useLoadingListDetail()
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
async function loadDetail() {
const sequence = ++loadSequence
detail.value = null
if (!planId.value || !warehouseId.value) {
loading.value = false
error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少所属仓库信息,无法安全查询详情,请返回列表重新进入。' }
return
}
loading.value = true
error.value = null
try {
const result = await getConfirmedLoadingList(planId.value, warehouseId.value)
if (sequence !== loadSequence) return
if (result.summary.status !== 'COMPLETED') {
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' }
return
}
detail.value = result
loading.value = false
if (route.query.print === '1') {
await nextTick()
if (sequence !== loadSequence) return
window.print()
const { print: _print, ...query } = route.query
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
}
} catch (cause) {
if (sequence !== loadSequence) return
detail.value = null
error.value = getApiErrorDetails(cause, '装柜清单详情加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
const result = await load('confirmed', planId.value, warehouseId.value)
if (!result || detail.value !== result || route.query.print !== '1') return
await printLoadingList()
const { print: _print, ...query } = route.query
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
}
function goBack() {
@@ -59,10 +25,6 @@ function goBack() {
else void router.push({ name: 'loading-lists' })
}
function printDetail() {
window.print()
}
onMounted(() => void loadDetail())
watch([planId, warehouseId], () => void loadDetail())
</script>
@@ -70,8 +32,7 @@ watch([planId, warehouseId], () => void loadDetail())
<template>
<AdminPage>
<div class="confirmed-loading-detail-page">
<el-button v-if="detail" class="print-action" :disabled="loading" @click="printDetail">打印</el-button>
<LoadingListDetailDrawer
<LoadingListDetailDrawer dense
mode="confirmed"
:loading="loading"
:detail="detail"
@@ -86,6 +47,5 @@ watch([planId, warehouseId], () => void loadDetail())
<style scoped>
.confirmed-loading-detail-page { display: grid; min-width: 0; max-width: 1280px; gap: 16px; }
@media print { .print-action { display: none; } }
@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }
</style>
@@ -0,0 +1,6 @@
import { nextTick } from 'vue'
export async function printLoadingList() {
await nextTick()
window.print()
}
@@ -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<typeof effectScope>[] = []
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')
})
})
@@ -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<LoadingDetail | null>(null)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(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 }
}
+3 -1
View File
@@ -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 仅用于车队报价导入模板。
@@ -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<typeof import('@/shared/api/http')>()), 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('版本无效')
@@ -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<PageResult<{ id: string; name: string }>>, ApiEnvelope<PageResult<{ id: string; name: string }>>>('/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<Blob> {
function check(bytes: ArrayBuffer, status: number) {
const text = new TextDecoder().decode(bytes)
if (!/^\s*[{[]/.test(text)) return
let error: ApiEnvelope<unknown>
try { error = JSON.parse(text) as ApiEnvelope<unknown> } 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<ArrayBuffer, ArrayBuffer>(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) }
@@ -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' })
})
})
@@ -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<ApiEnvelope<unknown>>
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<DownloadPayload, DownloadPayload>(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<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'remark'>
@@ -98,7 +45,6 @@ export async function listQuoteWarehouseOptions() { return unwrapApiResponse(awa
export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await http.post<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes`, payload))) }
export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await http.put<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) }
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await http.patch<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => 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<MonthlyExchangeRate, ExchangeRateWire>(`${base}/exchange-rates`, query, normalizeExchangeRate)
@@ -110,7 +56,6 @@ export async function getExchangeRateSummary(): Promise<ExchangeRateSummary> {
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.post<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${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<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${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<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
@@ -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<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles`, payload))) }
export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await http.put<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) }
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await http.patch<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
@@ -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<Tr
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult<WireRow>; 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 }
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Plus } from '@lucide/vue'
@@ -11,12 +12,12 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeExchangeRateStatus, createExchangeRate, exportExchangeRates, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
import { changeExchangeRateStatus, createExchangeRate, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { saveCsv } from '@/shared/downloadCsv'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
const submitExport = useExportTask()
const access = useAccessPreviewStore()
const rows = ref<MonthlyExchangeRate[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editingMonth = ref('')
@@ -40,7 +41,7 @@ function openCreate() { editingMonth.value = ''; Object.assign(form, { month: ''
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
async function save() { if (!form.month || form.usdToCnyAverage <= 0) { ElMessage.warning('请选择月份并填写大于 0 的平均汇率'); return } saving.value = true; try { if (editingMonth.value) await updateExchangeRate(editingMonth.value, { recordVersion: form.recordVersion, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); else await createExchangeRate({ month: form.month, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); ElMessage.success(editingMonth.value ? '月度汇率已更新' : '月度汇率已创建'); editorOpen.value = false; await load() } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '月度汇率保存失败')) } finally { saving.value = false } }
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, '汇率状态', { customClass: 'admin-density-confirm', type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); saveCsv(await exportExchangeRates(query), 'monthly-exchange-rates.csv') } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); await submitExport('MONTHLY_EXCHANGE_RATE_LIST', '月度汇率', query) } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
watch([page, pageSize], load); onMounted(load)
</script>
@@ -55,7 +56,7 @@ watch([page, pageSize], load); onMounted(load)
<template #before-results><dl class="rate-summary density-detail-overview"><div v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.exchangeRateEdit)" @click="openEdit(row as MonthlyExchangeRate)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.exchangeRateStatus)" :data-tone="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template>
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
</DataTable></section>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -13,7 +14,8 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import FleetQuoteEditor from '../components/FleetQuoteEditor.vue'
import FleetQuoteImport from '../components/FleetQuoteImport.vue'
import { listFleetQuotes, setFleetQuoteStatus, deleteFleetQuote, exportFleetQuotes, saveFleetCsv, type FleetQuote, type FleetQuoteKind, type FleetQuoteQuery } from '../api/fleetQuotes'
import { listFleetQuotes, setFleetQuoteStatus, deleteFleetQuote, type FleetQuote, type FleetQuoteKind, type FleetQuoteQuery } from '../api/fleetQuotes'
const submitExport = useExportTask()
const access = useAccessPreviewStore(), rows = ref<FleetQuote[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), busy = ref(false)
const kind = ref<FleetQuoteKind>('PURCHASE'), editor = ref(false), importer = ref(false), selected = ref<FleetQuote | null>(null), readonly = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ keyword: '', status: '', effectiveOn: '' }); let applied: FleetQuoteQuery = {}, sequence = 0
@@ -34,7 +36,7 @@ async function action(key: string, quoteNo: unknown) {
busy.value = true
try { if (key === 'delete') await deleteFleetQuote(row); else await setFleetQuoteStatus(row, row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'); ElMessage.success('报价已更新'); await load() } catch (e) { error.value = getApiErrorDetails(e, '报价操作失败') } finally { busy.value = false }
}
async function download() { busy.value = true; try { saveFleetCsv(await exportFleetQuotes({ ...applied, kind: kind.value }), '车队报价.csv') } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { busy.value = false } }
async function download() { busy.value = true; try { await submitExport('FLEET_QUOTE_LIST', '车队报价', { ...applied, kind: kind.value }) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { busy.value = false } }
watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout>
@@ -42,7 +44,7 @@ watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); w
<LoadErrorState v-if="error" title="报价操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="车队报价查询" @search="search" @reset="search" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="quoteNo" empty-text="暂无报价">
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:quote:create')" type="primary" @click="open(null)">新增报价</el-button><el-button v-if="access.can('platform:fleet:quote:import')" @click="importer = true">批量导入</el-button><el-button v-if="access.can('platform:fleet:quote:export')" :loading="busy" @click="download">导出CSV</el-button></template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:quote:create')" type="primary" @click="open(null)">新增报价</el-button><el-button v-if="access.can('platform:fleet:quote:import')" @click="importer = true">批量导入</el-button><el-button v-if="access.can('platform:fleet:quote:export') && access.can('platform:download:list')" :loading="busy" @click="download">导出</el-button></template>
<template #actions="{ row }"><RowActions :actions="actions(row.quoteNo)" @action="action($event,row.quoteNo)" /></template>
<template #origin="{ row }">{{ row.details.originCity }}</template><template #destination="{ row }">{{ row.details.destinationCode }}</template><template #miles="{ row }">{{ row.details.distanceMiles }}</template><template #currency="{ row }">{{ row.details.currency }}</template>
<template #pickup="{ row }">{{ row.details.selfPickupPrice ?? '—' }}</template><template #full="{ row }">{{ row.details.fullDeliveryPrice ?? '—' }}</template><template #loose="{ row }">{{ row.details.looseDeliveryPrice ?? '—' }}</template>
@@ -55,9 +55,9 @@ describe('overseas master data real contract pages', () => {
expect(view).toContain('access.can(permission.')
expect(view).toContain('ElMessage.success')
}
expect(source('OverseasQuotesView')).toContain('导出CSV')
expect(source('VehiclesView')).toContain('导出CSV')
expect(source('ExchangeRatesView')).toContain("'monthly-exchange-rates.csv'")
expect(source('OverseasQuotesView')).toContain('导出')
expect(source('VehiclesView')).toContain('导出')
expect(source('ExchangeRatesView')).toContain("submitExport('MONTHLY_EXCHANGE_RATE_LIST'")
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
expect(source(name)).not.toMatch(/deleteQuote|deleteExchangeRate|deleteVehicle/)
}
@@ -66,7 +66,7 @@ describe('overseas master data real contract pages', () => {
expect(source('VehiclesView')).toContain('changeVehicleStatus(row.id,statusTarget.value,row.recordVersion)')
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
expect(source(name)).toContain("masterDataErrorMessage(")
expect(source(name)).toContain('saveCsv(await export')
expect(source(name)).toContain('await submitExport(')
}
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Plus } from '@lucide/vue'
@@ -11,12 +12,12 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeQuoteStatus, createQuote, exportQuotes, getQuote, listQuotes, listQuoteWarehouseOptions, updateQuote } from '../api/overseasMasterData'
import { changeQuoteStatus, createQuote, getQuote, listQuotes, listQuoteWarehouseOptions, updateQuote } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { saveCsv } from '@/shared/downloadCsv'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption } from '../types'
const submitExport = useExportTask()
const access = useAccessPreviewStore()
const rows = ref<OverseasWarehouseQuote[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), options = ref<PartnerWarehouseOption[]>([]), detail = ref<OverseasWarehouseQuoteDetail | null>(null)
@@ -50,7 +51,7 @@ async function openCreate(){editingId.value='';resetForm();if(!options.value.len
async function openEdit(row:OverseasWarehouseQuote){try{const d=await getQuote(row.id);editingId.value=row.id;fill(d);editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))}}
async function save(){if(!form.name||!form.warehouseId||!form.providerName||!form.effectiveStart||!form.effectiveEnd||!form.warehouseFees.length){ElMessage.warning('请完整填写报价必填信息');return} if(form.effectiveEnd<form.effectiveStart){ElMessage.warning('失效日期不能早于生效日期');return} saving.value=true;try{const common={name:form.name,providerName:form.providerName,currency:form.currency as 'USD'|'CNY',effectiveStart:form.effectiveStart,effectiveEnd:form.effectiveEnd,warehouseFees:form.warehouseFees,transitPrices:form.transitPrices,remark:form.remark||null};if(editingId.value)await updateQuote(editingId.value,{...common,recordVersion:form.recordVersion});else await createQuote({...common,warehouseId:form.warehouseId,versionCode:form.versionCode});ElMessage.success(editingId.value?'报价已更新':'报价已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价保存失败'))}finally{saving.value=false}}
async function toggle(row:OverseasWarehouseQuote){const next:MasterDataStatus=row.status==='ACTIVE'?'INACTIVE':'ACTIVE';try{await ElMessageBox.confirm(`确认${next==='ACTIVE'?'启用':'停用'}“${row.name}”吗?`,'报价状态',{customClass:'admin-density-confirm',type:'warning'});await changeQuoteStatus(row.id,next,row.recordVersion);ElMessage.success('报价状态已更新');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(masterDataErrorMessage(e,'报价状态更新失败'))}}
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();saveCsv(await exportQuotes(q),'overseas-quotes.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();await submitExport('OVERSEAS_QUOTE_LIST', '海外报价', q)}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓报价列表">
@@ -62,7 +63,7 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
<template #notices><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出</el-button></template>
<template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="openDetail(row as OverseasWarehouseQuote)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.quoteEdit)" @click="openEdit(row as OverseasWarehouseQuote)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.quoteStatus)" :data-tone="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></div></template>
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
</DataTable></section>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -10,11 +11,11 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listTripOperations, exportTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
import { saveFleetCsv } from '../api/fleetQuotes'
import { listTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
import TripOperationsFinanceCell from '../components/TripOperationsFinanceCell.vue'
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
import TripOperationsRecordDialog from '../components/TripOperationsRecordDialog.vue'
const submitExport = useExportTask()
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
@@ -41,7 +42,7 @@ function range(): TripOperationsQuery {
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
async function download() { exporting.value = true; try { saveFleetCsv(await exportTripOperations(applied), '车次运营.csv') } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
@@ -61,7 +62,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
</div>
</template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export')" :loading="exporting" @click="download">导出CSV</el-button></template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></div></template>
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
<template #distance="{ row }">{{ row.measured?.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row }">{{ row.measured?.actualFuelLitres ?? '—' }}</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { vehicleStatusLabels } from '../presentation'
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
@@ -7,8 +8,9 @@ import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeVehicleStatus,createVehicle,exportVehicles,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import { saveCsv } from '@/shared/downloadCsv'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
import { changeVehicleStatus,createVehicle,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
import { masterDataErrorMessage } from '../apiError'
const submitExport = useExportTask()
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','DISPATCHED']]
const filters=ref<QueryModel>({keyword:'',region:'',status:''});const fields:QueryField[]=[{key:'keyword',type:'input',label:'车辆关键词',placeholder:'车牌 / 司机 / 车型',width:220},{key:'region',type:'select',label:'区域',placeholder:'全部区域',width:140,options:[{label:'国内',value:'DOMESTIC'},{label:'海外',value:'OVERSEAS'}]},{key:'status',type:'select',label:'状态',placeholder:'全部状态',width:140,options:Object.entries(vehicleStatusLabels).map(([value,label])=>({label,value}))}]
@@ -32,7 +34,7 @@ function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer1
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km==null||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}const capacity=[form.maxPalletSpaces,form.maxLoadKg,form.maxVolumeCbm];if(capacity.some(v=>v!=null)&&capacity.some(v=>v==null||v<=0)){ElMessage.warning('托位、载重和容积须同时填写正数,或全部留空');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null,maxPalletSpaces:form.maxPalletSpaces??null,maxLoadKg:form.maxLoadKg??null,maxVolumeCbm:form.maxVolumeCbm??null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();await submitExport('VEHICLE_LIST', '车辆', q)}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load)
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
@@ -40,6 +42,6 @@ watch([page,pageSize],load);onMounted(load)
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
<template #notices><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template>
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(1080px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="最大标准托位"><el-input-number v-model="form.maxPalletSpaces" :min="1" :max="10000" :precision="0" placeholder="请输入实际托位" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="form.maxLoadKg" :min="0.001" :precision="3" placeholder="请输入核定载重" /></el-form-item><el-form-item label="可用容积(m³)"><el-input-number v-model="form.maxVolumeCbm" :min="0.001" :precision="3" placeholder="请输入实际容积" /></el-form-item><p class="capacity-hint">三项容量须同时确认;未知时可全部留空,确认后才能安排出车。</p><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}.capacity-hint{grid-column:1/-1;margin:0;color:var(--cc-muted)}</style>
+2
View File
@@ -48,3 +48,5 @@
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
- 尾程订单、库存余额与进销存记录导出统一使用 `../downloads/` 的 `useExportTask` 创建 XLSX 任务;保留当前筛选和业务导出权限,并要求 `platform:download:list`。Lastleg 内部 Provider 契约为 `lastleg-exports-v1.yaml`,业务页面不再请求同步 CSV。
@@ -1,27 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInventoryBalances, downloadInventoryLedger, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
describe('overseas inventory API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('returns the binary response directly for inventory exports', async () => {
const balances = new Blob(['customerId\\r\\n10001\\r\\n'], { type: 'text/csv' })
const ledger = new Blob(['occurredAt\\r\\n'], { type: 'text/csv' })
vi.mocked(http.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger)
await expect(downloadInventoryBalances({ skuCode: 'SKU-1' })).resolves.toBe(balances)
await expect(downloadInventoryLedger({ inboundCode: 'IN-1' })).resolves.toBe(ledger)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', {
params: { skuCode: 'SKU-1' }, responseType: 'blob',
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', {
params: { inboundCode: 'IN-1' }, responseType: 'blob',
})
})
it('keeps skuCode and nullable location filters on real balance queries', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } })
@@ -117,9 +117,6 @@ export async function listInventoryBalances(query: InventoryBalanceQuery) {
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function downloadInventoryBalances(query: Partial<InventoryBalanceQuery>) {
return http.get<Blob, Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
}
export async function getInventorySummary(query: InventoryBalanceQuery) {
const response = await http.get<ApiEnvelope<InventoryDashboardDto>, ApiEnvelope<InventoryDashboardDto>>(
@@ -165,7 +162,3 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) {
)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function downloadInventoryLedger(query: Partial<InventoryLedgerQuery>) {
return http.get<Blob, Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
}
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { reviewCargoDiscard } from './lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
@@ -60,37 +60,8 @@ describe('last-mile order overseas API', () => {
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] }))
})
it('returns the exported CSV blob without reading data twice', async () => {
const blob = new Blob(['orders'], { type: 'text/csv' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(downloadLastMileOrders({ orderNo: 'LM-1' })).resolves.toBe(blob)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', {
params: { orderNo: 'LM-1' }, responseType: 'blob',
})
})
it('exports all authorized orders without pagination or empty filters', async () => {
const blob = new Blob(['orders'], { type: 'text/csv' })
vi.mocked(http.get).mockResolvedValue(blob)
await downloadLastMileOrders({ page: 1, size: 10, orderNo: '', createdFrom: undefined })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', {
params: {}, responseType: 'blob',
})
})
it('rejects a JSON business error instead of saving it as CSV', async () => {
const blob = new Blob([JSON.stringify({
code: 'S0500', message: '系统内部错误', data: null, requestId: 'req-1', traceId: 'trace-1',
})], { type: 'application/json' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(downloadLastMileOrders({})).rejects.toMatchObject({
code: 'S0500', message: '系统内部错误', requestId: 'req-1', traceId: 'trace-1',
})
})
it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
@@ -1,4 +1,4 @@
import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { InboundPlanDetailDto } from './inboundPlans'
@@ -394,28 +394,6 @@ export async function reviewDirectRelabelOrder(orderId: string, customerId: stri
return unwrapApiResponse(response)
}
export async function downloadLastMileOrders(query: Partial<LastMileOrderQuery>) {
const { page: _page, size: _size, ...filters } = query
const params = Object.fromEntries(
Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== ''),
)
const blob = await http.get<Blob, Blob>('/platform/v1/last-leg/orders/export', { params, responseType: 'blob' })
if (!blob.type.toLowerCase().includes('json')) return blob
let error: Partial<ApiEnvelope<unknown>>
try {
error = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
} catch {
throw new ApiResponseError('导出接口返回了非 CSV 内容', 'INVALID_EXPORT_RESPONSE')
}
throw new ApiResponseError(
error.message || '尾程订单导出失败',
error.code || 'INVALID_EXPORT_RESPONSE',
undefined,
error.data,
error.requestId,
error.traceId,
)
}
export async function createLastMileOrderFee(orderId: string, payload: CreateLastMileFeePayload) {
const response = await http.post<ApiEnvelope<LastMileFeeDto>, ApiEnvelope<LastMileFeeDto>>(
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { ArrowLeft, Download } from '@lucide/vue'
@@ -8,9 +9,9 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
import { saveCsv } from '@/shared/downloadCsv'
import { downloadInventoryLedger, listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime, inventoryBusinessTypeLabel } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types'
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime, inventoryBusinessTypeLabel } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types'
const submitExport = useExportTask()
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const exportLoading = ref(false)
const relationshipDialogOpen = ref(false); const relationshipDialogInboundCode = ref('')
@@ -39,7 +40,7 @@ function text(value: unknown) { return String(value ?? '').trim() || '—' }
function openRelationship(row: InventoryTransactionListItem) { if (row.inboundCode === '—') return; relationshipDialogInboundCode.value = row.inboundCode; relationshipDialogOpen.value = true }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerName), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: inventoryBusinessTypeLabel(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundCode: text(item.inboundCode), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: '—', remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; await submitExport('OVERSEAS_INVENTORY_LEDGER', '进销存记录', { customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="进销存记录列表">
@@ -51,7 +52,7 @@ async function exportLedger() { exportLoading.value = true; try { const range =
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { BookOpen, Download } from '@lucide/vue'
@@ -11,14 +12,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { downloadInventoryBalances, getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { saveCsv } from '@/shared/downloadCsv'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { ElMessage } from 'element-plus'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
import { overseasWarehousePermissions } from '../permissions'
import type { InventoryListItem } from '../types'
const submitExport = useExportTask()
const router = useRouter()
const access = useAccessPreviewStore()
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
@@ -82,7 +83,7 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { overseasWarehouseId: row.warehouseId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku === '—' ? undefined : row.sku.split('、')[0] } }) }
async function exportBalances() { exportLoading.value = true; try { const q = query(); const { page: _page, size: _size, ...params } = q; saveCsv(await downloadInventoryBalances(params) as Blob, '库存余额.csv') } catch { ElMessage.error('库存导出失败,请重试') } finally { exportLoading.value = false } }
async function exportBalances() { exportLoading.value = true; try { const q = query(); const { page: _page, size: _size, ...params } = q; await submitExport('OVERSEAS_INVENTORY_BALANCE', '库存余额', params) } catch { ElMessage.error('库存导出失败,请重试') } finally { exportLoading.value = false } }
onMounted(() => { void loadWarehouses(); void load() })
</script>
@@ -97,7 +98,7 @@ onMounted(() => { void loadWarehouses(); void load() })
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport)" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
@@ -1,3 +1,5 @@
const { submitExport } = vi.hoisted(() => ({ submitExport: vi.fn().mockResolvedValue(undefined) }))
vi.mock('@/modules/downloads', () => ({ useExportTask: () => submitExport }))
import ElementPlus, { ElMessageBox, ElMessage } from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
@@ -12,7 +14,6 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import {
changeContainerReceivingWarehouse,
createManualInboundPlan,
downloadLastMileOrders,
convertLastMileContainerToInboundPlans,
getContainerInboundPlanPreview,
getContainerPlanProgress,
@@ -36,7 +37,6 @@ vi.mock('../api/lastMileOrders', () => ({
changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
downloadLastMileOrders: vi.fn(),
reviewCargoDiscard: vi.fn(),
}))
@@ -226,7 +226,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false)
await button(wrapper, '导出订单')!.trigger('click')
await flushPromises()
expect(downloadLastMileOrders).toHaveBeenLastCalledWith(vi.mocked(listLastMileOrders).mock.lastCall![0])
expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0])
}
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '全部')!.trigger('click')
await flushPromises()
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
@@ -15,7 +16,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
@@ -24,9 +25,8 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { saveCsv } from '@/shared/downloadCsv'
const rows = ref<LastMileOrderListItem[]>([])
const submitExport = useExportTask()
const access = useAccessPreviewStore()
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -71,7 +71,7 @@ async function submitReturnPackingPlan() {
}
const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { saveCsv(await downloadLastMileOrders(orderQuery()) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
const actionError = ref<ApiErrorDetails | null>(null)
const actionTarget = ref<LastMileOrderListItem | null>(null)
const inboundPlanOpen = ref(false)
@@ -1093,7 +1093,7 @@ onMounted(() => { void load() })
</div>
</section></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
+42
View File
@@ -500,6 +500,48 @@ describe('approved Admin density ownership', () => {
})
})
it('keeps measurement sections within the clipped panel and gives long references room', () => {
const dialog = parse(read('modules/inbounds/measurement/InboundMeasurementDialog.vue')).descriptor
const layout = read('modules/inbounds/measurement/measurementDensity.css')
const summaryMargins: string[] = []
for (const css of [layout, ...dialog.styles.map(style => style.content)]) {
cssRoot(css).walkRules(rule => {
if (!/\.measurement-(summary-section|declaration|items-section)(?![\w-])/.test(rule.selector)) return
rule.walkDecls(decl => {
if (decl.prop.startsWith('margin')) {
expect(decl.value, `section must not escape the clipped panel: ${rule.selector}`).not.toMatch(/(^|\s)-\d/)
if (rule.selector.endsWith('.measurement-summary-section')) summaryMargins.push(decl.value)
}
})
})
}
expect(summaryMargins).toEqual(['0'])
const sectionStyles = new Map<string, Record<string, string>>()
cssRoot(layout).walkRules(rule => {
const declarations: Record<string, string> = {}
rule.walkDecls(decl => { declarations[decl.prop] = decl.value })
sectionStyles.set(rule.selector, declarations)
})
for (const [section, background] of [
['summary-section', '#f4fbf8'], ['declaration', '#f7f9ff'], ['items-section', '#fffbf4'],
]) {
expect(sectionStyles.get(`.inbound-measurement-workspace-dialog.inbound-density .measurement-${section}`)).toMatchObject({
padding: 'var(--admin-space-4, 10px) var(--admin-space-5, 12px)',
background,
})
}
// Shared density must not reset the coloured sections' horizontal gutters.
expect(sectionStyles.get('.inbound-density.admin-density-dialog .measurement-section')).toEqual({
'padding-block': 'var(--admin-space-4, 10px)',
})
expect(layout).toContain('.measurement-document-number { display: flex; flex: 1;')
expect(layout).toContain('.measurement-platform-fields { display: flex; flex: 1 1 520px;')
expect(layout).toContain('.measurement-platform-fields > .measurement-expected-pallets { flex: 0 0 180px; }')
for (const style of dialog.styles) {
expect(style.content).not.toMatch(/\.measurement-(summary-section|metrics|declaration|items-section)\b/)
}
})
it('keeps desktop table toolbar dimensions owned by DataTable without cascade overrides', () => {
const tableCss = parse(read('shared/components/DataTable.vue')).descriptor.styles.map(style => style.content).join('\n')
const dimensions: Record<string, string[]> = { padding: [], 'min-height': [] }