Merge pull request '发布已通过 TEST 回归的 2026-10-08 批次至灰度' (#16) from release/test into main

This commit was merged in pull request #16.
This commit is contained in:
2026-10-08 22:17:40 +08:00
72 changed files with 2289 additions and 634 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,列表下方展开只读明细并提供打印;货柜列表经 `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/` |
+2 -2
View File
@@ -21,7 +21,7 @@ describe('adminNavigation', () => {
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '客户服务', children: ['异常订单'] },
@@ -30,7 +30,7 @@ describe('adminNavigation', () => {
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(11)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(48)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
+3 -2
View File
@@ -107,14 +107,15 @@ export const adminNavigation: AdminNavigationNode[] = [
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' },
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list', hidden: true },
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list', hidden: true },
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
],
},
{
@@ -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>
@@ -2,8 +2,8 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LoadingListsView from './LoadingListsView.vue'
const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: mocks.push, replace: vi.fn() }) }))
const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: vi.fn(), replace: vi.fn(), query: {} as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push, replace: mocks.replace }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '<div />' } }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) }))
@@ -20,27 +20,32 @@ function render() {
ElButton: { template: '<button><slot /></button>' },
} } })
}
describe('confirmed manifest inline detail', () => {
beforeEach(() => { vi.clearAllMocks(); mocks.can.mockReturnValue(true); mocks.list.mockResolvedValue({ records: rows, total: 2 }) })
it('ignores an older detail response when another manifest is selected', async () => {
let resolveFirst!: (value: unknown) => void
mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
.mockResolvedValueOnce({ summary: { status: 'COMPLETED', planId: '2', loadingListNo: 'LIST-2' } })
describe('confirmed manifest navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.query = {}
mocks.can.mockReturnValue(true)
mocks.list.mockResolvedValue({ records: rows, total: 2 })
})
it('opens each manifest on its dedicated detail route with warehouse context', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button')[0]!.trigger('click')
await wrapper.findAll('button')[2]!.trigger('click'); await flushPromises()
resolveFirst({ summary: { status: 'COMPLETED', planId: '1', loadingListNo: 'LIST-1' } }); await flushPromises()
expect(wrapper.get('[data-testid="detail"]').text()).toBe('LIST-2')
expect(mocks.get).toHaveBeenNthCalledWith(2, '2', '22')
expect(mocks.push).not.toHaveBeenCalled()
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
await wrapper.findAll('button')[2]!.trigger('click')
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } })
expect(mocks.get).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="detail"]').exists()).toBe(false)
})
it('does not print when loading fails', async () => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
it('prints through the dedicated detail page', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button')[1]!.trigger('click'); await flushPromises()
expect(wrapper.get('[data-testid="detail"]').text()).toContain('清单不可用')
expect(print).not.toHaveBeenCalled(); print.mockRestore()
await wrapper.findAll('button')[1]!.trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11', print: '1' } })
expect(mocks.get).not.toHaveBeenCalled()
})
it('replaces a list deep link with the dedicated detail route', async () => {
mocks.query = { planId: '2', warehouseId: '22' }
render(); await flushPromises()
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } })
})
it('does not fetch manifests without list permission', async () => {
mocks.can.mockReturnValue(false)
@@ -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', () => {
@@ -59,15 +60,15 @@ describe('LoadingListsView', () => {
it('keeps preload routes while completed lists move into domestic container detail', () => {
expect(source).toContain("name: 'preload-plan-detail'")
expect(source).toContain('loadConfirmedDetail')
expect(source).toContain('confirmed-inline-detail')
expect(source).toContain("name: 'loading-list-detail'")
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).not.toContain("path: 'loading-lists/:planId'")
expect(routesSource).not.toContain("name: 'loading-list-detail'")
expect(routesSource).toContain("path: 'loading-lists/:planId'")
expect(routesSource).toContain("name: 'loading-list-detail'")
})
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
@@ -111,11 +112,11 @@ describe('LoadingListsView', () => {
expect(source).toContain('useRoute()')
expect(source).toContain('route.query.planId')
expect(source).toContain('route.query.warehouseId')
expect(source).toContain('await loadConfirmedDetail(planId, warehouseId)')
expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'")
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
expect(source).toContain("await router.replace({ name: 'loading-list-detail'")
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,7 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ContainerSimulationCreate } from '@/modules/container-loading'
@@ -19,8 +18,6 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { loadingListStatusLabel } from '../status'
import {
listConfirmedLoadingLists,
getConfirmedLoadingList,
type LoadingDetail,
listPreloadPlans,
type ConfirmedSummary,
type LoadingListMode,
@@ -43,44 +40,8 @@ const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
const handledDeepLink = ref('')
const selectedPlan = ref<{ planId: string; warehouseId: string } | null>(null)
const confirmedDetail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
let detailSequence = 0
function closeConfirmedDetail() {
++detailSequence
selectedPlan.value = null
confirmedDetail.value = null
detailError.value = null
detailLoading.value = false
}
async function loadConfirmedDetail(planId: string, warehouseId: string) {
const sequence = ++detailSequence
selectedPlan.value = { planId, warehouseId }
confirmedDetail.value = null
detailError.value = null
detailLoading.value = true
try {
if (!warehouseId) throw new Error('该清单缺少所属仓库信息,无法查询详情')
const result = await getConfirmedLoadingList(planId, warehouseId)
if (sequence !== detailSequence) return
if (result.summary.status !== 'COMPLETED') throw new Error('该计划尚未形成正式装柜清单')
confirmedDetail.value = result
} catch (cause) {
if (sequence === detailSequence) detailError.value = getApiErrorDetails(cause, '装柜清单明细加载失败')
} finally {
if (sequence === detailSequence) detailLoading.value = false
}
}
async function printConfirmed(row: LoadingListSummary) {
await loadConfirmedDetail(String(row.planId), String(row.warehouseId || ''))
if (!confirmedDetail.value || confirmedDetail.value.summary.planId !== String(row.planId)) return
await nextTick()
window.print()
function printConfirmed(row: LoadingListSummary) {
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || ''), print: '1' } })
}
const isPreload = computed(() => props.mode === 'preload')
@@ -180,7 +141,7 @@ async function openDeepLinkedDetail() {
await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } })
return
}
await loadConfirmedDetail(planId, warehouseId)
await router.replace({ name: 'loading-list-detail', params: { planId }, query: { warehouseId } })
}
function onSimulationCreated() {
@@ -200,7 +161,7 @@ function openDetail(rowValue: object) {
void router.push({ name: 'preload-plan-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
return
}
void loadConfirmedDetail(String(row.planId), String(row.warehouseId || ''))
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
}
async function loadCustomers() {
@@ -265,7 +226,7 @@ onMounted(() => {
</template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
<el-button v-if="canView && !isPreload" class="density-action" size="small" :disabled="detailLoading" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
<el-button v-if="canView && !isPreload" class="density-action" size="small" @click="printConfirmed(row as LoadingListSummary)">打印</el-button>
</div></template>
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
@@ -276,10 +237,6 @@ onMounted(() => {
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable></section>
</ListPageLayout>
<section v-if="!isPreload && canView && selectedPlan" class="confirmed-inline-detail" aria-label="装柜清单明细">
<h2>装柜清单明细 {{ confirmedDetail?.summary.loadingListNo || '' }}</h2>
<LoadingListDetailDrawer dense embedded mode="confirmed" :loading="detailLoading" :detail="confirmedDetail" :error="detailError" :can-edit="false" @back="closeConfirmedDetail" @retry="loadConfirmedDetail(selectedPlan.planId, selectedPlan.warehouseId)" />
</section>
</div>
</AdminPage>
@@ -287,7 +244,6 @@ onMounted(() => {
<style scoped>
.loading-lists-page { display: grid; min-width: 0; gap: var(--admin-space-6, 16px); }
@media print { .loading-lists-page :deep(.list-page-layout) { display: none; } }
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-2, 6px); }
.row-actions :deep(.el-button) { margin-left: 0; }
+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'
@@ -0,0 +1,65 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
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, getPreloadPlan: vi.fn() }))
vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) }))
function render() {
return shallowMount(ConfirmedLoadingListDetailView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<section />' },
} } })
}
const completed = (planId = '1') => ({ summary: { status: 'COMPLETED', planId, loadingListNo: `LIST-${planId}` }, items: [], histories: [] })
describe('confirmed loading list detail page', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.route = reactive({ params: { planId: '1' }, query: { warehouseId: '11' } })
mocks.get.mockResolvedValue(completed())
vi.spyOn(window, 'print').mockImplementation(() => {})
})
afterEach(() => vi.restoreAllMocks())
it('loads read-only detail on the dedicated page and returns to the list', async () => {
const wrapper = render(); await flushPromises()
expect(mocks.get).toHaveBeenCalledWith('1', '11')
const detail = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(detail.props('detail')).toEqual(completed())
expect(window.print).not.toHaveBeenCalled()
vi.spyOn(window.history, 'length', 'get').mockReturnValue(1)
detail.vm.$emit('back')
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-lists' })
wrapper.unmount()
})
it('prints only after detail has loaded and removes the print request', async () => {
mocks.route.query.print = '1'
const wrapper = render(); await flushPromises()
expect(window.print).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false)
expect(mocks.replace).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
wrapper.unmount()
})
it('keeps failure visible and does not print when loading fails', async () => {
mocks.route.query.print = '1'
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error').message).toBe('清单不可用')
expect(window.print).not.toHaveBeenCalled()
wrapper.unmount()
})
it('ignores a stale response after switching manifests', async () => {
let resolveFirst!: (value: unknown) => void
mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
.mockResolvedValueOnce(completed('2'))
const wrapper = render()
mocks.route.params.planId = '2'
await flushPromises()
resolveFirst(completed()); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail')).toEqual(completed('2'))
wrapper.unmount()
})
})
@@ -1,49 +1,23 @@
<script setup lang="ts">
import { computed, 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
} 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() {
@@ -58,7 +32,7 @@ watch([planId, warehouseId], () => void loadDetail())
<template>
<AdminPage>
<div class="confirmed-loading-detail-page">
<LoadingListDetailDrawer
<LoadingListDetailDrawer dense
mode="confirmed"
:loading="loading"
:detail="detail"
@@ -0,0 +1,6 @@
import { nextTick } from 'vue'
export async function printLoadingList() {
await nextTick()
window.print()
}
+1
View File
@@ -4,6 +4,7 @@ import { loadingListRoutes } from './routes'
describe('loading list routes', () => {
it('registers the confirmed list with its own read permission', () => {
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
['loading-lists', 'platform:firstleg:loading-list:list'],
['loading-plans', 'platform:firstleg:preload:list'],
['loading-plans/:planId', 'platform:firstleg:preload:list'],
+6
View File
@@ -1,6 +1,12 @@
import type { RouteRecordRaw } from 'vue-router'
export const loadingListRoutes = [
{
path: 'loading-lists/:planId',
name: 'loading-list-detail',
component: () => import('./pages/ConfirmedLoadingListDetailView.vue'),
meta: { title: '装柜清单详情', showPageHeading: false, permission: 'platform:firstleg:loading-list:list' },
},
{
path: 'loading-lists',
name: 'loading-lists',
@@ -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 }
@@ -22,7 +22,7 @@ export interface CapacityInquiry {
actions: { canEdit: boolean; canConfirm: boolean; canCancel: boolean; blockReason: string | null }
}
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage<CapacityInquiry> }
export interface OfferQuery { page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
export interface OfferQuery { scope?: 'AVAILABLE' | 'ALL'; page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' }
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
@@ -23,7 +23,7 @@ const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, co
async function load() {
if (!writable.value || !open.value || pending.value) return
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
try { const value = await listCapacityOffers({ tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
try { const value = await listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
finally { if (current === generation) loading.value = false }
}
@@ -18,7 +18,8 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null)
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false)
let sequence = 0, inquirySequence = 0, resetting = false
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
const hasCreatePermission = computed(() => (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && hasCreatePermission.value)
const columns: DataTableColumn[] = [{ key: 'fromName', label: '起始站', minWidth: 150 }, { key: 'toName', label: '到达站', minWidth: 150 }, { key: 'planned', label: '已关联货物计划占用', minWidth: 260, slotName: 'planned' }, { key: 'reserved', label: '未关联外接预留', minWidth: 260, slotName: 'reserved' }, { key: 'remaining', label: '可接托位 / 重量 / 体积', minWidth: 270, slotName: 'remaining' }]
async function load() {
const current = ++sequence; inquirySequence++; loading.value = true; error.value = null
@@ -58,11 +59,12 @@ watch([page, size], loadInquiries)
onBeforeUnmount(() => { sequence++; inquirySequence++ })
</script>
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车需求池</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="canCreate" type="primary" :disabled="loading" @click="create">登记意向</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车需求池</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记意向</el-button></div></div></template>
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>交货截止</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
<p>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记意向:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<h2 class="capacity-section-title">分段运力</h2><DataTable :data="detail.offer.segments" :columns="columns" :stripe="false" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable>
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的意向' }}</h2>
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
@@ -108,7 +108,7 @@ it('loads publishing settings only when explicitly opened, not with the trip pag
state.manage = true
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
expect(api.listCapacityOffers).not.toHaveBeenCalled(); await (wrapper as VueWrapper<Record<string, unknown>>).setProps({ modelValue: true }); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ tripNo: 'TR-1', page: 1, size: 1 })
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ scope: 'ALL', tripNo: 'TR-1', page: 1, size: 1 })
})
it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => {
state.manage = true
@@ -121,3 +121,24 @@ it('retries an uncertain dispatch confirmation with the same command and no seco
expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
expect(api.listCapacityInquiries).toHaveBeenCalledOnce()
})
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(button(wrapper, '登记意向').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('无法登记意向:交货截止已过')
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
state.write = false
const readonly = mount(CapacityOfferDetailView); await flushPromises()
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记意向')).toBe(false)
})
it('defaults administrators to available offers and requires an explicit history filter', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
const query = wrapper.findComponent({ name: 'QueryToolbar' })
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
query.vm.$emit('update:modelValue', {}); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
})
@@ -26,7 +26,7 @@ const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
...(manage.value ? [{ key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
] : [
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
@@ -45,7 +45,7 @@ async function load() {
try {
const q = applied.value
if (tab.value === 'offers') {
const query: OfferQuery = { page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
const query: OfferQuery = { scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'AVAILABLE', page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
} else {
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
@@ -83,9 +83,14 @@ async function recheckEditor() {
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车需求池">
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" :aria-selected="tab === 'offers'" :class="{ 'is-active': tab === 'offers' }" @click="switchTab('offers')">可接运力</button><button role="tab" :aria-selected="tab === 'inquiries'" :class="{ 'is-active': tab === 'inquiries' }" @click="switchTab('inquiries')">我的意向</button></div>
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
<template #toolbar-start><h1>拼车需求池</h1></template><template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="可接运力查询" :loading="loading" @search="search" /></template>
<template #toolbar-start><h1>拼车需求池</h1></template>
<template #filters>
<div class="integrated-query-group">
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的意向</button></div>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可接运力查询" :loading="loading" @search="search" />
</div>
</template>
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
@@ -96,7 +101,13 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequenc
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
</DataTable>
<CapacityInquiryTable v-else integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
<template #toolbar-start><h1>{{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}</h1></template><template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" /></template>
<template #toolbar-start><h1>{{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}</h1></template>
<template #filters>
<div class="integrated-query-group">
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的意向</button></div>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
</div>
</template>
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
</CapacityInquiryTable>
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="recheckEditor" />
@@ -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>
@@ -63,7 +63,7 @@ function accept(value: TripDetail) {
}
async function load() {
const current = ++generation; inspectionSequence++; historySequence++
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = 'overview'
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
@@ -177,6 +177,7 @@ async function loadHistory() {
finally { if (current === historySequence) historyLoading.value = false }
}
watch(() => route.params.tripNo, load, { immediate: true })
watch(() => route.query.tab, tab => { activeTab.value = tab === 'loading' ? 'loading' : 'overview' })
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
@@ -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,3 +1,4 @@
const tripQuery = vi.hoisted(() => ({ tab: '' }))
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -9,7 +10,7 @@ import * as api from '../api/trips'
import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes'
const state = vi.hoisted(() => ({ writable: true, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' } }), useRouter: () => ({ push: state.push }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' }, query: tripQuery }), useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || state.writable }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '请核对预约时间' }) } }))
vi.mock('../api/drivers', () => ({ listDriverWarehouses: vi.fn().mockResolvedValue([{ id: '92', name: '海外仓', code: 'WH-US' }]) }))
@@ -39,7 +40,7 @@ function mount(component: object, props: Record<string, unknown> = {}): VueWrapp
}
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
beforeEach(() => {
vi.clearAllMocks(); state.writable = true
vi.clearAllMocks(); state.writable = true; tripQuery.tab = ''
vi.mocked(api.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
@@ -166,3 +167,12 @@ it('refreshes trip members and loading review after external dispatch binding wi
expect(api.getTrip).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('bookings')
})
it('opens the loading review section from the dispatch list deep link', async () => {
tripQuery.tab = 'loading'
const wrapper = mount(TripDetailView)
await flushPromises()
expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('loading')
expect(wrapper.findComponent({ name: 'TripLoadingPanel' }).exists()).toBe(true)
expect(wrapper.text()).toContain('理货审核')
})
@@ -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。
@@ -0,0 +1,26 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardDetail { summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
export async function listCargoDiscards(query: CargoDiscardQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<CargoDiscardSummary>>, ApiEnvelope<OverseasPagePayload<CargoDiscardSummary>>>('/platform/v1/last-leg/cargo-discards', { params: query, headers: query.customerId ? { 'X-Customer-Id': query.customerId } : undefined })
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getCargoDiscard(discardNo: string, customerId?: string) {
const response = await http.get<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}`, { params: customerId ? { customerId } : undefined })
return unwrapApiResponse(response)
}
export async function reviewCargoDiscard(discardNo: string, decision: 'APPROVE' | 'REJECT', remark?: string, customerId?: string) {
const response = await http.post<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`, { decision, remark }, { params: customerId ? { customerId } : undefined })
return unwrapApiResponse(response)
}
export async function completeCargoDiscard(discardNo: string, customerId?: string) {
const response = await http.post<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/complete`, undefined, { params: customerId ? { customerId } : undefined })
return unwrapApiResponse(response)
}
@@ -9,8 +9,12 @@ export interface DispatchQuery {
vehiclePlate?: string
status?: string
fbaAppointmentNo?: string
stage?: 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'
}
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
export interface DispatchPalletCandidateDto {
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
returnReceiptId?: string | null
@@ -86,6 +90,7 @@ export interface PreArrangeDispatchDraftResult {
}
export interface DispatchSummaryDto {
workflow?: DispatchWorkflow | null
id: string
dispatchNo: string
vehiclePlate?: string | null
@@ -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'
@@ -27,6 +27,7 @@ export interface LastMileOrderQuery {
orderNo?: string
relabelNo?: string
orderType?: string
sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE'
inboundCode?: string
lxCode?: string
containerNo?: string
@@ -393,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>>(
@@ -98,7 +98,7 @@ export interface RelabelItemDto {
relabelFileUrl?: string | null
shipmentNo?: string | null
trackingCode?: string | null
remark?: string | null
remark?: string
cartonMark?: string | null
fbaCartonMark?: string | null
destinationWarehouseId?: string | null
@@ -109,7 +109,7 @@ export interface RelabelHistoryDto {
fromStatus?: string | null
toStatus?: string | null
operatorId?: string | null
remark?: string | null
remark?: string
createdAt?: string | null
}
@@ -137,16 +137,24 @@ export interface ReturnPackingPlanBox {
export interface ReturnPackingPlanPayload {
returnNos: string[]
mode: 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
mode: 'BOX' | 'SKU' | 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
boxes: ReturnPackingPlanBox[]
remark?: string
idempotencyKey: string
sourceCartonMarks?: string[]
}
export interface ReturnPackingPlanDto extends ReturnPackingPlanPayload {
export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> {
planNo: string
customerId?: string
overseasWarehouseId?: string
status: string
boxes: ReturnPackingPlanBox[]
remark?: string | null
createdAt?: string | null
pushedAt?: string | null
completedAt?: string | null
resultCartons?: import('./returnInventory').ReturnCartonDetail[]
}
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
@@ -156,6 +164,21 @@ export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload)
return unwrapApiResponse(response) as ReturnPackingPlanDto
}
export interface ReturnPackingPlanQuery { page: number; size: number; status?: string; planNo?: string; customerId?: string; overseasWarehouseId?: string; skuCode?: string }
export async function listReturnPackingPlans(query: ReturnPackingPlanQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnPackingPlanDto>>, ApiEnvelope<OverseasPagePayload<ReturnPackingPlanDto>>>('/platform/v1/last-leg/return-packing-plans', { params: query })
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function reviewReturnPackingPlan(planNo: string) {
const response = await http.post<ApiEnvelope<ReturnPackingPlanDto>, ApiEnvelope<ReturnPackingPlanDto>>(`/platform/v1/last-leg/return-packing-plans/${encodeURIComponent(planNo)}/review`)
return unwrapApiResponse(response) as ReturnPackingPlanDto
}
export async function cancelReturnPackingPlan(planNo: string) {
const response = await http.post<ApiEnvelope<ReturnPackingPlanDto>, ApiEnvelope<ReturnPackingPlanDto>>(`/platform/v1/last-leg/return-packing-plans/${encodeURIComponent(planNo)}/cancel`)
return unwrapApiResponse(response) as ReturnPackingPlanDto
}
export async function listRelabelOrders(query: RelabelOrderQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
'/platform/v1/last-leg/relabel-applications', { params: query },
@@ -2,14 +2,17 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
export interface ReturnPackingSource { cartonMark: string; sourceReturnNo?: string; sourceUnitType?: string; items: ReturnCartonDetail['items'] }
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string }
export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnStockRow>>, ApiEnvelope<OverseasPagePayload<ReturnStockRow>>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnPackingSources(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnPackingSource>>, ApiEnvelope<OverseasPagePayload<ReturnPackingSource>>>('/platform/v1/last-leg/return-inventory/packing-sources', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnCartons(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnCartonRow>>, ApiEnvelope<OverseasPagePayload<ReturnCartonRow>>>('/platform/v1/last-leg/return-inventory/cartons', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnInventoryMovements(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnInventoryMovement>>, ApiEnvelope<OverseasPagePayload<ReturnInventoryMovement>>>('/platform/v1/last-leg/return-inventory/movements', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function getReturnCarton(cartonMark: string) { const response = await http.get<ApiEnvelope<ReturnCartonDetail>, ApiEnvelope<ReturnCartonDetail>>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}`); return unwrapApiResponse(response) }
@@ -0,0 +1,32 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.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 StatusBadge from '@/shared/components/StatusBadge.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: 'PDA处理中', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '客户已确认', REJECTED: '已驳回', CANCELLED: '已取消' }
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } }
onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left>共 {{ total }} 条废弃单</template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
</template>
@@ -10,6 +10,8 @@ import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispa
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import DispatchesView from './DispatchesView.vue'
const push = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
@@ -60,6 +62,50 @@ describe('DispatchesView', () => {
})
it('queries stages on the server and discards an older response after switching', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof listDispatches>>) => void
vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await wrapper.get('[role="tablist"]').findAll('button')[2]!.trigger('click')
await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING', page: 1, status: undefined }))
expect(wrapper.text()).toContain('PC-1')
resolveOld({ records: [], total: 0, page: 1, size: 10, pages: 0 })
await flushPromises()
expect(wrapper.text()).toContain('PC-1')
await wrapper.get('[role="tablist"]').findAll('button')[3]!.trigger('click')
await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'AFTER_LOADING', page: 1 }))
})
it('shows review facts and opens the matching trip section with read permission', async () => {
const result = await listDispatches({ page: 1, size: 10 })
result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
canPermission.mockImplementation(code => code === 'platform:fleet:trip:list')
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const sections = wrapper.get('[aria-label="排车业务分区"]')
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
expect(wrapper.findAll('[role="tablist"]')).toHaveLength(2)
expect(wrapper.text()).not.toContain('请核对货量')
await sections.findAll('button')[1]!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('请核对货量')
expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false)
await wrapper.findAll('button').find(button => button.text() === '查看审核')!.trigger('click')
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
await sections.findAll('button')[2]!.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('请核对货量')
expect(wrapper.text()).toContain('待审核')
const links = wrapper.findAll('button').filter(button => button.text() === '查看审核')
await links[0]!.trigger('click')
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'loading' } })
expect(wrapper.text()).not.toContain('前往审核')
expect(listDispatches).toHaveBeenCalledTimes(1)
})
it.each([
[[], ['查看', '打印BOL'], false],
[['edit'], ['查看', '打印BOL', '编辑'], false],
@@ -31,6 +31,31 @@ const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const stage = ref<'' | 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'>('')
const stages = [
{ value: '', label: '全部' }, { value: 'BEFORE_LOADING', label: '装车前' },
{ value: 'LOADING', label: '装车中' }, { value: 'AFTER_LOADING', label: '装车后' },
{ value: 'CANCELLED', label: '已取消' },
] as const
const section = ref<'vehicle' | 'dispatchReview' | 'loadingReview'>('vehicle')
const sections = [
{ value: 'vehicle', label: '车辆信息' },
{ value: 'dispatchReview', label: '调度审核' },
{ value: 'loadingReview', label: '理货员审核' },
] as const
const reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' }
let loadSequence = 0
function switchStage(value: typeof stage.value) {
if (stage.value === value) return
stage.value = value
filters.value.status = ''
page.value = 1
void load()
}
function openTrip(row: DispatchListItem, tab: 'overview' | 'loading') {
if (!row.workflow || !access.can('platform:fleet:trip:list')) return
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.workflow.tripNo }, query: { tab } })
}
const filters = ref<QueryModel>({
dispatchNo: '',
vehiclePlate: '',
@@ -168,6 +193,9 @@ const fields = computed<QueryField[]>(() => [
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 380, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
{ key: 'workflow', label: '关联车次', minWidth: 210, slotName: 'workflow' },
{ key: 'dispatchReview', label: '调度审核', minWidth: 180, slotName: 'dispatchReview', singleLine: false },
{ key: 'loadingReview', label: '理货审核(整车)', minWidth: 180, slotName: 'loadingReview', singleLine: false },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
@@ -228,6 +256,13 @@ const columns: DataTableColumn[] = [
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
]
const visibleColumns = computed(() => {
if (section.value === 'vehicle') return columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key)))
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
return keys.map(key => columns.find(column => column.key === key)!)
})
const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`)
function text(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || '—'
@@ -263,6 +298,7 @@ function appointmentLabel(numberValue: unknown, statusValue: unknown) {
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
return {
workflow: dto.workflow,
id: String(dto.id),
dispatchNo: text(dto.dispatchNo),
vehiclePlate: text(dto.vehiclePlate),
@@ -295,6 +331,7 @@ function dispatchError(error: unknown) {
}
async function load(model: QueryModel = filters.value) {
const sequence = ++loadSequence
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
@@ -302,21 +339,24 @@ async function load(model: QueryModel = filters.value) {
const result = await listDispatches({
page: page.value,
size: pageSize.value,
stage: stage.value || undefined,
dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined,
vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined,
status: String(filters.value.status || '').trim() || undefined,
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
})
if (sequence !== loadSequence) return
dispatchSnapshots.clear()
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
rows.value = result.records.map(toListItem)
total.value = result.total
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
total.value = 0
loadError.value = dispatchError(error)
} finally {
loading.value = false
if (sequence === loadSequence) loading.value = false
}
}
@@ -501,6 +541,13 @@ async function cancel(row: DispatchListItem) {
}
}
function rowActions(row: DispatchListItem): RowActionItem[] {
if (section.value !== 'vehicle') {
const reviewPermission = section.value === 'dispatchReview' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'
return [
{ key: 'review', label: access.can(reviewPermission) ? '前往审核' : '查看审核', priority: 400, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
{ key: 'detail', label: '查看', priority: 300 },
]
}
return [
{ key: 'detail', label: '查看', priority: 300 },
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
@@ -510,7 +557,8 @@ function rowActions(row: DispatchListItem): RowActionItem[] {
]
}
function handleRowAction(command: string, row: DispatchListItem) {
if (command === 'detail') openDetail(row)
if (command === 'review') openTrip(row, section.value === 'dispatchReview' ? 'overview' : 'loading')
else if (command === 'detail') openDetail(row)
else if (command === 'bol') void openBol(row)
else if (command === 'edit') openEdit(row)
else if (command === 'transfer') openTransfer(row)
@@ -589,11 +637,30 @@ onMounted(load)
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="visibleColumns" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>排车列表</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #filters><div class="integrated-query-group">
<div class="density-status-tabs" role="tablist" aria-label="排车执行阶段">
<button v-for="item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }}</button>
</div>
<div class="density-status-tabs" role="tablist" aria-label="排车业务分区">
<button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="section = item.value">{{ item.label }}</button>
</div>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /></template>
<template #workflow="{ row }">
<el-button v-if="row.workflow && access.can('platform:fleet:trip:list')" link type="primary" @click="openTrip(row as DispatchListItem, 'overview')">{{ row.workflow.tripNo }}</el-button>
<span v-else>{{ row.workflow?.tripNo || '未关联车次' }}</span>
</template>
<template v-for="review in (['dispatchReview', 'loadingReview'] as const)" :key="review" #[review]="{ row }">
<template v-if="(row as DispatchListItem).workflow">
<div>{{ reviewLabels[(row as DispatchListItem).workflow![review].status] }}</div>
<div v-if="row.workflow[review].status === 'REJECTED'">{{ row.workflow[review].reason || '—' }}</div>
<div v-if="row.workflow[review].reviewedAt">{{ formatOverseasDateTime(row.workflow[review].reviewedAt) }}</div>
</template>
<span v-else>未关联车次</span>
</template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable>
@@ -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'
@@ -27,6 +29,7 @@ import LastMileOrdersView from './LastMileOrdersView.vue'
const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({
@@ -34,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(),
}))
@@ -140,7 +142,7 @@ async function mountView() {
async function openPending() {
const wrapper = await mountView()
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前')
await pendingTab!.trigger('click')
await flushPromises()
return wrapper
@@ -148,7 +150,7 @@ async function openPending() {
async function openHiddenContainerWorkbench(wrapper: Awaited<ReturnType<typeof mountView>>) {
const view = wrapper.vm as unknown as {
statusTab: '全部' | '待分配' | '柜子'
statusTab: '全部' | '开船前' | '开船后' | '柜子'
changeStatusTab(): void
}
view.statusTab = '柜子'
@@ -187,7 +189,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '待分配'])
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '开船前', '开船后'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
await selectRows(wrapper, [selectedRow()])
@@ -196,7 +198,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('is-active')
expect(document.activeElement).toBe(tabs[1]!.element)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, currentStatus: 'PENDING_ASSIGNMENT' }))
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE', currentStatus: undefined }))
expect((wrapper.vm as unknown as { selectedRows: unknown[] }).selectedRows).toEqual([])
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
await tabs[1]!.trigger('click')
@@ -205,10 +207,31 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await tabs[1]!.trigger('keydown', { key: 'Home' })
await flushPromises()
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: undefined }))
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: undefined, sailingStage: undefined, orderType: undefined }))
await tabs[0]!.trigger('keydown', { key: 'End' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ sailingStage: 'AFTER_DEPARTURE', orderType: 'FIRST_LEG' }))
expect(tabs[2]!.attributes('aria-selected')).toBe('true')
})
it('exports the same stage and filters as the list and restores other types in all', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), orderType: 'RETURN_RELABEL', detained: 'false' })
await wrapper.vm.$nextTick()
for (const [label, stage] of [['开船前', 'BEFORE_DEPARTURE'], ['开船后', 'AFTER_DEPARTURE']] as const) {
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === label)!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'FIRST_LEG', sailingStage: stage, detained: false }))
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false)
await button(wrapper, '导出订单')!.trigger('click')
await flushPromises()
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()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'RETURN_RELABEL', sailingStage: undefined }))
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(true)
})
it('preserves advanced conditions when collapsed and clears them on reset', async () => {
@@ -448,7 +471,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).not.toContain('按订单')
expect(wrapper.text()).not.toContain('待分配整柜')
expect(wrapper.text()).not.toContain('柜子工作台')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: 'PENDING_ASSIGNMENT' }))
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE' }))
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
@@ -481,9 +504,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
]))
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')!.trigger('click')
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'PENDING_ASSIGNMENT', lxCode: 'LX-SEARCH' }))
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', sailingStage: 'BEFORE_DEPARTURE', orderType: 'FIRST_LEG', lxCode: 'LX-SEARCH' }))
await openHiddenContainerWorkbench(wrapper)
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({
@@ -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 } 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 { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) 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)
@@ -150,8 +150,8 @@ const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(nul
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
let receivingWarehouseIdempotencyKey = ''
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const statusTabs = ['全部', '待分配'] as const
const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部')
const statusTabs = ['全部', '开船前', '开船后'] as const
const showAdvancedFilters = ref(false)
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
@@ -184,10 +184,11 @@ const allFields: QueryField[] = [
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
]
const availableFields = computed(() => allFields.filter((field) => statusTab.value === '全部' || field.key !== 'orderType'))
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: showAdvancedFilters.value
? allFields
? availableFields.value
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
@@ -333,6 +334,29 @@ function changeContainerPageSize(value: number) {
page.value = 1
void load()
}
function orderQuery(): LastMileOrderQuery {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
return {
page: page.value,
size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
orderType: statusTab.value === '全部' ? String(filters.value.orderType || '').trim() || undefined : 'FIRST_LEG',
sailingStage: statusTab.value === '开船前' ? 'BEFORE_DEPARTURE' : statusTab.value === '开船后' ? 'AFTER_DEPARTURE' : undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
lxCode: String(filters.value.lxCode || '').trim() || undefined,
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
currentStatus: String(filters.value.status || '').trim() || undefined,
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
abnormal: booleanValue(filters.value.abnormal),
detained: booleanValue(filters.value.detained),
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
}
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
loading.value = true
@@ -359,26 +383,7 @@ async function load(model: QueryModel = filters.value) {
return
}
try {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
const result = await listLastMileOrders({
page: page.value,
size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
orderType: String(filters.value.orderType || '').trim() || undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
lxCode: String(filters.value.lxCode || '').trim() || undefined,
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
abnormal: booleanValue(filters.value.abnormal),
detained: booleanValue(filters.value.detained),
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
})
const result = await listLastMileOrders(orderQuery())
rows.value = result.records.map(toLastMileOrderListItem)
total.value = result.total
} catch (error) {
@@ -1034,7 +1039,7 @@ onMounted(() => { void load() })
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
</div>
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
@@ -1088,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="statusTab === '待分配' && 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>
@@ -2,6 +2,7 @@ import ElementPlus, { ElCheckbox, ElInput, ElPagination, ElSelect } from 'elemen
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
import { listPlatformTenants } from '@/modules/iam'
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
@@ -48,6 +49,7 @@ async function selectBothPages() {
describe('outbound candidate search and selection', () => {
beforeEach(() => {
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
@@ -59,6 +61,24 @@ describe('outbound candidate search and selection', () => {
})
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
it('offers enabled customers from later pages when creating a return outbound', async () => {
vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
.mockResolvedValueOnce({ records: [{ id: 'customer-b', tenantName: '柏悦物流', status: 'ACTIVE' }], page: 2, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
await openCreate()
expect(listPlatformTenants).toHaveBeenNthCalledWith(1, { page: 1, size: 200, status: 'ACTIVE' })
expect(listPlatformTenants).toHaveBeenNthCalledWith(2, { page: 2, size: 200, status: 'ACTIVE' })
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
type.vm.$emit('update:modelValue', 'RETURN_OUTBOUND')
type.vm.$emit('change', 'RETURN_OUTBOUND')
await flushPromises()
const customer = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '请选择客户')!
expect(customer.findAllComponents({ name: 'ElOption' }).map(option => option.props('label'))).toEqual(['柏悦物流demo2', '柏悦物流'])
customer.vm.$emit('update:modelValue', 'customer-b')
await flushPromises()
expect(customer.props('modelValue')).toBe('customer-b')
})
it('selects and deselects the current page while preserving other pages, then submits all selected quantities', async () => {
await openCreate()
expect(wrapper.text()).toContain('可出毛重(kg)')
@@ -102,15 +102,25 @@ const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, outboundType: String(filters.value.outboundType || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
function outboundTypeLabel(value?: string | null) { return value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
async function loadCustomerOptions() {
const records: PlatformTenantResource[] = []
let page = 1
while (true) {
const result = await listPlatformTenants({ page, size: 200, status: 'ACTIVE' })
records.push(...result.records)
if (page >= result.pages || !result.records.length) return records
page += 1
}
}
async function loadInventoryOptions() {
inventoryLoading.value = true
try {
const [tenants, warehouses, fbaWarehouses] = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
loadCustomerOptions(),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'FBA', status: 'ACTIVE' }),
])
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value.records
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value
else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message)
if (warehouses.status === 'fulfilled') {
warehouseOptionsSource.value = warehouses.value.records
@@ -4,16 +4,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import DataTable from '@/shared/components/DataTable.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock } from '../api/returnInventory'
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock, listReturnPackingSources } from '../api/returnInventory'
import { listReturnPackingPlans } from '../api/relabelOrders'
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
import { listPlatformTenants } from '@/modules/iam'
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'customer-1', tenantName: '测试客户', status: 'ACTIVE' }] }),
listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'customer-1', tenantName: '测试客户', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 }),
listSharedReceivingOverseasWarehouseOptions: vi.fn().mockResolvedValue([{ id: 'warehouse-1', warehouseName: '测试仓库' }]),
}))
vi.mock('../api/returnInventory', () => ({
listReturnStock: vi.fn(), listReturnCartons: vi.fn(), listReturnInventoryMovements: vi.fn(),
getReturnCarton: vi.fn(), getReturnCartonLabel: vi.fn(),
listReturnPackingSources: vi.fn(),
}))
vi.mock('../api/relabelOrders', () => ({
listReturnPackingPlans: vi.fn(), createReturnPackingPlan: vi.fn(),
reviewReturnPackingPlan: vi.fn(), cancelReturnPackingPlan: vi.fn(),
}))
const wrappers: VueWrapper[] = []
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, cartonQuantity: 1, updatedAt: '2026-09-30' }
@@ -34,6 +41,11 @@ beforeEach(() => {
vi.mocked(listReturnCartons).mockResolvedValue({ records: [carton], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(listReturnInventoryMovements).mockResolvedValue({ records: [{ ...stock, movementType: 'MERGE', referenceNo: 'MERGE-1', totalDelta: -2, availableDelta: -2, occurredAt: '2026-09-30' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, items: [{ skuCode: 'SKU-TEST', quantity: 8 }] })
vi.mocked(listReturnPackingPlans).mockImplementation(async query => ({
records: query.status ? [] : [{ planNo: 'PLAN-NEW', returnNos: ['RETURN-1'], mode: 'SKU', boxes: [], status: 'PENDING_REVIEW', idempotencyKey: 'plan-key' }],
page: query.page, size: query.size, total: query.status ? 0 : 45, pages: 3,
}))
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
})
afterEach(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
@@ -41,6 +53,140 @@ afterEach(() => {
})
describe('ReturnInventoryManagementView shared list', () => {
it('includes customers from later server pages in the packing customer selector', async () => {
vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [{ id: 'customer-1', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
.mockResolvedValueOnce({ records: [{ id: 'customer-2', tenantName: '柏悦物流', status: 'ACTIVE' }], page: 2, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
const wrapper = await mountView('inventory')
expect(listPlatformTenants).toHaveBeenNthCalledWith(1, { page: 1, size: 200, status: 'ACTIVE' })
expect(listPlatformTenants).toHaveBeenNthCalledWith(2, { page: 2, size: 200, status: 'ACTIVE' })
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click')
await flushPromises()
const form = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
const options = form.getComponent({ name: 'ElSelect' }).findAllComponents({ name: 'ElOption' })
expect(options.map(option => option.props('label'))).toEqual(['柏悦物流demo2', '柏悦物流'])
})
it('finds boxes by contained SKU while retaining mixed-box contents and selected cartons', async () => {
vi.mocked(listReturnPackingSources).mockResolvedValue({records:[
{cartonMark:'BOX-A',sourceUnitType:'BOX',items:[{skuCode:'SKU-APPLE',quantity:2},{skuCode:'SKU-OTHER',quantity:3}]},
{cartonMark:'BOX-B',sourceUnitType:'BOX',items:[{skuCode:'SKU-BANANA',quantity:4}]},
],page:1,size:200,total:2,pages:1})
const wrapper=await mountView('inventory')
await wrapper.findAll('button').find(button=>button.text().includes('创建合箱计划'))!.trigger('click');await flushPromises()
const form=wrapper.findAllComponents({name:'ElForm'}).find(form=>form.classes().includes('packing-form'))!
const customer=form.getComponent({name:'ElSelect'})
customer.vm.$emit('update:modelValue','customer-1');customer.vm.$emit('change','customer-1');await flushPromises()
const input=form.get('input[placeholder="按箱内 SKU 查找,多个 SKU 请用英文逗号分隔"]')
await input.setValue('APPLE')
expect(form.text()).toContain('BOX-A');expect(form.text()).toContain('SKU-OTHER');expect(form.text()).not.toContain('BOX-B')
const selection=form.getComponent({name:'ElCheckboxGroup'})
selection.vm.$emit('update:modelValue',['BOX-A']);await flushPromises()
await input.setValue('APPLE, BANANA')
expect(form.text()).toContain('BOX-A');expect(form.text()).toContain('BOX-B')
await input.setValue('NO-MATCH')
expect(form.text()).toContain('没有包含该 SKU 的可用箱子')
await input.setValue('')
expect(form.getComponent({name:'ElCheckboxGroup'}).props('modelValue')).toContain('BOX-A')
})
it('queries packing plan numbers through server pagination and clears the filter', async () => {
const wrapper = await mountView('inventory')
const tabs = wrapper.getComponent({ name: 'ElTabs' })
tabs.vm.$emit('update:modelValue', 'plans'); tabs.vm.$emit('tab-change', 'plans')
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }).filter(select => ['全部客户','全部海外仓'].includes(select.props('placeholder')))
expect(selects.map(select => select.props('placeholder'))).toEqual(['全部客户','全部海外仓'])
expect(wrapper.find('input[placeholder="SKU"]').exists()).toBe(true)
await wrapper.get('input[placeholder="合箱计划号"]').setValue(' PLAN-NEW ')
await wrapper.get('.query-panel button').trigger('click'); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:1,size:20,planNo:'PLAN-NEW'})
wrapper.getComponent(DataTable).vm.$emit('update:page', 2); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:2,size:20,planNo:'PLAN-NEW'})
await wrapper.get('input[placeholder="合箱计划号"]').setValue('')
await wrapper.get('.query-panel button').trigger('click'); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:1,size:20})
selects[0]!.vm.$emit('update:modelValue','customer-1')
selects[1]!.vm.$emit('update:modelValue','warehouse-1')
await wrapper.get('input[placeholder="SKU"]').setValue(' SKU-TEST ')
await wrapper.get('.query-panel button').trigger('click'); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:1,size:20,customerId:'customer-1',overseasWarehouseId:'warehouse-1',skuCode:'SKU-TEST'})
})
it('shows source carton, return ownership and moved quantities in carton merge history', async () => {
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, items: [{ skuCode: 'SKU-TEST', quantity: 8 }], mergeHistory: [{planNo:'PLAN-MERGED',sourceCartonMark:'SOURCE-BOX',targetCartonMark:carton.cartonMark,returnNo:'RETURN-SOURCE',skuCode:'SKU-TEST',quantity:2,createdAt:'2026-10-08T14:00:00'}] })
const wrapper = await mountView('cartons')
await wrapper.findAll('button').find(button => button.text() === '详情')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('合箱明细')
expect(document.body.textContent).toContain('SOURCE-BOX')
expect(document.body.textContent).toContain('RETURN-SOURCE')
expect(document.body.textContent).toContain('PLAN-MERGED')
expect(document.body.textContent).toContain('合入')
})
it('shows pending plans without applying the carton AVAILABLE status on tab changes and pagination', async () => {
const wrapper = await mountView('inventory')
const tabs = wrapper.getComponent({ name: 'ElTabs' })
tabs.vm.$emit('update:modelValue', 'plans')
tabs.vm.$emit('tab-change', 'plans')
await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({ page: 1, size: 20 })
expect(wrapper.text()).toContain('PLAN-NEW')
wrapper.getComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({ page: 2, size: 20 })
tabs.vm.$emit('update:modelValue', 'stock')
tabs.vm.$emit('tab-change', 'stock')
await flushPromises()
expect(listReturnStock).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'AVAILABLE' }))
})
it('loads packing sources in pages without per-carton or stock requests and excludes reserved parcel quantities', async () => {
const wrapper = await mountView('inventory')
vi.mocked(listReturnPackingSources)
.mockResolvedValueOnce({ records: [{ cartonMark: 'PARCEL-1', sourceReturnNo: 'RETURN-1', sourceUnitType: 'PARCEL', items: [{ skuCode: 'PACK-SKU', quantity: 9, availableQuantity: 2 }] }], page: 1, size: 200, total: 2, pages: 2 })
.mockResolvedValueOnce({ records: [{ cartonMark: 'BOX-2', sourceReturnNo: 'RETURN-2', sourceUnitType: 'BOX', items: [{ skuCode: 'PACK-SKU', quantity: 100, availableQuantity: 100 }] }], page: 2, size: 200, total: 2, pages: 2 })
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0].vm.$emit('update:modelValue', 'customer-1')
selects[0].vm.$emit('change', 'customer-1')
await flushPromises()
expect(listReturnPackingSources).toHaveBeenCalledTimes(2)
expect(listReturnPackingSources).toHaveBeenLastCalledWith({ page: 2, size: 200, customerId: 'customer-1', overseasWarehouseId: 'warehouse-1' })
expect(getReturnCarton).not.toHaveBeenCalled()
expect(listReturnCartons).not.toHaveBeenCalled()
expect(listReturnStock).toHaveBeenCalledTimes(1)
const modes = dialog.findComponent({ name: 'ElRadioGroup' })
modes.vm.$emit('update:modelValue', 'SKU')
modes.vm.$emit('change', 'SKU')
await flushPromises()
expect(dialog.get('.packing-sku-row').text()).toContain('PACK-SKU')
expect(dialog.get('.packing-sku-row').text()).toContain('可用 2')
})
it('stops old pagination and ignores its result after the customer changes', async () => {
const wrapper = await mountView('inventory')
let resolveOld!: (value: Awaited<ReturnType<typeof listReturnPackingSources>>) => void
vi.mocked(listReturnPackingSources)
.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
.mockResolvedValueOnce({ records: [{ cartonMark: 'NEW-BOX', sourceUnitType: 'BOX', items: [] }], page: 1, size: 200, total: 1, pages: 1 })
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
const select = dialog.getComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', 'customer-1')
select.vm.$emit('change', 'customer-1')
await flushPromises()
select.vm.$emit('update:modelValue', 'customer-2')
select.vm.$emit('change', 'customer-2')
await flushPromises()
resolveOld({ records: [{ cartonMark: 'OLD-BOX', sourceUnitType: 'BOX', items: [] }], page: 1, size: 200, total: 400, pages: 2 })
await flushPromises()
expect(listReturnPackingSources).toHaveBeenCalledTimes(2)
expect(dialog.text()).toContain('NEW-BOX')
expect(dialog.text()).not.toContain('OLD-BOX')
})
it('keeps query submission and server pagination when using the shared table', async () => {
const wrapper = await mountView('inventory')
expect(wrapper.get('h1').text()).toBe('退货库存管理')
File diff suppressed because it is too large Load Diff
@@ -375,7 +375,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
expect(value).toContain('请选择目的 FBA 仓')
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain("const statusTabs = ['全部', '待分配'] as const")
expect(value).toContain("const statusTabs = ['全部', '开船前', '开船后'] as const")
expect(value).toContain('class="density-status-tabs" role="tablist"')
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
@@ -9,6 +9,8 @@ export const overseasWarehousePermissions = {
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
discardList: 'platform:overseas-warehouse:discard:list',
discardApprove: 'platform:overseas-warehouse:discard:approve',
dispatchList: 'platform:overseas-warehouse:dispatch:list',
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
+6
View File
@@ -20,6 +20,12 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/InboundOrdersView.vue'),
meta: { showPageHeading: false, title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList },
},
{
path: 'overseas-warehouse/cargo-discards',
name: 'overseas-cargo-discards',
component: () => import('./pages/CargoDiscardsView.vue'),
meta: { showPageHeading: false, title: '废弃单', description: '审核货物废弃申请并跟进 PDA 废弃与客户确认', permission: permission.discardList },
},
{
path: 'overseas-warehouse/inbound-orders/:orderId',
name: 'overseas-inbound-order-detail',
+1
View File
@@ -96,6 +96,7 @@ export interface InboundOrderListItem {
}
export interface DispatchListItem {
workflow?: import('./api/dispatches').DispatchWorkflow | null
id: string
dispatchNo: string
vehiclePlate: string
+44 -1
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': [] }
@@ -527,7 +569,8 @@ describe('approved Admin density ownership', () => {
expect(actionWeights.get('.admin-text-actions.el-popper .el-dropdown-menu__item')).toBe('400')
const dispatches = read('modules/overseas-warehouse/pages/DispatchesView.vue')
expect(dispatches).toContain('appearance="integrated"')
expect(dispatches).toContain('<template #filters><QueryToolbar')
expect(dispatches).toContain('<template #filters><div class="integrated-query-group">')
expect(dispatches).toContain('class="density-status-tabs" role="tablist"')
expect(dispatches).not.toContain('class="surface-panel table-panel"')
const calendar = read('modules/sailing-calendar/pages/SailingCalendarView.vue')
expect(calendar).toContain('appearance="integrated" :data="[]" :columns="[]" column-settings-mode="hide"')