发布已通过 TEST 回归的 2026-10-08 批次至灰度 #16
@@ -60,7 +60,7 @@
|
|||||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
| 入库单扣货、解除、凭证与历史 | `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/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/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/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/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/` |
|
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `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/` |
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('adminNavigation', () => {
|
|||||||
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
|
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
|
||||||
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
|
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
|
||||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] },
|
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
|
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
|
||||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||||
{ title: '客户服务', children: ['异常订单'] },
|
{ title: '客户服务', children: ['异常订单'] },
|
||||||
@@ -30,7 +30,7 @@ describe('adminNavigation', () => {
|
|||||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||||
])
|
])
|
||||||
expect(visible).toHaveLength(11)
|
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()
|
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -107,14 +107,15 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
|
{ 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-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-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-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-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: '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-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: '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-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', hidden: true },
|
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+16
-4
@@ -17,7 +17,17 @@ const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {
|
|||||||
const routerPush = vi.hoisted(() => vi.fn())
|
const routerPush = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
||||||
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
|
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/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
|
||||||
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: 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 />' } }))
|
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(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
|
||||||
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
|
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
|
||||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview 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(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 }])
|
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(getContainerOverview).mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
|
||||||
vi.mocked(getConfirmedLoadingList).mockResolvedValue({
|
vi.mocked(getConfirmedLoadingList).mockResolvedValue({
|
||||||
planNo: 'LP-1',
|
planNo: 'LP-1',
|
||||||
|
summary: { status: 'COMPLETED' },
|
||||||
items: [{ destinationWarehouse: 'ABE4' }, { destinationWarehouse: 'ABE4' }],
|
items: [{ destinationWarehouse: 'ABE4' }, { destinationWarehouse: 'ABE4' }],
|
||||||
} as never)
|
} as never)
|
||||||
|
|
||||||
@@ -158,7 +169,7 @@ describe('ContainerDetailView empty return', () => {
|
|||||||
|
|
||||||
describe('domestic container loading list and booking link', () => {
|
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 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(() => {
|
beforeEach(() => {
|
||||||
routeState.query = {}
|
routeState.query = {}
|
||||||
@@ -212,7 +223,8 @@ describe('domestic container loading list and booking link', () => {
|
|||||||
if (permitted) {
|
if (permitted) {
|
||||||
const drawer = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
|
const drawer = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
|
||||||
expect(drawer.props('mode')).toBe(planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
|
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()
|
wrapper.unmount()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ describe('ContainerDetailView customs integration', () => {
|
|||||||
expect(source).toContain(':sections="[\'documents\']"')
|
expect(source).toContain(':sections="[\'documents\']"')
|
||||||
expect(source).toContain(':sections="[\'pda\']"')
|
expect(source).toContain(':sections="[\'pda\']"')
|
||||||
expect(source).toContain('<LoadingListDetailDrawer')
|
expect(source).toContain('<LoadingListDetailDrawer')
|
||||||
expect(source).toContain('getConfirmedLoadingList')
|
expect(source).toContain('useLoadingListDetail')
|
||||||
expect(source).toContain('getPreloadPlan')
|
expect(source).toContain('await loadListDetail(mode, aggregate.loadingPlanId')
|
||||||
expect(source).toContain('label="装柜清单"')
|
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("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\']"')
|
expect(source).toContain(':sections="[\'pda\']"')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ describe('ContainerDetailView customs integration', () => {
|
|||||||
expect(navigationSource).toContain("title: '装柜清单'")
|
expect(navigationSource).toContain("title: '装柜清单'")
|
||||||
expect(source).toContain('<el-tabs v-model="activeTab"')
|
expect(source).toContain('<el-tabs v-model="activeTab"')
|
||||||
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
|
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('getContainer(containerId.value, props.workbench)')
|
||||||
expect(source).toContain('getContainerOverview(containerId.value)')
|
expect(source).toContain('getContainerOverview(containerId.value)')
|
||||||
expect(source).toContain(':can-edit="false"')
|
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
|
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 {
|
import {
|
||||||
confirmContainerOutboundDispatch,
|
confirmContainerOutboundDispatch,
|
||||||
getContainerOutboundDispatch,
|
getContainerOutboundDispatch,
|
||||||
@@ -144,9 +144,7 @@ const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
|
|||||||
const outboundError = ref<ApiErrorDetails | null>(null)
|
const outboundError = ref<ApiErrorDetails | null>(null)
|
||||||
const showPassedGates = ref(false)
|
const showPassedGates = ref(false)
|
||||||
const carrierRefreshKey = ref(0)
|
const carrierRefreshKey = ref(0)
|
||||||
const loadingDetail = ref<LoadingDetail | null>(null)
|
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
|
||||||
const loadingDetailLoading = ref(false)
|
|
||||||
const loadingDetailError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const emptyReturnOpen = ref(false)
|
const emptyReturnOpen = ref(false)
|
||||||
const emptyReturnAt = ref('')
|
const emptyReturnAt = ref('')
|
||||||
const emptyReturnReason = ref('')
|
const emptyReturnReason = ref('')
|
||||||
@@ -295,6 +293,7 @@ async function confirmEmptyReturn() {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!containerId.value) return
|
if (!containerId.value) return
|
||||||
|
resetLoadingDetail()
|
||||||
loading.value = true; error.value = null; overviewError.value = null
|
loading.value = true; error.value = null; overviewError.value = null
|
||||||
try {
|
try {
|
||||||
const [resource, aggregate, arrivals] = await Promise.all([
|
const [resource, aggregate, arrivals] = await Promise.all([
|
||||||
@@ -331,23 +330,12 @@ async function loadOutboundDispatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
|
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
|
||||||
if (loadingDetail.value || loadingDetailLoading.value) return
|
const mode = resource.planStatus === 'COMPLETED' ? 'confirmed' : 'preload'
|
||||||
loadingDetail.value = null; loadingDetailError.value = null
|
if (!(mode === 'confirmed' ? canViewLoadingList.value : canViewPreload.value)) {
|
||||||
if (!aggregate.loadingPlanId || !resource.warehouseId) return
|
resetLoadingDetail()
|
||||||
loadingDetailLoading.value = true
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
await loadListDetail(mode, aggregate.loadingPlanId || '', resource.warehouseId || '')
|
||||||
}
|
}
|
||||||
async function refreshAfterCustoms() {
|
async function refreshAfterCustoms() {
|
||||||
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
|
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 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') })
|
onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') })
|
||||||
</script>
|
</script>
|
||||||
@@ -588,10 +575,10 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
|
<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" />
|
<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" />
|
<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>
|
||||||
|
|
||||||
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
|
<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 ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
|
||||||
export type KnownExportTaskType =
|
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'
|
| 'CUSTOMER_LIST'
|
||||||
| 'DOMESTIC_WAREHOUSE_ORDER_LIST'
|
| 'DOMESTIC_WAREHOUSE_ORDER_LIST'
|
||||||
| 'FINANCE_CUSTOMER_ACCOUNT_LIST'
|
| 'FINANCE_CUSTOMER_ACCOUNT_LIST'
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './api/platformExportTasks'
|
export * from './api/platformExportTasks'
|
||||||
|
export { useExportTask } from './useExportTask'
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ type DownloadFilters = QueryModel & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = {
|
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' },
|
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
|
||||||
DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' },
|
DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' },
|
||||||
FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account: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 + "'")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="measurement-dialog-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 }}
|
{{ measurementPanelRef.submissionBlockReason }}
|
||||||
</span>
|
</span>
|
||||||
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
|
<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 { border: 0; border-radius: 0; box-shadow: none; }
|
||||||
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
|
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
|
||||||
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
|
.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) {
|
@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 { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
|
||||||
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
|
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
const text = wrapper.text()
|
const text = wrapper.text()
|
||||||
|
|
||||||
expect(text).toContain('实测箱数')
|
expect(text).toContain('箱数')
|
||||||
expect(text).toContain('货物净重')
|
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('平台实测')
|
||||||
expect(text).toContain('差异 +1.5 kg')
|
expect(text).toContain('差异 +1.5 kg')
|
||||||
expect(text).toContain('差异 -0.2 m³')
|
expect(text).toContain('差异 -0.2 m³')
|
||||||
expect(text).toContain('箱规明细')
|
expect(text).toContain('平台实测箱规')
|
||||||
expect(text).not.toContain('租户')
|
expect(text).not.toContain('租户')
|
||||||
expect(text).not.toContain('数据版本')
|
expect(text).not.toContain('数据版本')
|
||||||
|
|
||||||
@@ -287,27 +287,58 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect(source).toContain('<section class="measurement-declaration"')
|
expect(source).toContain('<section class="measurement-declaration"')
|
||||||
expect(source).not.toContain('<el-collapse-item name="declaration"')
|
expect(source).not.toContain('<el-collapse-item name="declaration"')
|
||||||
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
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-table { width: 100%; font-size: var(--admin-font-caption, 12px); }')
|
||||||
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
|
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 })')
|
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 () => {
|
it('renders customer declaration facts as plain read-only values', async () => {
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
const declaration = wrapper.find('.measurement-declaration')
|
const declaration = wrapper.find('.measurement-declaration')
|
||||||
|
|
||||||
expect(declaration.text()).toContain('申报箱数')
|
const comparison = wrapper.find('.measurement-comparison')
|
||||||
expect(declaration.text()).toContain('10')
|
expect(comparison.text()).toContain('118.5')
|
||||||
expect(declaration.text()).toContain('申报毛重(kg)')
|
expect(comparison.text()).toContain('0.8')
|
||||||
expect(declaration.text()).toContain('118.5')
|
expect(comparison.findAll('tbody tr')[0].text()).toContain('10')
|
||||||
expect(declaration.text()).toContain('申报体积(m³)')
|
|
||||||
expect(declaration.text()).toContain('0.8')
|
|
||||||
expect(declaration.text()).toContain('原单据号码')
|
expect(declaration.text()).toContain('原单据号码')
|
||||||
expect(declaration.text()).toContain('DOC-001')
|
expect(declaration.text()).toContain('DOC-001')
|
||||||
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(4)
|
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(1)
|
||||||
expect(declaration.findAll('input')).toHaveLength(1)
|
expect(declaration.findAll('input')).toHaveLength(0)
|
||||||
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
|
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('蓝牙音箱')
|
||||||
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
|
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 () => {
|
it('does not render differences when the corresponding declaration is unavailable', async () => {
|
||||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ originalGrossWeightKg: null, originalVolumeCbm: null }))
|
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ originalGrossWeightKg: null, originalVolumeCbm: null }))
|
||||||
const wrapper = await mountPanel()
|
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 () => {
|
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.find('input').exists()).toBe(false)
|
||||||
expect(declaration.text()).toContain('蓝牙音箱')
|
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 () => {
|
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)
|
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
|
expect(wrapper.find('.measurement-summary-section').text()).toContain('125.75')
|
||||||
expect(wrapper.find('.measurement-metrics').text()).toContain('115.75')
|
expect(wrapper.find('.measurement-summary-section').text()).toContain('115.75')
|
||||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -515,6 +547,35 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
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 () => {
|
it('requires at least one confirmed weight image before submitting for review', async () => {
|
||||||
const messageSpy = vi.spyOn(ElMessage, 'warning')
|
const messageSpy = vi.spyOn(ElMessage, 'warning')
|
||||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ weightResults: [] }))
|
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 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 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))
|
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 expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
|
||||||
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
|
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
|
||||||
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
|
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 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 maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
|
||||||
const submissionBlockReason = computed(() => {
|
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 (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
|
||||||
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
|
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
|
||||||
if (!expectedPalletCountValid.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-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">
|
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
|
||||||
<template #title>数据版本已变化,当前修改尚未丢失</template>
|
<template #title>数据版本已变化,当前修改尚未丢失</template>
|
||||||
<p>{{ operationError?.message }}</p>
|
<p>{{ operationError?.message }}</p>
|
||||||
@@ -699,13 +700,14 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
<legend class="sr-only">测量草稿</legend>
|
<legend class="sr-only">测量草稿</legend>
|
||||||
<section class="measurement-section measurement-summary-section">
|
<section class="measurement-section measurement-summary-section">
|
||||||
<header class="measurement-summary-header">
|
<header class="measurement-summary-header">
|
||||||
|
<h4 class="measurement-subheading">申报与实测对比</h4>
|
||||||
<div class="measurement-summary-context">
|
<div class="measurement-summary-context">
|
||||||
<p><span>客户</span>{{ displayCustomerName }}</p>
|
<p><span>客户</span>{{ displayCustomerName }}</p>
|
||||||
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
|
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="measurement-summary-status">
|
<div class="measurement-summary-status">
|
||||||
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
|
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
|
||||||
<span>{{ sourceLabel }}</span>
|
<span v-if="detail.sourceType && form.items.length">{{ sourceLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -720,57 +722,66 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
|
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<h4 class="measurement-subheading">平台实测</h4>
|
<table class="measurement-comparison" aria-label="客户申报与平台实测对比">
|
||||||
<dl class="measurement-metrics" aria-label="平台实测结果汇总">
|
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
|
||||||
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
|
<tbody>
|
||||||
<dt>实测箱数</dt>
|
<tr v-for="metric in comparisonMetrics" :key="metric.label">
|
||||||
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small v-if="formatDifference(confirmedPackageCount, expectedPackageCount, 0)">/ {{ formatDifference(confirmedPackageCount, expectedPackageCount, 0) }}</small></dd>
|
<th scope="row">{{ metric.label }}</th>
|
||||||
</div>
|
<td>{{ formatNumber(metric.declared, metric.digits) }}</td>
|
||||||
<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>
|
<td>{{ metric.actual == null ? '未测量' : formatNumber(metric.actual, metric.digits) }}</td>
|
||||||
<div class="measurement-metric--primary"><dt>货物净重</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
|
<td :class="{ 'measurement-comparison__difference': metric.actual != null && metric.declared != null && metric.actual !== metric.declared }">
|
||||||
<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>
|
{{ metric.actual == null ? '待测量' : metric.declared == null ? '—' : formatDifference(metric.actual, metric.declared, metric.digits, metric.unit) || '一致' }}
|
||||||
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
|
</td>
|
||||||
</dl>
|
</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">
|
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
|
||||||
<h4 id="measurement-declaration-title">客户申报</h4>
|
<header class="measurement-declaration-header">
|
||||||
<div class="measurement-data-grid">
|
<h4 id="measurement-declaration-title">客户申报明细</h4>
|
||||||
<div class="measurement-readonly-field"><span>申报箱数</span><strong>{{ formatNumber(expectedPackageCount, 0) }}</strong></div>
|
<div class="measurement-readonly-field measurement-document-number"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
|
||||||
<div class="measurement-readonly-field"><span>申报毛重(kg)</span><strong>{{ formatNumber(detail.originalGrossWeightKg) }}</strong></div>
|
</header>
|
||||||
<div class="measurement-readonly-field"><span>申报体积(m³)</span><strong>{{ formatNumber(detail.originalVolumeCbm, 4) }}</strong></div>
|
<el-table
|
||||||
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="255" /></label>
|
v-if="detail.latestDeclaration?.items.length"
|
||||||
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
|
:data="detail.latestDeclaration.items"
|
||||||
<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>
|
border
|
||||||
<div v-else-if="isDropship" class="measurement-readonly-field"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
|
stripe
|
||||||
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
|
size="small"
|
||||||
</div>
|
class="measurement-declaration-table"
|
||||||
<el-table
|
aria-label="客户申报箱规明细"
|
||||||
v-if="detail.latestDeclaration?.items.length"
|
>
|
||||||
:data="detail.latestDeclaration.items"
|
<el-table-column prop="itemNo" label="#" width="70" />
|
||||||
border
|
<el-table-column prop="cargoName" label="货物品名" min-width="150" show-overflow-tooltip />
|
||||||
stripe
|
<el-table-column prop="packageCount" label="申报箱数" width="100" />
|
||||||
size="small"
|
<el-table-column label="申报尺寸 长×宽×高(cm)" min-width="190">
|
||||||
class="measurement-declaration-table"
|
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||||
aria-label="客户申报箱规明细"
|
</el-table-column>
|
||||||
>
|
</el-table>
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
<section class="measurement-section measurement-items-section">
|
<section class="measurement-section measurement-items-section">
|
||||||
<div class="measurement-section__heading">
|
<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">
|
<div class="measurement-section-actions">
|
||||||
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
|
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
v-if="form.items.length"
|
v-if="form.items.length"
|
||||||
:data="form.items"
|
:data="form.items"
|
||||||
@@ -854,7 +865,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
|
<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-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
|
||||||
<span v-else>所有修改已保存</span>
|
<span v-else>所有修改已保存</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -906,6 +917,14 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 { overflow: hidden; }
|
||||||
.measurement-panel__heading > div:first-child { display: flex; min-width: 0; align-items: flex-start; gap: 11px; }
|
.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); }
|
.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 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__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-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-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-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
||||||
.measurement-skeleton { padding: var(--admin-space-7, 24px); }
|
.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-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-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-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-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 { 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-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); }
|
.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; }
|
.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; }
|
.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-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 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-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; }
|
.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-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.measurement-data-grid { grid-template-columns: repeat(3, 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-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) {
|
@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) {
|
@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-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-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-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-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 { 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-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-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); }
|
.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">
|
<script setup lang="ts">
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, ref, watch } from 'vue'
|
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 ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.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 StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
|
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
|
||||||
import { loadingListStatusLabel } from '../status'
|
import { loadingListStatusLabel } from '../status'
|
||||||
|
import { printLoadingList } from '../printLoadingList'
|
||||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||||
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ const props = defineProps<{
|
|||||||
pdaAssigneeSelected?: boolean
|
pdaAssigneeSelected?: boolean
|
||||||
dense?: boolean
|
dense?: boolean
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
backLabel?: string
|
||||||
canEditTransport?: boolean
|
canEditTransport?: boolean
|
||||||
confirmSaving?: 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 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="清单概览">
|
<section class="detail-overview" aria-label="清单概览">
|
||||||
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
|
<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">
|
<div v-if="!embedded" class="detail-page-header__identity">
|
||||||
<h1>{{ title }}</h1>
|
<h1>{{ title }}</h1>
|
||||||
<p v-if="mode !== 'preload'">查看已完成装柜明细和柜次费用</p>
|
<p v-if="mode !== 'preload'">查看已完成装柜明细和操作历史</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
<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'" />
|
<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">
|
<template v-if="canEditItemRemark">
|
||||||
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
|
<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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import LoadingListsView from './LoadingListsView.vue'
|
import LoadingListsView from './LoadingListsView.vue'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: 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: {} }), useRouter: () => ({ push: mocks.push, replace: vi.fn() }) }))
|
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('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
|
||||||
vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '<div />' } }))
|
vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '<div />' } }))
|
||||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) }))
|
vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) }))
|
||||||
@@ -20,27 +20,32 @@ function render() {
|
|||||||
ElButton: { template: '<button><slot /></button>' },
|
ElButton: { template: '<button><slot /></button>' },
|
||||||
} } })
|
} } })
|
||||||
}
|
}
|
||||||
describe('confirmed manifest inline detail', () => {
|
describe('confirmed manifest navigation', () => {
|
||||||
beforeEach(() => { vi.clearAllMocks(); mocks.can.mockReturnValue(true); mocks.list.mockResolvedValue({ records: rows, total: 2 }) })
|
beforeEach(() => {
|
||||||
it('ignores an older detail response when another manifest is selected', async () => {
|
vi.clearAllMocks()
|
||||||
let resolveFirst!: (value: unknown) => void
|
mocks.query = {}
|
||||||
mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
|
mocks.can.mockReturnValue(true)
|
||||||
.mockResolvedValueOnce({ summary: { status: 'COMPLETED', planId: '2', loadingListNo: 'LIST-2' } })
|
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()
|
const wrapper = render(); await flushPromises()
|
||||||
await wrapper.findAll('button')[0]!.trigger('click')
|
await wrapper.findAll('button')[0]!.trigger('click')
|
||||||
await wrapper.findAll('button')[2]!.trigger('click'); await flushPromises()
|
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11' } })
|
||||||
resolveFirst({ summary: { status: 'COMPLETED', planId: '1', loadingListNo: 'LIST-1' } }); await flushPromises()
|
await wrapper.findAll('button')[2]!.trigger('click')
|
||||||
expect(wrapper.get('[data-testid="detail"]').text()).toBe('LIST-2')
|
expect(mocks.push).toHaveBeenLastCalledWith({ name: 'loading-list-detail', params: { planId: '2' }, query: { warehouseId: '22' } })
|
||||||
expect(mocks.get).toHaveBeenNthCalledWith(2, '2', '22')
|
expect(mocks.get).not.toHaveBeenCalled()
|
||||||
expect(mocks.push).not.toHaveBeenCalled()
|
expect(wrapper.find('[data-testid="detail"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
it('does not print when loading fails', async () => {
|
it('prints through the dedicated detail page', async () => {
|
||||||
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
|
|
||||||
mocks.get.mockRejectedValueOnce(new Error('清单不可用'))
|
|
||||||
const wrapper = render(); await flushPromises()
|
const wrapper = render(); await flushPromises()
|
||||||
await wrapper.findAll('button')[1]!.trigger('click'); await flushPromises()
|
await wrapper.findAll('button')[1]!.trigger('click')
|
||||||
expect(wrapper.get('[data-testid="detail"]').text()).toContain('清单不可用')
|
expect(mocks.push).toHaveBeenCalledWith({ name: 'loading-list-detail', params: { planId: '1' }, query: { warehouseId: '11', print: '1' } })
|
||||||
expect(print).not.toHaveBeenCalled(); print.mockRestore()
|
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 () => {
|
it('does not fetch manifests without list permission', async () => {
|
||||||
mocks.can.mockReturnValue(false)
|
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 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 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 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')
|
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8')
|
||||||
|
|
||||||
describe('LoadingListsView', () => {
|
describe('LoadingListsView', () => {
|
||||||
@@ -31,7 +32,7 @@ describe('LoadingListsView', () => {
|
|||||||
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
|
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
|
||||||
expect(source).not.toContain("'请先选择仓库后查询'")
|
expect(source).not.toContain("'请先选择仓库后查询'")
|
||||||
expect(preloadDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
|
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', () => {
|
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', () => {
|
it('keeps preload routes while completed lists move into domestic container detail', () => {
|
||||||
expect(source).toContain("name: 'preload-plan-detail'")
|
expect(source).toContain("name: 'preload-plan-detail'")
|
||||||
expect(source).toContain('loadConfirmedDetail')
|
expect(source).toContain("name: 'loading-list-detail'")
|
||||||
expect(source).toContain('confirmed-inline-detail')
|
expect(source).not.toContain('confirmed-inline-detail')
|
||||||
expect(preloadDetailSource).toContain('返回列表')
|
expect(preloadDetailSource).toContain('返回列表')
|
||||||
expect(preloadDetailSource).toContain('router.back()')
|
expect(preloadDetailSource).toContain('router.back()')
|
||||||
expect(confirmedDetailSource).toContain('返回列表')
|
expect(detailSource).toContain("backLabel || '返回列表'")
|
||||||
expect(confirmedDetailSource).toContain('router.back()')
|
expect(confirmedDetailSource).toContain('router.back()')
|
||||||
expect(confirmedDetailSource).toContain('@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }')
|
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).toContain("path: 'loading-lists/:planId'")
|
||||||
expect(routesSource).not.toContain("name: 'loading-list-detail'")
|
expect(routesSource).toContain("name: 'loading-list-detail'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
|
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('useRoute()')
|
||||||
expect(source).toContain('route.query.planId')
|
expect(source).toContain('route.query.planId')
|
||||||
expect(source).toContain('route.query.warehouseId')
|
expect(source).toContain('route.query.warehouseId')
|
||||||
expect(source).toContain('await loadConfirmedDetail(planId, warehouseId)')
|
expect(source).toContain("await router.replace({ name: 'loading-list-detail'")
|
||||||
expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'")
|
expect(sharedLoaderSource).toContain("result.summary.status !== 'COMPLETED'")
|
||||||
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
|
expect(sharedLoaderSource).toContain('MODE_STATUS_MISMATCH')
|
||||||
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
|
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], () => {')
|
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => {')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ContainerSimulationCreate } from '@/modules/container-loading'
|
import { ContainerSimulationCreate } from '@/modules/container-loading'
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
|||||||
import { loadingListStatusLabel } from '../status'
|
import { loadingListStatusLabel } from '../status'
|
||||||
import {
|
import {
|
||||||
listConfirmedLoadingLists,
|
listConfirmedLoadingLists,
|
||||||
getConfirmedLoadingList,
|
|
||||||
type LoadingDetail,
|
|
||||||
listPreloadPlans,
|
listPreloadPlans,
|
||||||
type ConfirmedSummary,
|
type ConfirmedSummary,
|
||||||
type LoadingListMode,
|
type LoadingListMode,
|
||||||
@@ -43,44 +40,8 @@ const customers = ref<PlatformTenantResource[]>([])
|
|||||||
const customersLoading = ref(false)
|
const customersLoading = ref(false)
|
||||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
|
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
|
||||||
const handledDeepLink = ref('')
|
const handledDeepLink = ref('')
|
||||||
const selectedPlan = ref<{ planId: string; warehouseId: string } | null>(null)
|
function printConfirmed(row: LoadingListSummary) {
|
||||||
const confirmedDetail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
|
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || ''), print: '1' } })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPreload = computed(() => props.mode === 'preload')
|
const isPreload = computed(() => props.mode === 'preload')
|
||||||
@@ -180,7 +141,7 @@ async function openDeepLinkedDetail() {
|
|||||||
await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } })
|
await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await loadConfirmedDetail(planId, warehouseId)
|
await router.replace({ name: 'loading-list-detail', params: { planId }, query: { warehouseId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSimulationCreated() {
|
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 || '') } })
|
void router.push({ name: 'preload-plan-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
|
||||||
return
|
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() {
|
async function loadCustomers() {
|
||||||
@@ -265,7 +226,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
|
<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" :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>
|
</div></template>
|
||||||
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></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>
|
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
|
||||||
@@ -276,10 +237,6 @@ onMounted(() => {
|
|||||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||||
</DataTable></section>
|
</DataTable></section>
|
||||||
</ListPageLayout>
|
</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>
|
</div>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
@@ -287,7 +244,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.loading-lists-page { display: grid; min-width: 0; gap: var(--admin-space-6, 16px); }
|
.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); }
|
.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 { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-2, 6px); }
|
||||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { loadingListStatusLabel } from './status'
|
|||||||
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
|
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
|
||||||
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
|
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
|
||||||
export { default as PalletLabelPrintDialog } from './components/PalletLabelPrintDialog.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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||||
import {
|
import { useLoadingListDetail } from '../useLoadingListDetail'
|
||||||
getConfirmedLoadingList,
|
import { printLoadingList } from '../printLoadingList'
|
||||||
type ConfirmedSummary,
|
|
||||||
type LoadingDetail,
|
|
||||||
} from '../api/platformLoadingLists'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const { detail, loading, error, load } = useLoadingListDetail()
|
||||||
const detail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
|
|
||||||
const error = ref<ApiErrorDetails | null>(null)
|
|
||||||
let loadSequence = 0
|
|
||||||
const planId = computed(() => String(route.params.planId || ''))
|
const planId = computed(() => String(route.params.planId || ''))
|
||||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
const sequence = ++loadSequence
|
const result = await load('confirmed', planId.value, warehouseId.value)
|
||||||
detail.value = null
|
if (!result || detail.value !== result || route.query.print !== '1') return
|
||||||
if (!planId.value || !warehouseId.value) {
|
await printLoadingList()
|
||||||
loading.value = false
|
const { print: _print, ...query } = route.query
|
||||||
error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少所属仓库信息,无法安全查询详情,请返回列表重新进入。' }
|
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query })
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
@@ -58,7 +32,7 @@ watch([planId, warehouseId], () => void loadDetail())
|
|||||||
<template>
|
<template>
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
<div class="confirmed-loading-detail-page">
|
<div class="confirmed-loading-detail-page">
|
||||||
<LoadingListDetailDrawer
|
<LoadingListDetailDrawer dense
|
||||||
mode="confirmed"
|
mode="confirmed"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:detail="detail"
|
:detail="detail"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
export async function printLoadingList() {
|
||||||
|
await nextTick()
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { loadingListRoutes } from './routes'
|
|||||||
describe('loading list routes', () => {
|
describe('loading list routes', () => {
|
||||||
it('registers the confirmed list with its own read permission', () => {
|
it('registers the confirmed list with its own read permission', () => {
|
||||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
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-lists', 'platform:firstleg:loading-list:list'],
|
||||||
['loading-plans', 'platform:firstleg:preload:list'],
|
['loading-plans', 'platform:firstleg:preload:list'],
|
||||||
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
export const loadingListRoutes = [
|
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',
|
path: 'loading-lists',
|
||||||
name: '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 }
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
- 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。
|
- 报价、汇率、车辆现有 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存在推导页面能力。
|
- 共享车次接口在 `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权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。
|
- 用户已于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,不自动修改车辆公里码、状态或财务。
|
- 车辆运维在 `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权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
|
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
|
||||||
- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。
|
- 司机列表 `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(已扣除外接占位),不使用旧自有货余位覆盖。
|
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
|
||||||
|
|
||||||
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
|
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
|
||||||
|
|
||||||
|
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { http, ApiResponseError } from '@/shared/api/http'
|
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() } }))
|
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 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' }
|
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])
|
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] })
|
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', () => {
|
it('rejects lossy money and numeric versions instead of rounding them', () => {
|
||||||
expect(() => fleetMoney('99999999999999999.01')).toThrow('精确展示')
|
expect(() => fleetMoney('99999999999999999.01')).toThrow('精确展示')
|
||||||
expect(() => fleetVersion(9007199254740992)).toThrow('版本无效')
|
expect(() => fleetVersion(9007199254740992)).toThrow('版本无效')
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import axios from 'axios'
|
|
||||||
import type { PageResult } from './overseasMasterData'
|
import type { PageResult } from './overseasMasterData'
|
||||||
|
|
||||||
export type FleetQuoteKind = 'PURCHASE' | 'SALE'
|
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' } }))
|
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) }))
|
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) }
|
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 {
|
import {
|
||||||
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
||||||
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
||||||
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
||||||
type QuoteCreate, type QuoteUpdate,
|
type QuoteCreate, type QuoteUpdate,
|
||||||
} from './overseasMasterData'
|
} from './overseasMasterData'
|
||||||
@@ -124,22 +124,4 @@ describe('overseas master data API', () => {
|
|||||||
expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' })
|
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, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
|
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 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) })
|
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 }
|
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
|
||||||
type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] }
|
type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] }
|
||||||
type QuoteEditableFields = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'remark'>
|
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 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 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 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 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)
|
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 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 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 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 }
|
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
|
||||||
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
|
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 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 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 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 axios from 'axios'
|
||||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||||
import type { PageResult } from './overseasMasterData'
|
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'
|
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 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 }
|
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 }))
|
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) } }
|
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 TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null }
|
||||||
export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: 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 }
|
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 }
|
actions: { canEdit: boolean; canConfirm: boolean; canCancel: boolean; blockReason: string | null }
|
||||||
}
|
}
|
||||||
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage<CapacityInquiry> }
|
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 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 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 }
|
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() {
|
async function load() {
|
||||||
if (!writable.value || !open.value || pending.value) return
|
if (!writable.value || !open.value || pending.value) return
|
||||||
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
|
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, '发布设置读取失败') }
|
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
|
||||||
finally { if (current === generation) loading.value = false }
|
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 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)
|
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
|
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' }]
|
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() {
|
async function load() {
|
||||||
const current = ++sequence; inquirySequence++; loading.value = true; error.value = null
|
const current = ++sequence; inquirySequence++; loading.value = true; error.value = null
|
||||||
@@ -58,11 +59,12 @@ watch([page, size], loadInquiries)
|
|||||||
onBeforeUnmount(() => { sequence++; inquirySequence++ })
|
onBeforeUnmount(() => { sequence++; inquirySequence++ })
|
||||||
</script>
|
</script>
|
||||||
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
|
<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>
|
<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">
|
<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>
|
<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">分段运力</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>
|
<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>
|
<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
|
state.manage = true
|
||||||
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
|
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).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 () => {
|
it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => {
|
||||||
state.manage = true
|
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.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
||||||
expect(api.listCapacityInquiries).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: '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: '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: '暂不可确认' }] },
|
{ 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: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
|
||||||
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
|
{ 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 {
|
try {
|
||||||
const q = applied.value
|
const q = applied.value
|
||||||
if (tab.value === 'offers') {
|
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 }
|
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
|
||||||
} else {
|
} 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' }
|
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++ })
|
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
|
||||||
</script>
|
</script>
|
||||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车需求池">
|
<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) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
<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 #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 #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>
|
<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>
|
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||||||
</DataTable>
|
</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">
|
<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>
|
<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>
|
</CapacityInquiryTable>
|
||||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="recheckEditor" />
|
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="recheckEditor" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { onMounted, reactive, ref, watch } from 'vue'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Download, Plus } from '@lucide/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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
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 { overseasMasterDataPermissions as permission } from '../permissions'
|
||||||
import { saveCsv } from '@/shared/downloadCsv'
|
|
||||||
import { masterDataErrorMessage } from '../apiError'
|
import { masterDataErrorMessage } from '../apiError'
|
||||||
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
|
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
|
||||||
|
|
||||||
|
const submitExport = useExportTask()
|
||||||
const access = useAccessPreviewStore()
|
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 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('')
|
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 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 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 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)
|
watch([page, pageSize], load); onMounted(load)
|
||||||
</script>
|
</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 #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 #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>
|
<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>
|
</DataTable></section>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import FleetQuoteEditor from '../components/FleetQuoteEditor.vue'
|
import FleetQuoteEditor from '../components/FleetQuoteEditor.vue'
|
||||||
import FleetQuoteImport from '../components/FleetQuoteImport.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 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 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
|
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
|
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 }
|
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++ })
|
watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||||
</script>
|
</script>
|
||||||
<template><AdminPage class="admin-density"><ListPageLayout>
|
<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>
|
<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" />
|
<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="暂无报价">
|
<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 #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 #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>
|
<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('access.can(permission.')
|
||||||
expect(view).toContain('ElMessage.success')
|
expect(view).toContain('ElMessage.success')
|
||||||
}
|
}
|
||||||
expect(source('OverseasQuotesView')).toContain('导出CSV')
|
expect(source('OverseasQuotesView')).toContain('导出')
|
||||||
expect(source('VehiclesView')).toContain('导出CSV')
|
expect(source('VehiclesView')).toContain('导出')
|
||||||
expect(source('ExchangeRatesView')).toContain("'monthly-exchange-rates.csv'")
|
expect(source('ExchangeRatesView')).toContain("submitExport('MONTHLY_EXCHANGE_RATE_LIST'")
|
||||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||||
expect(source(name)).not.toMatch(/deleteQuote|deleteExchangeRate|deleteVehicle/)
|
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)')
|
expect(source('VehiclesView')).toContain('changeVehicleStatus(row.id,statusTarget.value,row.recordVersion)')
|
||||||
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
|
||||||
expect(source(name)).toContain("masterDataErrorMessage(")
|
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">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { onMounted, reactive, ref, watch } from 'vue'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Download, Plus } from '@lucide/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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
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 { overseasMasterDataPermissions as permission } from '../permissions'
|
||||||
import { saveCsv } from '@/shared/downloadCsv'
|
|
||||||
import { masterDataErrorMessage } from '../apiError'
|
import { masterDataErrorMessage } from '../apiError'
|
||||||
import type { MasterDataStatus, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption } from '../types'
|
import type { MasterDataStatus, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption } from '../types'
|
||||||
|
|
||||||
|
const submitExport = useExportTask()
|
||||||
const access = useAccessPreviewStore()
|
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 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)
|
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 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 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 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()])})
|
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
|
||||||
</script>
|
</script>
|
||||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓报价列表">
|
<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 #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 #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>
|
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
|
||||||
</DataTable></section>
|
</DataTable></section>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function accept(value: TripDetail) {
|
|||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const current = ++generation; inspectionSequence++; historySequence++
|
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
|
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
|
||||||
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
|
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
|
||||||
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
|
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 }
|
finally { if (current === historySequence) historyLoading.value = false }
|
||||||
}
|
}
|
||||||
watch(() => route.params.tripNo, load, { immediate: true })
|
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(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
|
||||||
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
|
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
|
||||||
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { listTripOperations, exportTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
|
import { listTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
|
||||||
import { saveFleetCsv } from '../api/fleetQuotes'
|
|
||||||
import TripOperationsFinanceCell from '../components/TripOperationsFinanceCell.vue'
|
import TripOperationsFinanceCell from '../components/TripOperationsFinanceCell.vue'
|
||||||
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
|
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
|
||||||
import TripOperationsRecordDialog from '../components/TripOperationsRecordDialog.vue'
|
import TripOperationsRecordDialog from '../components/TripOperationsRecordDialog.vue'
|
||||||
|
const submitExport = useExportTask()
|
||||||
const router = useRouter(), access = useAccessPreviewStore()
|
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 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)
|
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 } }
|
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 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 } }
|
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(' / ') : '—'
|
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
|
||||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||||
</script>
|
</script>
|
||||||
@@ -61,7 +62,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
|||||||
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
|
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 #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 #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>
|
<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 { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -9,7 +10,7 @@ import * as api from '../api/trips'
|
|||||||
import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes'
|
import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes'
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({ writable: true, push: vi.fn() }))
|
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('@/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('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' }]) }))
|
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)! }
|
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||||
beforeEach(() => {
|
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.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 })
|
||||||
vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
|
vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
|
||||||
vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
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(api.getTrip).toHaveBeenCalledTimes(2)
|
||||||
expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('bookings')
|
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">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { vehicleStatusLabels } from '../presentation'
|
import { vehicleStatusLabels } from '../presentation'
|
||||||
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
@@ -7,8 +8,9 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { useRouter } from 'vue-router'
|
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 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 { 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'
|
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 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 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}))}]
|
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,'车辆详情加载失败'))}}
|
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}}
|
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}}
|
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)
|
watch([page,pageSize],load);onMounted(load)
|
||||||
</script>
|
</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="暂无车辆">
|
<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 #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 #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>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -48,3 +48,5 @@
|
|||||||
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
|
- 排车列表编辑、托盘移除、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`。应用完整门禁继续遵循上层指引。
|
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
|
||||||
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
|
- 索引变更从后端根运行 `./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
|
vehiclePlate?: string
|
||||||
status?: string
|
status?: string
|
||||||
fbaAppointmentNo?: 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 {
|
export interface DispatchPalletCandidateDto {
|
||||||
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
|
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
|
||||||
returnReceiptId?: string | null
|
returnReceiptId?: string | null
|
||||||
@@ -86,6 +90,7 @@ export interface PreArrangeDispatchDraftResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DispatchSummaryDto {
|
export interface DispatchSummaryDto {
|
||||||
|
workflow?: DispatchWorkflow | null
|
||||||
id: string
|
id: string
|
||||||
dispatchNo: string
|
dispatchNo: string
|
||||||
vehiclePlate?: string | null
|
vehiclePlate?: string | null
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { http } from '@/shared/api/http'
|
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() } }))
|
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||||
|
|
||||||
describe('overseas inventory API', () => {
|
describe('overseas inventory API', () => {
|
||||||
beforeEach(() => { vi.clearAllMocks() })
|
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 () => {
|
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 } })
|
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))
|
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) {
|
export async function getInventorySummary(query: InventoryBalanceQuery) {
|
||||||
const response = await http.get<ApiEnvelope<InventoryDashboardDto>, ApiEnvelope<InventoryDashboardDto>>(
|
const response = await http.get<ApiEnvelope<InventoryDashboardDto>, ApiEnvelope<InventoryDashboardDto>>(
|
||||||
@@ -165,7 +162,3 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) {
|
|||||||
)
|
)
|
||||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { http } from '@/shared/api/http'
|
import { http } from '@/shared/api/http'
|
||||||
import { reviewCargoDiscard } from './lastMileOrders'
|
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() } }))
|
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'] }))
|
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 () => {
|
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: {} })
|
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'
|
import type { InboundPlanDetailDto } from './inboundPlans'
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export interface LastMileOrderQuery {
|
|||||||
orderNo?: string
|
orderNo?: string
|
||||||
relabelNo?: string
|
relabelNo?: string
|
||||||
orderType?: string
|
orderType?: string
|
||||||
|
sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE'
|
||||||
inboundCode?: string
|
inboundCode?: string
|
||||||
lxCode?: string
|
lxCode?: string
|
||||||
containerNo?: string
|
containerNo?: string
|
||||||
@@ -393,28 +394,6 @@ export async function reviewDirectRelabelOrder(orderId: string, customerId: stri
|
|||||||
return unwrapApiResponse(response)
|
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) {
|
export async function createLastMileOrderFee(orderId: string, payload: CreateLastMileFeePayload) {
|
||||||
const response = await http.post<ApiEnvelope<LastMileFeeDto>, ApiEnvelope<LastMileFeeDto>>(
|
const response = await http.post<ApiEnvelope<LastMileFeeDto>, ApiEnvelope<LastMileFeeDto>>(
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export interface RelabelItemDto {
|
|||||||
relabelFileUrl?: string | null
|
relabelFileUrl?: string | null
|
||||||
shipmentNo?: string | null
|
shipmentNo?: string | null
|
||||||
trackingCode?: string | null
|
trackingCode?: string | null
|
||||||
remark?: string | null
|
remark?: string
|
||||||
cartonMark?: string | null
|
cartonMark?: string | null
|
||||||
fbaCartonMark?: string | null
|
fbaCartonMark?: string | null
|
||||||
destinationWarehouseId?: string | null
|
destinationWarehouseId?: string | null
|
||||||
@@ -109,7 +109,7 @@ export interface RelabelHistoryDto {
|
|||||||
fromStatus?: string | null
|
fromStatus?: string | null
|
||||||
toStatus?: string | null
|
toStatus?: string | null
|
||||||
operatorId?: string | null
|
operatorId?: string | null
|
||||||
remark?: string | null
|
remark?: string
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,16 +137,24 @@ export interface ReturnPackingPlanBox {
|
|||||||
|
|
||||||
export interface ReturnPackingPlanPayload {
|
export interface ReturnPackingPlanPayload {
|
||||||
returnNos: string[]
|
returnNos: string[]
|
||||||
mode: 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
|
mode: 'BOX' | 'SKU' | 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
|
||||||
boxes: ReturnPackingPlanBox[]
|
boxes: ReturnPackingPlanBox[]
|
||||||
remark?: string
|
remark?: string
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
|
sourceCartonMarks?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReturnPackingPlanDto extends ReturnPackingPlanPayload {
|
export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> {
|
||||||
planNo: string
|
planNo: string
|
||||||
|
customerId?: string
|
||||||
|
overseasWarehouseId?: string
|
||||||
status: string
|
status: string
|
||||||
|
boxes: ReturnPackingPlanBox[]
|
||||||
|
remark?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
pushedAt?: string | null
|
pushedAt?: string | null
|
||||||
|
completedAt?: string | null
|
||||||
|
resultCartons?: import('./returnInventory').ReturnCartonDetail[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
|
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
|
||||||
@@ -156,6 +164,21 @@ export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload)
|
|||||||
return unwrapApiResponse(response) as ReturnPackingPlanDto
|
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) {
|
export async function listRelabelOrders(query: RelabelOrderQuery) {
|
||||||
const response = await http.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
|
const response = await http.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
|
||||||
'/platform/v1/last-leg/relabel-applications', { params: query },
|
'/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'
|
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 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; 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 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 ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }
|
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 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 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 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 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 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 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) }
|
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 DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
|
||||||
import DispatchesView from './DispatchesView.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))
|
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
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() }))
|
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([
|
it.each([
|
||||||
[[], ['查看', '打印BOL'], false],
|
[[], ['查看', '打印BOL'], false],
|
||||||
[['edit'], ['查看', '打印BOL', '编辑'], false],
|
[['edit'], ['查看', '打印BOL', '编辑'], false],
|
||||||
|
|||||||
@@ -31,6 +31,31 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
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>({
|
const filters = ref<QueryModel>({
|
||||||
dispatchNo: '',
|
dispatchNo: '',
|
||||||
vehiclePlate: '',
|
vehiclePlate: '',
|
||||||
@@ -168,6 +193,9 @@ const fields = computed<QueryField[]>(() => [
|
|||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 380, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 380, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
|
{ 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: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
|
||||||
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
|
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
|
||||||
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
|
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
|
||||||
@@ -228,6 +256,13 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
|
{ 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) {
|
function text(value: unknown) {
|
||||||
const normalized = String(value ?? '').trim()
|
const normalized = String(value ?? '').trim()
|
||||||
return normalized || '—'
|
return normalized || '—'
|
||||||
@@ -263,6 +298,7 @@ function appointmentLabel(numberValue: unknown, statusValue: unknown) {
|
|||||||
|
|
||||||
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
|
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
|
||||||
return {
|
return {
|
||||||
|
workflow: dto.workflow,
|
||||||
id: String(dto.id),
|
id: String(dto.id),
|
||||||
dispatchNo: text(dto.dispatchNo),
|
dispatchNo: text(dto.dispatchNo),
|
||||||
vehiclePlate: text(dto.vehiclePlate),
|
vehiclePlate: text(dto.vehiclePlate),
|
||||||
@@ -295,6 +331,7 @@ function dispatchError(error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load(model: QueryModel = filters.value) {
|
async function load(model: QueryModel = filters.value) {
|
||||||
|
const sequence = ++loadSequence
|
||||||
Object.assign(filters.value, model)
|
Object.assign(filters.value, model)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
@@ -302,21 +339,24 @@ async function load(model: QueryModel = filters.value) {
|
|||||||
const result = await listDispatches({
|
const result = await listDispatches({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
size: pageSize.value,
|
size: pageSize.value,
|
||||||
|
stage: stage.value || undefined,
|
||||||
dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined,
|
dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined,
|
||||||
vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined,
|
vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined,
|
||||||
status: String(filters.value.status || '').trim() || undefined,
|
status: String(filters.value.status || '').trim() || undefined,
|
||||||
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
|
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
|
||||||
})
|
})
|
||||||
|
if (sequence !== loadSequence) return
|
||||||
dispatchSnapshots.clear()
|
dispatchSnapshots.clear()
|
||||||
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
|
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
|
||||||
rows.value = result.records.map(toListItem)
|
rows.value = result.records.map(toListItem)
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (sequence !== loadSequence) return
|
||||||
rows.value = []
|
rows.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
loadError.value = dispatchError(error)
|
loadError.value = dispatchError(error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (sequence === loadSequence) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +541,13 @@ async function cancel(row: DispatchListItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function rowActions(row: DispatchListItem): RowActionItem[] {
|
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 [
|
return [
|
||||||
{ key: 'detail', label: '查看', priority: 300 },
|
{ key: 'detail', label: '查看', priority: 300 },
|
||||||
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
|
{ 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) {
|
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 === 'bol') void openBol(row)
|
||||||
else if (command === 'edit') openEdit(row)
|
else if (command === 'edit') openEdit(row)
|
||||||
else if (command === 'transfer') openTransfer(row)
|
else if (command === 'transfer') openTransfer(row)
|
||||||
@@ -589,11 +637,30 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
|
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
|
||||||
<OperationErrorToast :details="mutationError" />
|
<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 #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 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 #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 #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ArrowLeft, Download } from '@lucide/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 { 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 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 InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
|
||||||
import { saveCsv } from '@/shared/downloadCsv'
|
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime, inventoryBusinessTypeLabel } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types'
|
||||||
import { downloadInventoryLedger, 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 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 exportLoading = ref(false)
|
||||||
const relationshipDialogOpen = ref(false); const relationshipDialogInboundCode = ref('')
|
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 }
|
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 } }
|
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)
|
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>
|
</script>
|
||||||
|
|
||||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="进销存记录列表">
|
<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 #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 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')" />
|
<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>
|
</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>
|
<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">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { BookOpen, Download } from '@lucide/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 ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { downloadInventoryBalances, getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
|
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
|
||||||
import { saveCsv } from '@/shared/downloadCsv'
|
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||||
import { toInventoryListItem } from '../inventoryPresentation'
|
import { toInventoryListItem } from '../inventoryPresentation'
|
||||||
import { overseasWarehousePermissions } from '../permissions'
|
import { overseasWarehousePermissions } from '../permissions'
|
||||||
import type { InventoryListItem } from '../types'
|
import type { InventoryListItem } from '../types'
|
||||||
|
|
||||||
|
const submitExport = useExportTask()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
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 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] } }) }
|
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() })
|
onMounted(() => { void loadWarehouses(); void load() })
|
||||||
</script>
|
</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 #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 #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 #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>
|
<template #pagination-left>共 {{ total }} 条库存记录</template>
|
||||||
</DataTable></section>
|
</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 ElementPlus, { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
@@ -27,6 +29,7 @@ import LastMileOrdersView from './LastMileOrdersView.vue'
|
|||||||
|
|
||||||
const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
|
const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
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('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||||
vi.mock('../api/lastMileOrders', () => ({
|
vi.mock('../api/lastMileOrders', () => ({
|
||||||
@@ -34,7 +37,6 @@ vi.mock('../api/lastMileOrders', () => ({
|
|||||||
changeContainerReceivingWarehouse: vi.fn(),
|
changeContainerReceivingWarehouse: vi.fn(),
|
||||||
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
|
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||||
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
|
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
|
||||||
downloadLastMileOrders: vi.fn(),
|
|
||||||
reviewCargoDiscard: vi.fn(),
|
reviewCargoDiscard: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ async function mountView() {
|
|||||||
|
|
||||||
async function openPending() {
|
async function openPending() {
|
||||||
const wrapper = await mountView()
|
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 pendingTab!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -148,7 +150,7 @@ async function openPending() {
|
|||||||
|
|
||||||
async function openHiddenContainerWorkbench(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
async function openHiddenContainerWorkbench(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||||
const view = wrapper.vm as unknown as {
|
const view = wrapper.vm as unknown as {
|
||||||
statusTab: '全部' | '待分配' | '柜子'
|
statusTab: '全部' | '开船前' | '开船后' | '柜子'
|
||||||
changeStatusTab(): void
|
changeStatusTab(): void
|
||||||
}
|
}
|
||||||
view.statusTab = '柜子'
|
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 () => {
|
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
const tabs = wrapper.findAll('[role="tab"]')
|
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[0]!.attributes('aria-selected')).toBe('true')
|
||||||
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
|
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
|
||||||
await selectRows(wrapper, [selectedRow()])
|
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]!.attributes('aria-selected')).toBe('true')
|
||||||
expect(tabs[1]!.classes()).toContain('is-active')
|
expect(tabs[1]!.classes()).toContain('is-active')
|
||||||
expect(document.activeElement).toBe(tabs[1]!.element)
|
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([])
|
expect((wrapper.vm as unknown as { selectedRows: unknown[] }).selectedRows).toEqual([])
|
||||||
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
|
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
|
||||||
await tabs[1]!.trigger('click')
|
await tabs[1]!.trigger('click')
|
||||||
@@ -205,10 +207,31 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
|||||||
await tabs[1]!.trigger('keydown', { key: 'Home' })
|
await tabs[1]!.trigger('keydown', { key: 'Home' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
|
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 tabs[0]!.trigger('keydown', { key: 'End' })
|
||||||
await flushPromises()
|
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 () => {
|
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(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()
|
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']))
|
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()
|
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)
|
await openHiddenContainerWorkbench(wrapper)
|
||||||
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({
|
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useExportTask } from '@/modules/downloads'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/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 OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.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 OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||||
import { reviewCargoDiscard } from '../api/lastMileOrders'
|
import { reviewCargoDiscard } from '../api/lastMileOrders'
|
||||||
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
|
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
|
||||||
@@ -24,9 +25,8 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|||||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||||
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
|
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
|
||||||
import type { LastMileOrderListItem } from '../types'
|
import type { LastMileOrderListItem } from '../types'
|
||||||
import { saveCsv } from '@/shared/downloadCsv'
|
|
||||||
|
|
||||||
const rows = ref<LastMileOrderListItem[]>([])
|
const rows = ref<LastMileOrderListItem[]>([])
|
||||||
|
const submitExport = useExportTask()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
@@ -71,7 +71,7 @@ async function submitReturnPackingPlan() {
|
|||||||
}
|
}
|
||||||
const actionLoading = ref(false)
|
const actionLoading = ref(false)
|
||||||
const exportLoading = 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 actionError = ref<ApiErrorDetails | null>(null)
|
||||||
const actionTarget = ref<LastMileOrderListItem | null>(null)
|
const actionTarget = ref<LastMileOrderListItem | null>(null)
|
||||||
const inboundPlanOpen = ref(false)
|
const inboundPlanOpen = ref(false)
|
||||||
@@ -150,8 +150,8 @@ const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(nul
|
|||||||
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
|
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
|
||||||
let receivingWarehouseIdempotencyKey = ''
|
let receivingWarehouseIdempotencyKey = ''
|
||||||
let containerOptionRequest = 0
|
let containerOptionRequest = 0
|
||||||
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
|
const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部')
|
||||||
const statusTabs = ['全部', '待分配'] as const
|
const statusTabs = ['全部', '开船前', '开船后'] as const
|
||||||
const showAdvancedFilters = ref(false)
|
const showAdvancedFilters = ref(false)
|
||||||
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
||||||
const allFields: QueryField[] = [
|
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: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||||
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
|
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
|
||||||
]
|
]
|
||||||
|
const availableFields = computed(() => allFields.filter((field) => statusTab.value === '全部' || field.key !== 'orderType'))
|
||||||
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
|
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
|
||||||
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
|
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
|
||||||
: showAdvancedFilters.value
|
: showAdvancedFilters.value
|
||||||
? allFields
|
? availableFields.value
|
||||||
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
|
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
|
||||||
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
|
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
|
||||||
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
|
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
|
||||||
@@ -333,6 +334,29 @@ function changeContainerPageSize(value: number) {
|
|||||||
page.value = 1
|
page.value = 1
|
||||||
void load()
|
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) {
|
async function load(model: QueryModel = filters.value) {
|
||||||
Object.assign(filters.value, model)
|
Object.assign(filters.value, model)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -359,26 +383,7 @@ async function load(model: QueryModel = filters.value) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
|
const result = await listLastMileOrders(orderQuery())
|
||||||
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,
|
|
||||||
})
|
|
||||||
rows.value = result.records.map(toLastMileOrderListItem)
|
rows.value = result.records.map(toLastMileOrderListItem)
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1034,7 +1039,7 @@ onMounted(() => { void load() })
|
|||||||
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
|
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
|
||||||
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
|
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
|
||||||
</div>
|
</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>
|
<template v-if="statusTab !== '柜子'" #actions>
|
||||||
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
||||||
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
||||||
@@ -1088,7 +1093,7 @@ onMounted(() => { void load() })
|
|||||||
</div>
|
</div>
|
||||||
</section></template>
|
</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 #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 #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>
|
<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 { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import OutboundsView from './OutboundsView.vue'
|
import OutboundsView from './OutboundsView.vue'
|
||||||
|
import { listPlatformTenants } from '@/modules/iam'
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn() }))
|
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn() }))
|
||||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
@@ -48,6 +49,7 @@ async function selectBothPages() {
|
|||||||
|
|
||||||
describe('outbound candidate search and selection', () => {
|
describe('outbound candidate search and selection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||||
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
|
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
|
||||||
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
|
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
|
||||||
@@ -59,6 +61,24 @@ describe('outbound candidate search and selection', () => {
|
|||||||
})
|
})
|
||||||
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
|
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 () => {
|
it('selects and deselects the current page while preserving other pages, then submits all selected quantities', async () => {
|
||||||
await openCreate()
|
await openCreate()
|
||||||
expect(wrapper.text()).toContain('可出毛重(kg)')
|
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 } }
|
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 || '—' }
|
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() {
|
async function loadInventoryOptions() {
|
||||||
inventoryLoading.value = true
|
inventoryLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [tenants, warehouses, fbaWarehouses] = await Promise.allSettled([
|
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: 'OVERSEAS', status: 'ACTIVE' }),
|
||||||
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'FBA', 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)
|
else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message)
|
||||||
if (warehouses.status === 'fulfilled') {
|
if (warehouses.status === 'fulfilled') {
|
||||||
warehouseOptionsSource.value = warehouses.value.records
|
warehouseOptionsSource.value = warehouses.value.records
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import DataTable from '@/shared/components/DataTable.vue'
|
import DataTable from '@/shared/components/DataTable.vue'
|
||||||
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
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 ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
|
||||||
|
import { listPlatformTenants } from '@/modules/iam'
|
||||||
|
|
||||||
vi.mock('@/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: '测试仓库' }]),
|
listSharedReceivingOverseasWarehouseOptions: vi.fn().mockResolvedValue([{ id: 'warehouse-1', warehouseName: '测试仓库' }]),
|
||||||
}))
|
}))
|
||||||
vi.mock('../api/returnInventory', () => ({
|
vi.mock('../api/returnInventory', () => ({
|
||||||
listReturnStock: vi.fn(), listReturnCartons: vi.fn(), listReturnInventoryMovements: vi.fn(),
|
listReturnStock: vi.fn(), listReturnCartons: vi.fn(), listReturnInventoryMovements: vi.fn(),
|
||||||
getReturnCarton: vi.fn(), getReturnCartonLabel: 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 wrappers: VueWrapper[] = []
|
||||||
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, cartonQuantity: 1, updatedAt: '2026-09-30' }
|
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(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(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(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(() => {
|
afterEach(() => {
|
||||||
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
|
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
|
||||||
@@ -41,6 +53,140 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ReturnInventoryManagementView shared list', () => {
|
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 () => {
|
it('keeps query submission and server pagination when using the shared table', async () => {
|
||||||
const wrapper = await mountView('inventory')
|
const wrapper = await mountView('inventory')
|
||||||
expect(wrapper.get('h1').text()).toBe('退货库存管理')
|
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("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
|
||||||
expect(value).toContain('请选择目的 FBA 仓')
|
expect(value).toContain('请选择目的 FBA 仓')
|
||||||
expect(value).toContain('aria-label="尾程订单状态"')
|
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"')
|
expect(value).toContain('class="density-status-tabs" role="tablist"')
|
||||||
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
|
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
|
||||||
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
|
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const overseasWarehousePermissions = {
|
|||||||
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
|
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
|
||||||
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
|
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
|
||||||
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
|
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',
|
dispatchList: 'platform:overseas-warehouse:dispatch:list',
|
||||||
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
|
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
|
||||||
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
|
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export const overseasWarehouseRoutes = [
|
|||||||
component: () => import('./pages/InboundOrdersView.vue'),
|
component: () => import('./pages/InboundOrdersView.vue'),
|
||||||
meta: { showPageHeading: false, title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList },
|
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',
|
path: 'overseas-warehouse/inbound-orders/:orderId',
|
||||||
name: 'overseas-inbound-order-detail',
|
name: 'overseas-inbound-order-detail',
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface InboundOrderListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DispatchListItem {
|
export interface DispatchListItem {
|
||||||
|
workflow?: import('./api/dispatches').DispatchWorkflow | null
|
||||||
id: string
|
id: string
|
||||||
dispatchNo: string
|
dispatchNo: string
|
||||||
vehiclePlate: string
|
vehiclePlate: string
|
||||||
|
|||||||
@@ -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', () => {
|
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 tableCss = parse(read('shared/components/DataTable.vue')).descriptor.styles.map(style => style.content).join('\n')
|
||||||
const dimensions: Record<string, string[]> = { padding: [], 'min-height': [] }
|
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')
|
expect(actionWeights.get('.admin-text-actions.el-popper .el-dropdown-menu__item')).toBe('400')
|
||||||
const dispatches = read('modules/overseas-warehouse/pages/DispatchesView.vue')
|
const dispatches = read('modules/overseas-warehouse/pages/DispatchesView.vue')
|
||||||
expect(dispatches).toContain('appearance="integrated"')
|
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"')
|
expect(dispatches).not.toContain('class="surface-panel table-panel"')
|
||||||
const calendar = read('modules/sailing-calendar/pages/SailingCalendarView.vue')
|
const calendar = read('modules/sailing-calendar/pages/SailingCalendarView.vue')
|
||||||
expect(calendar).toContain('appearance="integrated" :data="[]" :columns="[]" column-settings-mode="hide"')
|
expect(calendar).toContain('appearance="integrated" :data="[]" :columns="[]" column-settings-mode="hide"')
|
||||||
|
|||||||
Reference in New Issue
Block a user