Compare commits

...
36 Commits
Author SHA1 Message Date
liuyihe a2ef3542f4 优化 2026-10-29 15:49:53 +08:00
liuyihe 07a160e843 优化 2026-10-29 14:31:47 +08:00
liuyihe 8970ad52fa 优化 2026-10-29 13:37:22 +08:00
ouyangxq ac2ecb4663 优化 2026-10-28 17:18:01 +08:00
ouyangxq d990637b38 统一履约状态和节点日志展示并整理装柜与尾程界面 2026-10-28 16:56:32 +08:00
liuyihe a52a6620fc Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-28 14:45:12 +08:00
liuyihe 1fc806ea7f 优化 2026-10-28 14:45:10 +08:00
ouyangxq 4fdba0fb5d 修正采购出库费用提示测试的空值类型 2026-10-28 14:12:23 +08:00
liuyihe 4581130b62 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-28 13:59:40 +08:00
liuyihe 6464935014 优化 2026-10-28 13:59:39 +08:00
ouyangxq 39b9a6a1a0 完善预约确认与排车表单并统一计费和筛选展示 2026-10-28 12:01:43 +08:00
liuyihe 0c3f4ece2a Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-28 11:30:02 +08:00
liuyihe ee7386f401 优化 2026-10-28 11:30:00 +08:00
ouyangxq 6f8dc920ca 更新计费预览与测量精度并精简作业界面 2026-10-28 11:06:42 +08:00
ouyangxq e41e9e6d3f 统一后台状态列顺序、详情布局与测量计费展示 2026-10-28 10:32:33 +08:00
liuyihe 8e97fd6a9f 优化 2026-10-27 18:30:33 +08:00
liuyihe fbb7d4fe24 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-27 17:45:56 +08:00
liuyihe 5c162bf6c5 Merge origin/release/test and combine transport photo workflows 2026-10-27 17:45:43 +08:00
ouyangxq 8451607f6c 优化 2026-10-27 17:44:49 +08:00
liuyihe c148912b73 优化 2026-10-27 17:39:19 +08:00
ouyangxq 3464f35a01 fix(admin): 完善船期日历刷新与交互状态 2026-10-27 16:56:20 +08:00
ouyangxq b3ab87f22c style(finance): unify list and detail layouts and receipt controls 2026-10-27 16:55:34 +08:00
ouyangxq b9b930936d fix(finance): show payable settlement progress in sources 2026-10-27 12:08:56 +08:00
ouyangxq 392f94e95c fix(dispatch): guard unsaved evidence without disabling uploads 2026-10-27 10:17:05 +08:00
ouyangxq 4595dfef34 fix(dispatch): present loading evidence as a single-column list 2026-10-27 08:33:01 +08:00
ouyangxq 0137fd3a4c fix(dispatch): reject stale loading document upload rounds 2026-10-26 19:21:13 +08:00
ouyangxq a9dc19a86b 优化 2026-10-26 18:52:21 +08:00
ouyangxq 22242701e4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-26 17:27:28 +08:00
ouyangxq ca7095af4c 优化 2026-10-26 17:27:27 +08:00
admin 04b099249e Merge pull request '同步排车档案照片与装车多图至 TEST' (#30) from hotfix-gray-bugfix into release/test 2026-10-26 17:26:12 +08:00
ouyangxq 48d6c31397 test(admin): isolate merged inbound fees and shortage components 2026-10-26 17:23:38 +08:00
ouyangxq b06d46b0b3 merge: integrate TEST changes with fleet photo updates 2026-10-26 17:21:14 +08:00
ouyangxq b5d8ccdffe Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-26 13:58:54 +08:00
ouyangxq 6580853ad3 优化 2026-10-26 13:58:52 +08:00
liuyihe 32c6a0ad7b Merge origin/release/test and preserve procurement direction with request unlock 2026-10-26 13:51:37 +08:00
liuyihe 18a78e5508 费用优化 2026-10-26 13:43:08 +08:00
288 changed files with 7533 additions and 1530 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 | | 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` | | 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 | | 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ACTUAL/ADD/MULTIPLY`;未启用默认调整规则时按实测原尺寸计算,仓库修正值替换对应实测边。测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的箱数、毛重汇总当前 `actualMeasurementRows`,计费体积默认取服务端已保存的 `outerMeasurementRows` 汇总;通过只读 `/fees/preview` 刷新当前重量、体积和人工单价的计费量与金额,预览未完成或失败时不得发送;最终按用户确认值提交,体积来源标记为 `OUTER`;不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 | | 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
| 入库单扣货、解除、凭证与历史 | `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` 状态 |
+3 -3
View File
@@ -49,14 +49,14 @@ 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: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
]) ])
expect(visible).toHaveLength(13) expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58) expect(visible.flatMap(node => node.children ?? [])).toHaveLength(60)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
}) })
+2 -1
View File
@@ -157,7 +157,7 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: PrototypeCustomerServiceIcon, icon: PrototypeCustomerServiceIcon,
children: [ children: [
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' }, { key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true }, { key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
], ],
}, },
{ {
@@ -165,6 +165,7 @@ export const adminNavigation: AdminNavigationNode[] = [
title: '财务管理', title: '财务管理',
icon: PrototypeFinanceIcon, icon: PrototypeFinanceIcon,
children: [ children: [
{ key: 'overseas-fees', title: '海外费用明细', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' }, { key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' }, { key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' }, { key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
@@ -11,6 +11,7 @@ import { batchContext } from './batch.test-support'
import type { ScheduleEvent } from './schedule' import type { ScheduleEvent } from './schedule'
import { getWorkDetail, type WorkAction } from './workApi' import { getWorkDetail, type WorkAction } from './workApi'
import { workDetail, workItem } from './work.test-support' import { workDetail, workItem } from './work.test-support'
import { getCargoVerification } from './cargoVerificationApi'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() })) const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
@@ -19,6 +20,7 @@ vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArriv
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() })) vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() })) vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() })) vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals() }) afterEach(() => { vi.unstubAllGlobals() })
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true } const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
@@ -35,6 +37,7 @@ beforeEach(() => {
vi.mocked(saveWarehouseArrival).mockResolvedValue({}) vi.mocked(saveWarehouseArrival).mockResolvedValue({})
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }) vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(getCargoVerification).mockResolvedValue({ source, verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: ['FBA-TEST'], availablePackages: 20, sourceChanged: false, allowedActions: [], blockReasons: [] })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview) vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment) vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(getWorkDetail).mockResolvedValue(workDetail()) vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
@@ -52,6 +55,7 @@ async function create(wrapper: Awaited<ReturnType<typeof open>>) {
const content = creationBody() const content = creationBody()
await content.get('input[type="checkbox"]').setValue(true) await content.get('input[type="checkbox"]').setValue(true)
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises() await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises()
return dialog return dialog
} }
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) } function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
@@ -72,17 +76,19 @@ it('keeps the arrival context and typed local time across native fullscreen and
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String)) expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
}) })
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => { it('keeps the real arrival input editable after a failed attempt and submits the edited time with a fresh key', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost')) vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises() const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')! const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises() dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(dialog.get('input').attributes('disabled')).toBeDefined() expect(dialog.get('input').attributes('disabled')).toBeUndefined()
expect(dialog.text()).toContain('上次提交结果尚未确认') expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('保存')
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果') dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-23T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0]) const calls = vi.mocked(saveWarehouseArrival).mock.calls
expect(calls[1]![2]).not.toBe(calls[0]![2])
expect(calls[1]![1]).toEqual({ estimatedWarehouseArrivalAt: '2026-10-23T09:30:00', expectedVersion: 3 })
}) })
it('clears the Element arrival picker without saving an empty required time', async () => { it('clears the Element arrival picker without saving an empty required time', async () => {
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises() const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
@@ -114,7 +120,7 @@ it('preserves real FBA fields and entered values when expanding the create dialo
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(date.props('modelValue')).toBe('2026-10-25') expect(date.props('modelValue')).toBe('2026-10-25')
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) })) expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }), idempotencyKey: expect.any(String) }))
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0) expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001') expect(creationBody().text()).toContain('AP-001')
}) })
@@ -141,9 +147,10 @@ it('uses the same real continuous form through the original work detail deep lin
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises() const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!) const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
await content.get('.primary-next-action').trigger('click'); await flushPromises() await content.get('.primary-next-action').trigger('click'); await flushPromises()
await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true) expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : []) expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) })) expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }) }))
expect(content.text()).toContain('本次保存已完成') expect(content.text()).toContain('本次保存已完成')
}) })
@@ -12,7 +12,7 @@ vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanning
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() })) vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] } const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } } const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] } const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: [], availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey } const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
function render(active = true, disabled = false) { function render(active = true, disabled = false) {
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: { return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
@@ -52,8 +52,10 @@ it('leaves pending arrangements lazy and reads only when formal confirmation is
}) })
it('does not ask for known materials to be entered again', async () => { it('does not ask for known materials to be entered again', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } }) vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] })
const wrapper = render(); await flushPromises() const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('资料齐全,无需重复填写') expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.text()).toContain('FBA-ORIGINAL、TRACK-ORIGINAL')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false) expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount() expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
}) })
@@ -68,11 +68,12 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button> <el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p> <p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody> <table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>FBA 货件号 / 追踪号</th><th>需要补充的资料</th></tr></thead><tbody>
<tr v-for="row in rows" :key="key(row)"> <tr v-for="row in rows" :key="key(row)">
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td> <td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td> <td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td> <td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
<td>{{ row.cargo.effectiveShipmentReferences.join('、') || '—' }}</td>
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td> <td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
</tr> </tr>
</tbody></table> </tbody></table>
@@ -37,12 +37,12 @@ let generation = 0, selectionGeneration = 0, controller: AbortController | null
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'select', label: '选择', width: 60, slotName: 'select' }, { key: 'select', label: '选择', width: 60, slotName: 'select' },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility', afterColumn: 'inboundCode' },
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' }, { prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
{ prop: 'containerNo', label: '柜号', minWidth: 145 }, { prop: 'containerNo', label: '柜号', minWidth: 145 },
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' }, { prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 }, { prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 }, { prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
] ]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) } function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) { function reason(row: PlanningCargo) {
@@ -131,11 +131,10 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr> <tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr> <tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr> <tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr> <tr v-if="createdForm.appointmentAt || createdForm.appointmentEndAt"><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr> <tr v-if="createdForm.startOffset || createdForm.endOffset"><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr> <tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table> </tbody></table>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
</section> </section>
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div> <div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
</template> </template>
@@ -10,7 +10,7 @@ vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCa
const source = { sourceKey: 'ORDER:9007199254740993', customerId: 'customer-1', destinationKey: 'FBA:ONT8' } const source = { sourceKey: 'ORDER:9007199254740993', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }
const context: CargoVerificationContext = { const context: CargoVerificationContext = {
source: { ...source, orderId: '9007199254740993', inboundCode: 'IN-TEST-001', bookingNo: null, containerNo: 'TEST-CN-001', originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null, canFollowup: true, blockReasons: [] }, source: { ...source, orderId: '9007199254740993', inboundCode: 'IN-TEST-001', bookingNo: null, containerNo: 'TEST-CN-001', originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null, canFollowup: true, blockReasons: [] },
verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [], verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, effectiveShipmentReferences: [], availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [],
} }
function render() { return mount(CargoVerificationForm, { props: { source }, global: { stubs: { ElButton: { props: ['nativeType'], template: '<button :type="nativeType"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true } } }) } function render() { return mount(CargoVerificationForm, { props: { source }, global: { stubs: { ElButton: { props: ['nativeType'], template: '<button :type="nativeType"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true } } }) }
beforeEach(() => { beforeEach(() => {
@@ -26,6 +26,15 @@ it('saves independent cargo verification before any appointment exists, preservi
expect(vi.mocked(saveCargoVerification).mock.calls[0]![1]).not.toHaveProperty('appointmentStatus') expect(vi.mocked(saveCargoVerification).mock.calls[0]![1]).not.toHaveProperty('appointmentStatus')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
}) })
it('prefills existing order shipment and tracking numbers without creating a verification record', async () => {
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: 20, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] })
const wrapper = render(); await flushPromises()
expect((wrapper.get('textarea[aria-label="本票货件号"]').element as HTMLTextAreaElement).value).toBe('FBA-ORIGINAL\nTRACK-ORIGINAL')
expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ shipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] }))
wrapper.unmount()
})
it('uses the same key and payload after an unknown save outcome', async () => { it('uses the same key and payload after an unknown save outcome', async () => {
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('network lost')) vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('network lost'))
const wrapper = render(); await flushPromises() const wrapper = render(); await flushPromises()
@@ -27,7 +27,7 @@ async function load(preserve = false) {
context.value = await getCargoVerification(props.source) context.value = await getCargoVerification(props.source)
if (!preserve) { if (!preserve) {
const record = context.value.verification const record = context.value.verification
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' }) Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: context.value.effectiveShipmentReferences.join('\n'), remark: record?.remark || '' })
baseline.value = JSON.stringify(form) baseline.value = JSON.stringify(form)
} }
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') } } catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
@@ -26,7 +26,7 @@ it('shows readable preview requirements without exposing missing-field machine c
}) })
async function confirmRegistration(wrapper: ReturnType<typeof render>) { async function confirmRegistration(wrapper: ReturnType<typeof render>) {
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' }) const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-16T09:30:00' }) Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED' })
fields.vm.$emit('confirmationReady', true) fields.vm.$emit('confirmationReady', true)
await flushPromises() await flushPromises()
} }
@@ -35,7 +35,7 @@ it('previews and creates the exact canonical external source and customer with r
await confirmRegistration(wrapper) await confirmRegistration(wrapper)
expect(previewPlanningBatch).toHaveBeenCalledWith(sources) expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises() await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-CONFIRMED' }) })) expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: null, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-CONFIRMED' }) }))
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).not.toContain('external-1'); wrapper.unmount() expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).not.toContain('external-1'); wrapper.unmount()
}) })
it('retains the same immutable request after an unknown outcome and blocks leaving', async () => { it('retains the same immutable request after an unknown outcome and blocks leaving', async () => {
@@ -63,7 +63,7 @@ onMounted(load)
</tbody></table> </tbody></table>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p> <p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section> </section>
<FbaAppointmentFields continuous ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" /> <FbaAppointmentFields continuous hide-confirmation-times ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState> <LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div> <div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template> </template>
@@ -55,7 +55,7 @@ onBeforeUnmount(() => { generation++ })
<section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3> <section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3>
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p> <p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table> <table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>阶段</th><th>车牌号</th><th>箱数</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ stageLabel(row.stage) }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td></tr></tbody></table>
<WorkSharedTripForm v-if="addingNo" :key="addingNo" :dispatch-no="addingNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="updated" @external="value => emit('external', value)" /> <WorkSharedTripForm v-if="addingNo" :key="addingNo" :dispatch-no="addingNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="updated" @external="value => emit('external', value)" />
</section> </section>
</template> </template>
@@ -48,7 +48,7 @@ onMounted(load)
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template> <template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button> <el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
<LoadErrorState v-if="previewError" title="入库计划预览失败" :message="previewError.message" :loading="previewLoading" @retry="loadPreview"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState> <LoadErrorState v-if="previewError" title="入库计划预览失败" :message="previewError.message" :loading="previewLoading" @retry="loadPreview"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState>
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div> <div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>计划状态</th><th>目的地</th><th>箱数</th><th>目标托数</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
<LoadErrorState v-if="saveError" title="入库计划尚未确认" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState> <LoadErrorState v-if="saveError" title="入库计划尚未确认" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
</section> </section>
</template> </template>
@@ -452,7 +452,7 @@ it('shows authoritative subset blockers and ignores a late response for a previo
}) })
function verifiedCargo(): CargoVerificationContext { function verifiedCargo(): CargoVerificationContext {
return { source: { sourceKey: 'BOOKING:2', customerId: 'customer-b', orderId: null, inboundCode: 'IN-B', bookingNo: null, containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '本仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 40, sourceVersion: 'source-latest', canFollowup: true, blockReasons: [] }, verification: { version: 2, sourceVersion: 'source-latest', destinationKey: 'FBA:ONT8', sourcePackages: 40, packageCount: 40, shipmentReferences: ['FBA-NEW-B'], remark: null, operatorName: '核实人员', verifiedAt: '2026-10-16T10:00:00Z' }, allocatedPackages: 30, unknownAllocationCount: 0, effectivePackages: 40, availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] } return { source: { sourceKey: 'BOOKING:2', customerId: 'customer-b', orderId: null, inboundCode: 'IN-B', bookingNo: null, containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '本仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 40, sourceVersion: 'source-latest', canFollowup: true, blockReasons: [] }, verification: { version: 2, sourceVersion: 'source-latest', destinationKey: 'FBA:ONT8', sourcePackages: 40, packageCount: 40, shipmentReferences: ['FBA-NEW-B'], remark: null, operatorName: '核实人员', verifiedAt: '2026-10-16T10:00:00Z' }, allocatedPackages: 30, unknownAllocationCount: 0, effectivePackages: 40, effectiveShipmentReferences: ['FBA-NEW-B'], availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
} }
async function enterRefresh(wrapper: ReturnType<typeof render>) { async function enterRefresh(wrapper: ReturnType<typeof render>) {
await flushPromises(); await wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')[1]!.trigger('click'); await flushPromises() await flushPromises(); await wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')[1]!.trigger('click'); await flushPromises()
@@ -1,7 +1,7 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { PlanningSource } from './planningApi' import type { PlanningSource } from './planningApi'
export interface CargoVerification { version: number; sourceVersion: string | null; destinationKey: string; sourcePackages: number | null; packageCount: number | null; shipmentReferences: string[]; remark: string | null; operatorName: string | null; verifiedAt: string } export interface CargoVerification { version: number; sourceVersion: string | null; destinationKey: string; sourcePackages: number | null; packageCount: number | null; shipmentReferences: string[]; remark: string | null; operatorName: string | null; verifiedAt: string }
export interface CargoVerificationContext { source: PlanningSource; verification: CargoVerification | null; allocatedPackages: number; unknownAllocationCount: number; effectivePackages: number | null; availablePackages: number | null; sourceChanged: boolean; allowedActions: string[]; blockReasons: string[] } export interface CargoVerificationContext { source: PlanningSource; verification: CargoVerification | null; allocatedPackages: number; unknownAllocationCount: number; effectivePackages: number | null; effectiveShipmentReferences: string[]; availablePackages: number | null; sourceChanged: boolean; allowedActions: string[]; blockReasons: string[] }
export interface CargoVerificationSource { sourceKey: string; customerId: string; destinationKey?: string } export interface CargoVerificationSource { sourceKey: string; customerId: string; destinationKey?: string }
export interface SaveCargoVerification { destinationKey: string; expectedSourceVersion: string | null; expectedVersion: number; packageCount: number | null; shipmentReferences: string[]; remark: string | null; idempotencyKey: string } export interface SaveCargoVerification { destinationKey: string; expectedSourceVersion: string | null; expectedVersion: number; packageCount: number | null; shipmentReferences: string[]; remark: string | null; idempotencyKey: string }
const path = (key: string) => `/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(key)}/cargo-verification` const path = (key: string) => `/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(key)}/cargo-verification`
@@ -215,7 +215,7 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('覆盖预录单') expect(wrapper.text()).toContain('覆盖预录单')
expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一') expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一')
wrapper.unmount() wrapper.unmount()
wrapper = await render('DECLARING', false, true) wrapper = await render('DECLARING', false, true)
@@ -237,10 +237,12 @@ describe('ContainerCustomsPanel', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => { it('隐藏预录单历史和资料版本,保留当前确认状态、原文件名和上传并发版本', async () => {
const value = customs('DECLARING', true) const value = customs('DECLARING', true)
const document = value.documents[0]! const document = value.documents[0]!
document.receipt!.version = 7 document.receipt!.version = 7
document.receiptStatus = 'CONFIRMED'
document.confirmationHistory.unshift({ id: '2092438843630391413', receiptVersion: 6, action: 'REJECT', reason: '请更新预录单', operatorName: '客户一', createdAt: '2026-08-29T10:55:00' })
document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }] document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }]
api.get.mockResolvedValue(value) api.get.mockResolvedValue(value)
const wrapper = mount(ContainerCustomsPanel, { const wrapper = mount(ContainerCustomsPanel, {
@@ -248,10 +250,13 @@ describe('ContainerCustomsPanel', () => {
global: { plugins: [ElementPlus] }, global: { plugins: [ElementPlus] },
}) })
await flushPromises() await flushPromises()
expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '客户退税资料', '预录单', '真实报关完税文件', '关联入仓码', '报关行状态', '客户', '预录单情况']) expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '关联入仓码', '报关行状态', '客户退税资料', '预录单', '真实报关完税文件', '客户', '预录单情况'])
expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/) expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/)
expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员') expect(wrapper.findAll('.confirmation-history-note, .receipt-history-note')).toHaveLength(0)
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一') expect(wrapper.text()).not.toContain('预录单历史')
expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一')
expect(wrapper.text()).not.toContain('请更新预录单')
expect(wrapper.text()).toContain('客户已确认预录单')
expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true) expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true)
const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' }) const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' })
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
@@ -79,9 +79,6 @@ const statusText: Record<ContainerCustomsStatus, string> = {
function documentCustomers(document: ContainerCustomsDocument) { function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—' return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
} }
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
return action === 'CONFIRM' ? '确认' : '打回'
}
function formatDateTime(value: string | null | undefined) { function formatDateTime(value: string | null | undefined) {
if (!value) return '—' if (!value) return '—'
const date = new Date(value) const date = new Date(value)
@@ -321,13 +318,13 @@ onMounted(load)
</div> </div>
<div v-if="customs.documents.length" class="compact-table-wrap"> <div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料、预录单与完税文件</h3> <h3>已冻结退税资料、预录单与完税文件</h3>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>客户</th><th>预录单情况</th></tr></thead><tbody> <div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>关联入仓码</th><th>报关行状态</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId"> <tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td> <td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td>
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td> <td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td> <td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td><td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td><td>{{ documentCustomers(document) }}</td><td><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small><small v-for="history in document.receiptHistory" :key="history.version" class="receipt-history-note">预录单历史:{{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}</small>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
</tr> </tr>
</tbody></table></div> </tbody></table></div>
</div> </div>
@@ -363,5 +360,5 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--workbench-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)} .container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
</style> </style>
@@ -176,12 +176,12 @@ onMounted(load)
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column> <el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column> <el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column> <el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column> <el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
<el-table-column prop="supplierName" label="供应商" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.supplierName || '—' }}</template></el-table-column> <el-table-column prop="supplierName" label="供应商" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.supplierName || '—' }}</template></el-table-column>
<el-table-column prop="amount" label="金额" min-width="130" align="right"><template #default="{ row }">¥{{ row.amount.toFixed(2) }}</template></el-table-column> <el-table-column prop="amount" label="金额" min-width="130" align="right"><template #default="{ row }">¥{{ row.amount.toFixed(2) }}</template></el-table-column>
<el-table-column label="付款凭证" min-width="180"><template #default="{ row }"><div v-if="row.paymentVoucherFileIds.length" class="voucher-list"><el-button v-for="(fileId, index) in (row.paymentVoucherFileIds as string[])" :key="fileId" link type="primary" @click="previewVoucher(row.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button></div><span v-else>—</span></template></el-table-column> <el-table-column label="付款凭证" min-width="180"><template #default="{ row }"><div v-if="row.paymentVoucherFileIds.length" class="voucher-list"><el-button v-for="(fileId, index) in (row.paymentVoucherFileIds as string[])" :key="fileId" link type="primary" @click="previewVoucher(row.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button></div><span v-else>—</span></template></el-table-column>
<el-table-column prop="businessDate" label="业务日期" width="120" /> <el-table-column prop="businessDate" label="业务日期" width="120" />
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column> <el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column> <el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table> </el-table>
@@ -10,7 +10,7 @@ vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: v
const overview: ContainerOverview = { const overview: ContainerOverview = {
container: { id: '7', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 }, container: { id: '7', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
loadingPlanId: '9', loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] }, pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_1', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_1', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }], documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' }, exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' },
exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }], exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }],
@@ -26,14 +26,19 @@ describe('ContainerOverviewPanel', () => {
}) })
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => { it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) const types = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const pda = { ...overview.pda!, photoCounts: types.map(evidenceType => ({ evidenceType, count: 1 })), photos: types.map((evidenceType, index) => ({
...overview.pda!.photos[0]!, evidenceId: String(20 + index), fileId: String(23 + index), evidenceType,
downloadPath: `/api/platform/v1/containers/7/pda/photos/${23 + index}/download`,
})) }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('DO 资料') expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf') expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度') expect(wrapper.text()).toContain('PDA 作业进度')
expect(wrapper.text()).toContain('待人工复核') expect(wrapper.text()).toContain('待人工复核')
expect(wrapper.text()).toContain('空柜照片') expect(wrapper.findAll('.photo-caption strong').map(caption => caption.text())).toEqual(['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照'])
expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER') expect(wrapper.text()).not.toContain('PHOTO_STEP_')
expect(wrapper.text()).toContain('一项待复核') expect(wrapper.text()).toContain('一项待复核')
expect(wrapper.text()).toContain('码头查验') expect(wrapper.text()).toContain('码头查验')
expect(wrapper.text()).not.toContain('更新预计到港时间') expect(wrapper.text()).not.toContain('更新预计到港时间')
@@ -58,8 +63,10 @@ describe('ContainerOverviewPanel', () => {
it('renders two file rows and one inline PU code row without a nested heading', async () => { it('renders two file rows and one inline PU code row without a nested heading', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text()) const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
expect(headers).toEqual(['操作', '资料类型', '资料内容', '更新人', '更新时间', '状态']) expect(headers).toEqual(['操作', '资料类型', '状态', '资料内容', '更新人', '更新时间'])
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3) expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3)
expect(wrapper.findAll('.document-table tbody tr').every(row => row.findAll('td')[2]!.find('.el-tag').exists())).toBe(true)
expect(wrapper.find('.pickup-code-row').findAll('td')[3]!.find('input[aria-label="PU码"]').exists()).toBe(true)
expect(wrapper.findAll('.document-item')).toHaveLength(0) expect(wrapper.findAll('.document-item')).toHaveLength(0)
expect(wrapper.find('.document-table-wrap').exists()).toBe(true) expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
expect(wrapper.text()).not.toContain('业务资料') expect(wrapper.text()).not.toContain('业务资料')
@@ -77,8 +77,8 @@ const workflowStatusLabels: Record<string, string> = {
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成', LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
} }
const evidenceTypeLabels: Record<string, string> = { const evidenceTypeLabels: Record<string, string> = {
PHOTO_STEP_EMPTY_CONTAINER: '空柜照片', PHOTO_STEP_HALF_LOADED: '半柜照片', PHOTO_STEP_FULL_CONTAINER: '满柜照片', PHOTO_STEP_1: '空仓照片', PHOTO_STEP_2: '半仓照片', PHOTO_STEP_3: '满仓照片',
EMPTY_CONTAINER: '空柜照片', HALF_LOADED: '半柜照片', FULL_CONTAINER: '满柜照片', SEAL: '封条照片', PHOTO_STEP_4: '锁仓照片', SEAL: '带锁码照',
} }
function workflowStatusLabel(workflowStatus: string | null | undefined, taskStatus: string) { return workflowStatusLabels[workflowStatus || taskStatus] || '作业状态待同步' } function workflowStatusLabel(workflowStatus: string | null | undefined, taskStatus: string) { return workflowStatusLabels[workflowStatus || taskStatus] || '作业状态待同步' }
function evidenceTypeLabel(value: string) { return evidenceTypeLabels[value] || '装柜照片' } function evidenceTypeLabel(value: string) { return evidenceTypeLabels[value] || '装柜照片' }
@@ -130,21 +130,21 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料"> <section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
<div class="document-table-wrap"> <div class="document-table-wrap">
<div class="workbench-evidence"><table class="document-table density-table"> <div class="workbench-evidence"><table class="document-table density-table">
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>资料内容</th><th>更新人</th><th>更新时间</th><th>状态</th></tr></thead> <thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>状态</th><th>资料内容</th><th>更新人</th><th>更新时间</th></tr></thead>
<tbody><tr v-for="item in documentTypes" :key="item.type"> <tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions density-actions"><el-button class="density-action" v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)">下载</el-button><el-button class="density-action" v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)">{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td> <td class="document-table__actions"><div class="document-actions density-actions"><el-button class="density-action" v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)">下载</el-button><el-button class="density-action" v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)">{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong></td> <td><strong>{{ item.label }}</strong></td>
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span><small v-if="item.type === 'DELIVERY_ORDER' && deliveryOrderBlockedReason" class="document-blocked-reason">{{ deliveryOrderBlockedReason }}</small></td> <td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span><small v-if="item.type === 'DELIVERY_ORDER' && deliveryOrderBlockedReason" class="document-blocked-reason">{{ deliveryOrderBlockedReason }}</small></td>
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td> <td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td> <td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
</tr><tr class="pickup-code-row"> </tr><tr class="pickup-code-row">
<td class="document-table__actions"><el-button data-tone="primary" class="density-action" v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td> <td class="document-table__actions"><el-button data-tone="primary" class="density-action" v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td>
<td><strong>PU 资料</strong></td> <td><strong>PU 资料</strong></td>
<td><el-tag size="small" :type="overview.container.pickupCode ? 'success' : 'info'">{{ overview.container.pickupCode ? '已填写' : '未填写' }}</el-tag></td>
<td><el-input v-model="pickupCodeInput" :disabled="!canManageDocuments" maxlength="64" inputmode="numeric" placeholder="请输入数字 PU 码" aria-label="PU码" @input="pickupCodeInput = pickupCodeInput.replace(/\D/g, '')" /></td> <td><el-input v-model="pickupCodeInput" :disabled="!canManageDocuments" maxlength="64" inputmode="numeric" placeholder="请输入数字 PU 码" aria-label="PU码" @input="pickupCodeInput = pickupCodeInput.replace(/\D/g, '')" /></td>
<td>{{ overview.container.pickupCodeUpdatedByName || '—' }}</td> <td>{{ overview.container.pickupCodeUpdatedByName || '—' }}</td>
<td>{{ formatTime(overview.container.pickupCodeUpdatedAt) }}</td> <td>{{ formatTime(overview.container.pickupCodeUpdatedAt) }}</td>
<td><el-tag size="small" :type="overview.container.pickupCode ? 'success' : 'info'">{{ overview.container.pickupCode ? '已填写' : '未填写' }}</el-tag></td>
</tr></tbody> </tr></tbody>
</table></div> </table></div>
</div> </div>
@@ -245,6 +245,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never) vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never)
const wrapper = await mountDetail() const wrapper = await mountDetail()
expect(wrapper.text()).toContain('待还柜') expect(wrapper.text()).toContain('待还柜')
expect(wrapper.findAll('dt').find(item => item.text() === '码头拖车状态')!.element.nextElementSibling!.textContent).toBe('待还柜')
expect(wrapper.text()).not.toContain('装柜完成') expect(wrapper.text()).not.toContain('装柜完成')
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click') await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
await flushPromises() await flushPromises()
@@ -257,6 +258,7 @@ describe('ContainerDetailView empty return', () => {
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: '2026-09-09T12:00:00', remark: undefined }), expect.any(String)) expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: '2026-09-09T12:00:00', remark: undefined }), expect.any(String))
expect(getContainer).toHaveBeenCalledTimes(2) expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('已还柜') expect(wrapper.text()).toContain('已还柜')
expect(wrapper.findAll('dt').find(item => item.text() === '码头拖车状态')!.element.nextElementSibling!.textContent).toBe('已还柜')
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false) expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
}) })
@@ -319,6 +321,27 @@ describe('domestic container loading list and booking link', () => {
return wrapper return wrapper
} }
it('keeps customs and PDA information only in the overview when switching to the loading list and back', async () => {
canAccess.mockImplementation((code) => ['platform:firstleg:container:list', 'platform:firstleg:loading-list:list', 'platform:firstleg:customs:list'].includes(code))
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus: 'COMPLETED' } as never)
const wrapper = await mountDomestic()
const pdaPanels = () => wrapper.findAllComponents({ name: 'ContainerOverviewPanel' }).filter(panel => panel.props('sections')?.includes('pda'))
expect(pdaPanels()).toHaveLength(1)
expect(wrapper.findAllComponents({ name: 'ContainerCustomsPanel' })).toHaveLength(1)
await wrapper.findAll('[role="tab"]').find(tab => accessibleText(tab) === '装柜清单')!.trigger('click')
await flushPromises()
const list = wrapper.get('[aria-labelledby="container-loading-list-heading"]')
expect(list.findComponent({ name: 'LoadingListDetailDrawer' }).exists()).toBe(true)
expect(list.findComponent({ name: 'ContainerOverviewPanel' }).exists()).toBe(false)
expect(list.findComponent({ name: 'ContainerCustomsPanel' }).exists()).toBe(false)
expect(pdaPanels()).toHaveLength(1)
expect(wrapper.findAllComponents({ name: 'ContainerCustomsPanel' })).toHaveLength(1)
await wrapper.findAll('[role="tab"]').find(tab => accessibleText(tab) === '送柜报关')!.trigger('click')
await flushPromises()
expect(pdaPanels()[0]!.isVisible()).toBe(true)
expect(wrapper.findComponent({ name: 'ContainerCustomsPanel' }).isVisible()).toBe(true)
})
it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => { it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => {
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never) vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never)
const wrapper = await mountDomestic() const wrapper = await mountDomestic()
@@ -302,16 +302,17 @@ const oceanBookingLinkOpen = ref(false)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => 'DO 资料') const documentTitle = computed(() => 'DO 资料')
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情') const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const overseasStageLabels: Record<string, string> = {
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
}
const overseasStageText = computed(() => { const overseasStageText = computed(() => {
const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus
|| workbenchDetail.value?.terminalDrayage?.status || workbenchDetail.value?.terminalDrayage?.status
|| workbenchDetail.value?.workbenchStage || workbenchDetail.value?.workbenchStage
return ({ return overseasStageLabels[status || ''] || '海外履约中'
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
} as Record<string, string>)[status || ''] || '海外履约中'
}) })
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value) const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload') const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
@@ -664,13 +665,11 @@ onMounted(async () => {
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading"> <section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header> <header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div> <div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ overseasStageLabels[workbenchDetail?.statusSummary?.terminalDrayageStatus || ''] || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section> </section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading"> <section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header> <header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :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>
@@ -702,7 +701,7 @@ onMounted(async () => {
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal"> <AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState> <LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p> <p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p> <template v-if="terminal">
<section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" /> <section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
@@ -849,8 +849,8 @@ describe('ContainersView', () => {
describe('ContainersView trucking supplier contract', () => { describe('ContainersView trucking supplier contract', () => {
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => { it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode'] const fields = ['containerNo', 'planStatus', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'exceptionActive', 'planNo', 'cabinetNo', 'pickupCode']
let previous = source.indexOf("key: 'actions'") let previous = source.indexOf('const allColumns')
for (const field of fields) { for (const field of fields) {
const current = source.indexOf(`key: '${field}'`, previous + 1) const current = source.indexOf(`key: '${field}'`, previous + 1)
expect(current, field).toBeGreaterThan(previous) expect(current, field).toBeGreaterThan(previous)
@@ -109,9 +109,9 @@ let emailSequence = 0
const emailColumns: DataTableColumn[] = [ const emailColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'emailActions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'emailActions' },
{ key: 'kind', label: '类型', width: 100, formatter: (row: TerminalDrayageEmail) => row.kind === 'CANCEL_PICKUP' ? '取消提柜' : '请求提柜' }, { key: 'kind', label: '类型', width: 100, formatter: (row: TerminalDrayageEmail) => row.kind === 'CANCEL_PICKUP' ? '取消提柜' : '请求提柜' },
{ key: 'status', label: '发送状态', width: 100, formatter: (row: TerminalDrayageEmail) => ({ PENDING: '待发送', SENDING: '发送中', SENT: '已发送', FAILED: '失败待核对' })[row.status], afterColumn: 'kind' },
{ key: 'fleetName', label: '车队', minWidth: 130 }, { key: 'fleetName', label: '车队', minWidth: 130 },
{ key: 'recipient', label: '收件邮箱', minWidth: 210 }, { key: 'recipient', label: '收件邮箱', minWidth: 210 },
{ key: 'status', label: '发送状态', width: 100, formatter: (row: TerminalDrayageEmail) => ({ PENDING: '待发送', SENDING: '发送中', SENT: '已发送', FAILED: '失败待核对' })[row.status] },
{ key: 'content', label: '邮件内容', minWidth: 270, slotName: 'emailContent' }, { key: 'content', label: '邮件内容', minWidth: 270, slotName: 'emailContent' },
{ key: 'failureReason', label: '失败说明', minWidth: 200 }, { key: 'failureReason', label: '失败说明', minWidth: 200 },
{ key: 'createdAt', label: '创建时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.createdAt) }, { key: 'createdAt', label: '创建时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.createdAt) },
@@ -239,12 +239,12 @@ const allColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
{ key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' }, { key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' }, { key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status', afterColumn: 'containerNo' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus), afterColumn: 'containerNo' },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus), afterColumn: 'containerNo' },
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常', afterColumn: 'containerNo' },
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' }, { key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' }, { key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' }, { key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
@@ -240,8 +240,8 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [ const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', label: '入仓码', minWidth: 180, fixed: 'left', slotName: 'candidateInbounds' }, { key: 'inboundCode', label: '入仓码', minWidth: 180, fixed: 'left', slotName: 'candidateInbounds' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot', afterColumn: 'inboundCode' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'customerName' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'customerName' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 }, { key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 },
{ key: 'totalGrossWeightKg', label: '重量', width: 100, slotName: 'candidateTotalWeight' }, { key: 'totalGrossWeightKg', label: '重量', width: 100, slotName: 'candidateTotalWeight' },
{ key: 'totalVolumeCbm', label: '体积', width: 100, slotName: 'candidateTotalVolume' }, { key: 'totalVolumeCbm', label: '体积', width: 100, slotName: 'candidateTotalVolume' },
@@ -157,12 +157,12 @@ const itemColumns: DataTableColumn[] = [
const candidateColumns: DataTableColumn[] = [ const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'candidateInbounds' }, { key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'candidateInbounds' },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus', afterColumn: 'inboundCode' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' }, { key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' }, { key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 90 }, { key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 90 },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 120, slotName: 'averageUnitVolume' }, { key: 'averageUnitVolumeCbm', label: '单位体积', width: 120, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 120, slotName: 'unitGrossWeight' }, { key: 'unitGrossWeightKg', label: '单位毛重', width: 120, slotName: 'unitGrossWeight' },
] ]
@@ -89,6 +89,18 @@ describe('CustomerBillingRulesPanel', () => {
expect(document.body.textContent).toContain('标准中箱') expect(document.body.textContent).toContain('标准中箱')
}) })
it('shows weighted volume and volume weight with the coefficient applied to all three dimensions', async () => {
vi.mocked(listCustomerChargeRules).mockResolvedValue([{ ...chargeRule, defaultRule: true, lengthFactor: 1.25 }])
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const chargeTable = wrapper.findAll('.el-table')[1]!
const largeRow = chargeTable.findAll('.el-table__body tbody tr')[3]!
const values = largeRow.findAll('td').map(cell => cell.text())
expect(values[4]).toBe('0.293')
expect(values[6]).toBe('48.93')
})
it('hides permission codes in the no-access state', async () => { it('hides permission codes in the no-access state', async () => {
const auth = useAuthStore() const auth = useAuthStore()
auth.permissions = [] auth.permissions = []
@@ -399,15 +399,15 @@ onBeforeUnmount(stopInitializationPolling)
<div v-else-if="initializationReady" v-loading="loading" class="loading-state" /> <div v-else-if="initializationReady" v-loading="loading" class="loading-state" />
<section v-if="initializationReady && canChargeView" class="rule-card charge-rule-card"> <section v-if="initializationReady && canChargeView" class="rule-card charge-rule-card">
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算<InlineHelp title="默认规则说明"><p>勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></InlineHelp></h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div></div></header> <header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算<InlineHelp title="默认规则说明"><p>勾选后才默认按该规则调整;两个默认规则均未勾选时使用实测原尺寸,仓库修正尺寸替换对应实测边。</p><p>系数分别作用于长、宽、高,加权体积 = 原体积 × 系数³。</p></InlineHelp></h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div></div></header>
<div class="workbench-table"><el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置"> <div class="workbench-table"><el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" /> <el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
<el-table-column label="内径尺寸(长×宽×高 cm)" min-width="240"><template #default="{ row }">{{ row.dimension ? formatDimension(row.dimension.innerLengthCm, row.dimension.innerWidthCm, row.dimension.innerHeightCm) : '—' }}</template></el-table-column> <el-table-column label="内径尺寸(长×宽×高 cm)" min-width="240"><template #default="{ row }">{{ row.dimension ? formatDimension(row.dimension.innerLengthCm, row.dimension.innerWidthCm, row.dimension.innerHeightCm) : '—' }}</template></el-table-column>
<el-table-column label="权重系数" min-width="110"><template #default="{ row }"><strong>{{ row.coefficient }}</strong></template></el-table-column> <el-table-column label="权重系数" min-width="110"><template #default="{ row }"><strong>{{ row.coefficient }}</strong></template></el-table-column>
<el-table-column label="体积(CBM)" min-width="120"><template #default="{ row }">{{ row.dimension ? Number(row.dimension.volumeCbm).toFixed(3) : '—' }}</template></el-table-column> <el-table-column label="体积(CBM)" min-width="120"><template #default="{ row }">{{ row.dimension ? Number(row.dimension.volumeCbm).toFixed(3) : '—' }}</template></el-table-column>
<el-table-column label="加权体积(CBM)" min-width="145"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient).toFixed(3) : '—' }}</template></el-table-column> <el-table-column label="加权体积(CBM)" min-width="145"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient ** 3).toFixed(3) : '—' }}</template></el-table-column>
<el-table-column label="重量(kg)" min-width="105"><template #default="{ row }">{{ row.weight ? row.weight.toFixed(2) : '—' }}</template></el-table-column> <el-table-column label="重量(kg)" min-width="105"><template #default="{ row }">{{ row.weight ? row.weight.toFixed(2) : '—' }}</template></el-table-column>
<el-table-column label="体积重(kg)" min-width="115"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient * 167).toFixed(2) : '—' }}</template></el-table-column> <el-table-column label="体积重(kg)" min-width="115"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient ** 3 * 167).toFixed(2) : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" align="left" header-align="left" class-name="charge-remark-column" label-class-name="charge-remark-column"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column> <el-table-column prop="remark" label="备注" min-width="180" align="left" header-align="left" class-name="charge-remark-column" label-class-name="charge-remark-column"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
</el-table></div> </el-table></div>
</section> </section>
@@ -289,14 +289,14 @@ onBeforeUnmount(() => {
<section class="detail-panel user-panel"> <section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户<InlineHelp title="账号信息说明"><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></InlineHelp></h2></div><UsersRound :size="19" /></div> <div class="panel-title"><div><h2>企业用户<InlineHelp title="账号信息说明"><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></InlineHelp></h2></div><UsersRound :size="19" /></div>
<div v-if="enterpriseMembers.length" class="user-table-wrap workbench-evidence"> <div v-if="enterpriseMembers.length" class="user-table-wrap workbench-evidence">
<table class="density-table"><thead><tr><th>账号</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th><th>状态</th></tr></thead> <table class="density-table"><thead><tr><th>账号</th><th>状态</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th></tr></thead>
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)"> <tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
<td><div class="user-identity"><span>{{ (member.displayName || member.username || '?').slice(0, 1) }}</span><div><strong>{{ displayText(member.username) }}</strong><small>{{ displayText(member.displayName) }}</small></div></div></td> <td><div class="user-identity"><span>{{ (member.displayName || member.username || '?').slice(0, 1) }}</span><div><strong>{{ displayText(member.username) }}</strong><small>{{ displayText(member.displayName) }}</small></div></div></td>
<td><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light" size="small">{{ member.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></td>
<td>{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}</td> <td>{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}</td>
<td>{{ displayText(member.phone) }}</td> <td>{{ displayText(member.phone) }}</td>
<td>{{ displayText(member.email) }}</td> <td>{{ displayText(member.email) }}</td>
<td><div v-if="member.roleNames?.length" class="role-list"><el-tag v-for="role in member.roleNames" :key="role" size="small" effect="plain">{{ role }}</el-tag></div><span v-else>—</span></td> <td><div v-if="member.roleNames?.length" class="role-list"><el-tag v-for="role in member.roleNames" :key="role" size="small" effect="plain">{{ role }}</el-tag></div><span v-else>—</span></td>
<td><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light" size="small">{{ member.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></td>
</tr></tbody> </tr></tbody>
</table> </table>
</div> </div>
@@ -214,6 +214,7 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns: DataTableColumn[] = [ const customerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 330, slotName: 'actions' }, { key: 'actions', label: '操作', width: 330, slotName: 'actions' },
{ key: 'tenantCode', prop: 'tenantCode', label: '客户编号', width: 220, className: 'customer-code-column' }, { key: 'tenantCode', prop: 'tenantCode', label: '客户编号', width: 220, className: 'customer-code-column' },
{ key: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'tenantCode' },
{ key: 'customerNickname', prop: 'customerNickname', label: '客户昵称', width: 120, formatter: (row: PlatformTenantResource) => displayText(row.customerNickname) }, { key: 'customerNickname', prop: 'customerNickname', label: '客户昵称', width: 120, formatter: (row: PlatformTenantResource) => displayText(row.customerNickname) },
{ key: 'company', label: '公司名称', width: 250, slotName: 'company' }, { key: 'company', label: '公司名称', width: 250, slotName: 'company' },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) }, { key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
@@ -221,7 +222,6 @@ const customerColumns: DataTableColumn[] = [
{ key: 'contactPhone', prop: 'contactPhone', label: '公司联系电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) }, { key: 'contactPhone', prop: 'contactPhone', label: '公司联系电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
{ key: 'salesOwnerName', prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) }, { key: 'salesOwnerName', prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) },
{ key: 'paymentTermDays', prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 }, { key: 'paymentTermDays', prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'orderCount', label: '运单数', width: 100, slotName: 'orderCount' }, { key: 'orderCount', label: '运单数', width: 100, slotName: 'orderCount' },
{ key: 'createdAt', prop: 'createdAt', label: '入驻时间', minWidth: 165, formatter: (row: PlatformTenantResource) => formatDate(row.createdAt) }, { key: 'createdAt', prop: 'createdAt', label: '入驻时间', minWidth: 165, formatter: (row: PlatformTenantResource) => formatDate(row.createdAt) },
] ]
@@ -27,7 +27,7 @@ const filters = ref<QueryModel>({ customerId: '', status: '' }); const fields =
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'status', type: 'select', label: '状态', width: 160, options: customsCaseStatusOptions.map((option) => ({ ...option })) }, { key: 'status', type: 'select', label: '状态', width: 160, options: customsCaseStatusOptions.map((option) => ({ ...option })) },
]) ])
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { key: 'orderLink', label: '订单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.orderId) }, { key: 'inboundLink', label: '入库单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.inboundId) }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }] const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'customsNo' }, { key: 'orderLink', label: '订单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.orderId) }, { key: 'inboundLink', label: '入库单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.inboundId) }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
function associationLabel(value: string | null | undefined) { return value ? '已关联' : '未关联' } function associationLabel(value: string | null | undefined) { return value ? '已关联' : '未关联' }
async function loadCustomers() { customersLoading.value = true; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((customer) => customer.status === 'ACTIVE') } catch { customers.value = [] } finally { customersLoading.value = false } } async function loadCustomers() { customersLoading.value = true; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((customer) => customer.status === 'ACTIVE') } catch { customers.value = [] } finally { customersLoading.value = false } }
async function loadCustomsAgents() { customsAgentsLoading.value = true; customsAgentsError.value = ''; try { const result = await listSuppliers({ page: 1, size: 200, category: 'CUSTOMS', status: 'ACTIVE' }); customsAgents.value = result.records.filter((supplier) => supplier.category === 'CUSTOMS' && supplier.status === 'ACTIVE') } catch (cause) { customsAgents.value = []; customsAgentsError.value = getApiErrorDetails(cause, '报关代理加载失败').message } finally { customsAgentsLoading.value = false } } async function loadCustomsAgents() { customsAgentsLoading.value = true; customsAgentsError.value = ''; try { const result = await listSuppliers({ page: 1, size: 200, category: 'CUSTOMS', status: 'ACTIVE' }); customsAgents.value = result.records.filter((supplier) => supplier.category === 'CUSTOMS' && supplier.status === 'ACTIVE') } catch (cause) { customsAgents.value = []; customsAgentsError.value = getApiErrorDetails(cause, '报关代理加载失败').message } finally { customsAgentsLoading.value = false } }
@@ -94,7 +94,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<template> <template>
<section ref="dashboardRef" class="dashboard-view admin-workbench"> <section ref="dashboardRef" class="dashboard-view admin-workbench">
<AdminPage as="div" spacing="normal" class="admin-density admin-workbench"> <AdminPage as="div" spacing="compact" class="admin-density admin-workbench">
<section class="dashboard-context" aria-label="数据看板操作"> <section class="dashboard-context" aria-label="数据看板操作">
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p> <p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
<div class="dashboard-head-actions"> <div class="dashboard-head-actions">
@@ -128,6 +128,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<style scoped> <style scoped>
.dashboard-view { display: block; min-width: 0; } .dashboard-view { display: block; min-width: 0; }
.dashboard-view > .admin-page.is-spacing-compact { gap: var(--admin-space-2); }
.dashboard-view:fullscreen { overflow: auto; background: var(--workbench-surface); } .dashboard-view:fullscreen { overflow: auto; background: var(--workbench-surface); }
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--workbench-section-gap); } .dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--workbench-section-gap); }
.dashboard-head-actions { display: flex; margin-left: auto; align-items: center; gap: 12px; white-space: nowrap; } .dashboard-head-actions { display: flex; margin-left: auto; align-items: center; gap: 12px; white-space: nowrap; }
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import DomesticContainersView from './DomesticContainersView.vue' import DomesticContainersView from './DomesticContainersView.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import * as api from '../api/domesticContainers' import * as api from '../api/domesticContainers'
import type { InventoryRecord } from '../types' import type { InventoryPage, InventoryRecord } from '../types'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
// Use Node's supported require export so the validator retains its constructor interop. // Use Node's supported require export so the validator retains its constructor interop.
@@ -31,11 +34,16 @@ describe('DomesticContainersView', () => {
}) })
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") }) it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') }) it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
it('keeps all four inventory facts in the approved compact summary', () => { it('keeps all four inventory facts and units in shared compact metric cards', () => {
for (const fact of ['summary.inboundBoxes', 'summary.outboundBoxes', 'summary.availableBoxes', 'summary.inboundVolumeCbm']) expect(source).toContain(fact) for (const fact of ['summary?.inboundBoxes', 'summary?.outboundBoxes', 'summary?.availableBoxes', 'summary?.inboundVolumeCbm']) expect(source).toContain(fact)
expect(source).toContain("import './domesticContainersSummary.css'") expect(source).toContain("import './domesticContainersSummary.css'")
expect(summaryStyles).toContain('display: flex; flex-wrap: wrap') const summary = source.match(/<section\b[^>]*class="inventory-summary"[\s\S]*?<\/section>/)?.[0] || ''
expect(summaryStyles).toContain('font-size: var(--admin-font-section, 14px)') expect(summary).not.toContain('v-if=')
expect(summary.match(/class="workbench-metric workbench-tone is-compact"/g)).toHaveLength(4)
expect(summary.match(/<span>箱<\/span>/g)).toHaveLength(3)
expect(summary).toContain("summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span>")
expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(summaryStyles).toContain('gap: var(--workbench-grid-gap)')
expect(summaryStyles).not.toContain('position: absolute') expect(summaryStyles).not.toContain('position: absolute')
}) })
it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") }) it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") })
@@ -49,10 +57,14 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mock
vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() })) vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() }))
const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' } const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' }
let mounted: VueWrapper | undefined let mounted: VueWrapper | undefined
async function openCreate(label: string) { async function mountView() {
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
mounted = view mounted = view
await flushPromises() await flushPromises()
return view
}
async function openCreate(label: string) {
const view = await mountView()
if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click') if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
await flushPromises() await flushPromises()
await view.findAll('button').find(button => button.text() === label)!.trigger('click') await view.findAll('button').find(button => button.text() === label)!.trigger('click')
@@ -64,7 +76,7 @@ async function submitCreate() {
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click() document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
await flushPromises() await flushPromises()
} }
describe('DomesticContainersView create workflows', () => { describe('DomesticContainersView workflows', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 } const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
@@ -75,6 +87,60 @@ describe('DomesticContainersView create workflows', () => {
}) })
afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' }) afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' })
it('loads the overview on entry and keeps it above the list across all three tabs', async () => {
const view = await mountView()
const overview = view.get('.inventory-summary')
expect(view.element.firstElementChild).toBe(overview.element)
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(1)
for (const label of ['整柜出库单', '整柜库存', '整柜入库单']) {
const requests = vi.mocked(api.listDomesticContainerInventories).mock.calls.length
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
await flushPromises()
expect(view.element.firstElementChild).toBe(overview.element)
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(requests + 1)
}
})
it.each(['整柜入库单', '整柜出库单'])('uses common filters for the overview on %s without passing order-only filters', async label => {
const view = await mountView()
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
await flushPromises()
const filters = { warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', cargoName: '测试货物', orderNo: 'ORDER-01', status: 'PENDING' }
view.findComponent(QueryToolbar).vm.$emit('search', filters)
await flushPromises()
expect(api.listDomesticContainerInventories).toHaveBeenLastCalledWith({ page: 1, size: 1, warehouseId: filters.warehouseId, containerNo: filters.containerNo, cargoName: filters.cargoName })
const list = label === '整柜入库单' ? api.listDomesticContainerInbounds : api.listDomesticContainerOutbounds
expect(list).toHaveBeenLastCalledWith({ ...filters, page: 1, size: 20 })
})
it('shows unknown summary values on failure while keeping the order list available and allowing retry', async () => {
vi.mocked(api.listDomesticContainerInventories).mockRejectedValueOnce(new Error('统计失败'))
const view = await mountView()
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['—', '—', '—', '—'])
expect(view.findComponent(DataTable).props('resultsVisible')).toBe(true)
const error = view.findComponent(LoadErrorState)
expect(error.props('title')).toBe('整柜库存统计加载失败')
error.vm.$emit('retry')
await flushPromises()
expect(view.findComponent(LoadErrorState).exists()).toBe(false)
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
})
it('ignores a stale overview response after switching tabs', async () => {
let resolveFirst!: (value: InventoryPage) => void
vi.mocked(api.listDomesticContainerInventories).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
const view = await mountView()
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('true')
await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
await flushPromises()
resolveFirst({ records: [], page: 1, size: 1, total: 0, pages: 0, summary: { inboundBoxes: 99, outboundBoxes: 0, availableBoxes: 99, inboundVolumeCbm: 99 } })
await flushPromises()
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('false')
})
it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => { it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => {
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false }) vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
const { form } = await openCreate('新建入库单') const { form } = await openCreate('新建入库单')
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus } from '@lucide/vue' import { Box, Boxes, PackageCheck, PackagePlus, Plus } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -28,7 +28,10 @@ const businessTabs: Array<{ value: TabName; label: string }> = [
{ value: 'inventory', label: '整柜库存' }, { value: 'inventory', label: '整柜库存' },
] ]
const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0) const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 }) const summary = ref<InventorySummary | null>(null)
const summaryLoading = ref(false)
const summaryError = ref<ApiErrorDetails | null>(null)
let loadSequence = 0
const warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null) const warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' }) const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value }) const appliedFilters = ref<QueryModel>({ ...filters.value })
@@ -41,15 +44,49 @@ const queryFields = computed<QueryField[]>(() => {
return base return base
}) })
const columns = computed<DataTableColumn[]>(() => activeTab.value === 'inventory' ? [ const columns = computed<DataTableColumn[]>(() => activeTab.value === 'inventory' ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 112, slotName: 'actions', hideable: false, movable: false }, { prop: 'containerNo', label: '集装箱号', minWidth: 150 }, { prop: 'warehouseName', label: '国内仓', minWidth: 150 }, { prop: 'cargoName', label: '货物名称', minWidth: 150 }, { prop: 'inboundBoxes', label: '入库箱数', width: 105, align: 'right' }, { prop: 'outboundBoxes', label: '已出库箱数', width: 115, align: 'right' }, { prop: 'availableBoxes', label: '当前库存', width: 105, align: 'right' }, { prop: 'inboundVolumeCbm', label: '累计入库体积(m³)', width: 155, align: 'right' }, { prop: 'inventoryStatus', label: '状态', width: 110, slotName: 'status' }, { key: 'actions', label: '操作', fixed: 'left', width: 112, slotName: 'actions', hideable: false, movable: false }, { prop: 'containerNo', label: '集装箱号', minWidth: 150 }, { prop: 'inventoryStatus', label: '状态', width: 110, slotName: 'status', afterColumn: 'containerNo' }, { prop: 'warehouseName', label: '国内仓', minWidth: 150 }, { prop: 'cargoName', label: '货物名称', minWidth: 150 }, { prop: 'inboundBoxes', label: '入库箱数', width: 105, align: 'right' }, { prop: 'outboundBoxes', label: '已出库箱数', width: 115, align: 'right' }, { prop: 'availableBoxes', label: '当前库存', width: 105, align: 'right' }, { prop: 'inboundVolumeCbm', label: '累计入库体积(m³)', width: 155, align: 'right' },
] : [ ] : [
{ key: 'actions', label: '操作', fixed: 'left', width: 112, slotName: 'actions', hideable: false, movable: false }, { prop: 'orderNo', label: activeTab.value === 'inbound' ? '入库单号' : '出库单号', minWidth: 155 }, { prop: 'warehouseName', label: '国内仓', minWidth: 150 }, { prop: 'containerNo', label: '集装箱号', minWidth: 150 }, { prop: 'cargoName', label: '货物名称', minWidth: 150 }, { prop: 'boxCount', label: activeTab.value === 'inbound' ? '箱子数量' : '出库箱数', width: 105, align: 'right' }, ...(activeTab.value === 'inbound' ? [{ prop: 'volumeCbm', label: '体积(m³)', width: 105, align: 'right' }] : []), { prop: 'operationTime', label: activeTab.value === 'inbound' ? '入库时间' : '出库时间', minWidth: 165, formatter: (row: ContainerOrder) => displayTime(row.operationTime) }, { prop: 'status', label: '状态', width: 105, slotName: 'status' }, { key: 'actions', label: '操作', fixed: 'left', width: 112, slotName: 'actions', hideable: false, movable: false }, { prop: 'orderNo', label: activeTab.value === 'inbound' ? '入库单号' : '出库单号', minWidth: 155 }, { prop: 'status', label: '状态', width: 105, slotName: 'status', afterColumn: 'orderNo' }, { prop: 'warehouseName', label: '国内仓', minWidth: 150 }, { prop: 'containerNo', label: '集装箱号', minWidth: 150 }, { prop: 'cargoName', label: '货物名称', minWidth: 150 }, { prop: 'boxCount', label: activeTab.value === 'inbound' ? '箱子数量' : '出库箱数', width: 105, align: 'right' }, ...(activeTab.value === 'inbound' ? [{ prop: 'volumeCbm', label: '体积(m³)', width: 105, align: 'right' }] : []), { prop: 'operationTime', label: activeTab.value === 'inbound' ? '入库时间' : '出库时间', minWidth: 165, formatter: (row: ContainerOrder) => displayTime(row.operationTime) },
]) ])
function displayTime(value: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function displayTime(value: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') } function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') }
function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' } function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' }
function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } } function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } }
async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } } async function load() {
const sequence = ++loadSequence
const tab = activeTab.value
const params = queryParams()
loading.value = true
loadError.value = null
summaryLoading.value = true
summaryError.value = null
summary.value = null
const inventoryRequest = listDomesticContainerInventories(tab === 'inventory' ? params : {
page: 1,
size: 1,
warehouseId: params.warehouseId,
containerNo: params.containerNo,
cargoName: params.cargoName,
})
const summaryTask = inventoryRequest.then(result => {
if (sequence === loadSequence) summary.value = result.summary
}).catch(cause => {
if (sequence === loadSequence && tab !== 'inventory') summaryError.value = getApiErrorDetails(cause, '整柜库存统计加载失败')
}).finally(() => {
if (sequence === loadSequence) summaryLoading.value = false
})
const listRequest = tab === 'inbound' ? listDomesticContainerInbounds(params) : tab === 'outbound' ? listDomesticContainerOutbounds(params) : inventoryRequest
const listTask = listRequest.then(result => {
if (sequence !== loadSequence) return
rows.value = result.records
total.value = result.total
}).catch(cause => {
if (sequence === loadSequence) loadError.value = getApiErrorDetails(cause, '整柜业务加载失败')
}).finally(() => {
if (sequence === loadSequence) loading.value = false
})
await Promise.all([summaryTask, listTask])
}
async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } } async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } }
function search(model = filters.value) { appliedFilters.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 } function search(model = filters.value) { appliedFilters.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function switchTab(name: TabName) { if (activeTab.value === name) return; activeTab.value = name; filters.value = { warehouseId: filters.value.warehouseId || '', orderNo: '', containerNo: '', cargoName: '', status: '' }; appliedFilters.value = { ...filters.value }; page.value = 1; void load() } function switchTab(name: TabName) { if (activeTab.value === name) return; activeTab.value = name; filters.value = { warehouseId: filters.value.warehouseId || '', orderNo: '', containerNo: '', cargoName: '', status: '' }; appliedFilters.value = { ...filters.value }; page.value = 1; void load() }
@@ -73,13 +110,31 @@ async function submit() { if (!await formRef.value?.validate().catch(() => false
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) }) watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
</script> </script>
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench"> <template><AdminPage as="div" spacing="compact" class="domestic-containers-page admin-density admin-workbench">
<section v-loading="summaryLoading" class="inventory-summary" aria-label="整柜库存概览" :aria-busy="summaryLoading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackagePlus aria-hidden="true" /></span><h2 class="workbench-metric-label">入库箱数</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.inboundBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已出库箱数</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.outboundBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Boxes aria-hidden="true" /></span><h2 class="workbench-metric-label">当前库存</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.availableBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="teal">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Box aria-hidden="true" /></span><h2 class="workbench-metric-label">累计入库体积</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span></div>
</article>
</section>
<LoadErrorState v-if="summaryError" compact title="整柜库存统计加载失败" :message="summaryError.message" :loading="summaryLoading" @retry="load"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState> <LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"> <ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template> <template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template>
<template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template> <template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></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> <template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></div></section></ListPageLayout> <template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></div></section></ListPageLayout>
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog admin-workbench is-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"> <AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog admin-workbench is-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit">
<LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState> <LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
@@ -122,4 +177,7 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
</el-form> </el-form>
</AppDialog></AdminPage></template> </AppDialog></AdminPage></template>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--workbench-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--workbench-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--workbench-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-bottom:0}}</style> <style scoped>
.admin-workbench.domestic-containers-page.admin-page.is-spacing-compact { gap: 0; }
.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
</style>
@@ -1,9 +1,17 @@
.domestic-containers-page .table-panel .inventory-summary { .domestic-containers-page .inventory-summary {
display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px) 28px; margin: 0; display: grid;
padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 0; border-bottom: 1px solid var(--cc-border); background: var(--admin-surface, #fff); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--workbench-grid-gap);
} }
.domestic-containers-page .table-panel .inventory-summary > div {
display: flex; align-items: baseline; min-width: 0; gap: var(--admin-space-3, 8px); padding: 0; border: 0; @media (max-width: 720px) {
.domestic-containers-page .inventory-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 360px) {
.domestic-containers-page .inventory-summary {
grid-template-columns: 1fr;
}
} }
.domestic-containers-page .table-panel .inventory-summary span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.domestic-containers-page .table-panel .inventory-summary strong { font-size: var(--admin-font-section, 14px); font-weight: 600; }
@@ -85,8 +85,8 @@ const queryFields: QueryField[] = [
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'taskName', prop: 'taskName', label: '任务名称', minWidth: 180, showOverflowTooltip: true }, { key: 'taskName', prop: 'taskName', label: '任务名称', minWidth: 180, showOverflowTooltip: true },
{ key: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'taskName' },
{ key: 'type', label: '任务类型', width: 120, slotName: 'type' }, { key: 'type', label: '任务类型', width: 120, slotName: 'type' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'progress', label: '数据量', width: 120, slotName: 'progress' }, { key: 'progress', label: '数据量', width: 120, slotName: 'progress' },
{ key: 'format', prop: 'format', label: '格式', width: 80 }, { key: 'format', prop: 'format', label: '格式', width: 80 },
{ key: 'requester', label: '申请人', minWidth: 130, slotName: 'requester' }, { key: 'requester', label: '申请人', minWidth: 130, slotName: 'requester' },
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue' import { ArrowLeft, CircleCheck, Download, RefreshCw } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -448,12 +448,6 @@ onMounted(() => {
</div> </div>
</div> </div>
</template> </template>
<template v-if="detail" #summary>
<dl class="density-detail-overview">
<div><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<div><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
</dl>
</template>
<LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail"> <LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
@@ -462,9 +456,11 @@ onMounted(() => {
<template v-else-if="detail"> <template v-else-if="detail">
<section class="version-panel"> <section class="version-panel">
<header class="section-heading"> <header class="section-heading">
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div> <div><h2>版本信息</h2></div>
</header> </header>
<dl class="detail-grid density-fields"> <dl class="detail-grid density-fields">
<div class="detail-grid__half"><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<div class="detail-grid__half"><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div> <div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div> <div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div> <div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
@@ -475,7 +471,7 @@ onMounted(() => {
</section> </section>
<section v-if="isPending && canEdit" class="mapping-section"> <section v-if="isPending && canEdit" class="mapping-section">
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header> <header class="section-heading"><div><h2>仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
<el-alert <el-alert
v-if="nonMappingBlockingIssues.length" v-if="nonMappingBlockingIssues.length"
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。" title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
@@ -514,37 +510,39 @@ onMounted(() => {
</section> </section>
<section class="detail-section"> <section class="detail-section">
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header> <header class="section-heading"><div><h2>解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" /> <el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题"> <el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column> <el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip /> <el-table-column prop="issueCode" label="编码" width="270" />
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip /> <el-table-column prop="message" label="说明" min-width="320" />
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column> <el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
</el-table> </el-table>
</section> </section>
<section class="detail-section channel-preview"> <section class="detail-section channel-preview">
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2></div></header> <header class="section-heading"><div><h2>按渠道预览</h2></div></header>
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" /> <el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
<template v-else> <template v-else>
<el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs"> <el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs">
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label><span class="density-tab-icon" aria-hidden="true">🚢</span>{{ channel.channelName }}</template></el-tab-pane> <el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label>{{ channel.channelName }}</template></el-tab-pane>
</el-tabs> </el-tabs>
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '—' }}</span></div> <dl class="channel-facts density-fields" aria-label="渠道信息">
<div><dt>渠道</dt><dd>{{ selectedChannel?.channelName || '—' }}</dd></div>
<div><dt>计价方式</dt><dd>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</dd></div>
<div><dt>来源表页</dt><dd>{{ selectedChannel?.sourceSheetName || '—' }}</dd></div>
<div><dt>参考时效</dt><dd>{{ selectedChannel?.referenceTransit || '—' }}</dd></div>
<div><dt>最晚进仓时间</dt><dd>{{ selectedChannel?.latestWarehouseEntry || '—' }}</dd></div>
<div><dt>船期</dt><dd>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</dd></div>
<div class="channel-facts__wide"><dt>附加费</dt><dd>{{ selectedChannel?.surchargeDescription || '—' }}</dd></div>
<div class="channel-facts__wide"><dt>备注</dt><dd>{{ selectedChannel?.remark || '—' }}</dd></div>
</dl>
<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览"> <el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column> <el-table-column prop="originWarehouseName" label="起运仓" min-width="180" />
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column> <el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column> <el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
<el-table-column prop="chargeUnit" label="单位" width="78" /> <el-table-column prop="chargeUnit" label="单位" width="78" />
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column> <el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column> <el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table> </el-table>
</template> </template>
@@ -555,23 +553,24 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.price-book-detail { min-width: 0; max-width: 1880px; } .price-book-detail { min-width: 0; }
.version-panel, .mapping-section, .detail-section { padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); } .price-book-detail :deep(.density-detail-layout > .detail-page-layout__content) { padding: 0; }
.price-book-detail .channel-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); --admin-field-label-width: 110px; }
.channel-facts .channel-facts__wide { grid-column: 1 / -1; }
.detail-grid dd, .channel-facts dd { overflow-wrap: anywhere; }
.version-panel, .mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
.detail-section:last-child { border-bottom: 0; } .detail-section:last-child { border-bottom: 0; }
.price-book-header-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); } .price-book-header-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.detail-grid .detail-grid__half { grid-column: span 2; }
.detail-grid .detail-grid__wide { grid-column: 1 / -1; } .detail-grid .detail-grid__wide { grid-column: 1 / -1; }
.section-heading > div { min-width: 0; } .section-heading > div { min-width: 0; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); } .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin: 0; }
.section-heading h2 { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section); } .section-heading h2 { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section); }
.section-heading h2 svg { color: var(--workbench-primary); }
.section-heading p { overflow-wrap: anywhere; margin: 5px 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.55; }
.is-danger { color: var(--el-color-danger) !important; } .is-danger { color: var(--el-color-danger) !important; }
.mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); }
.mapping-section .section-heading, .detail-section .section-heading { margin-bottom: 0; }
.mapping-groups { display: grid; gap: var(--admin-space-5); } .mapping-groups { display: grid; gap: var(--admin-space-5); }
.mapping-group { display: grid; gap: var(--admin-space-3); } .mapping-group { display: grid; gap: var(--admin-space-3); }
.mapping-group h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); } .mapping-group h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); }
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); } .mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); }
.mapping-row > div { display: grid; min-width: 0; gap: var(--admin-space-1); } .mapping-row > div { display: grid; min-width: 0; gap: var(--admin-space-1); }
.mapping-row strong { overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-body); } .mapping-row strong { overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-body); }
.mapping-row span { color: var(--workbench-muted); font-size: var(--admin-font-caption); } .mapping-row span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@@ -580,8 +579,8 @@ onMounted(() => {
.mapping-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-body); } .mapping-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
.mapping-load-error p { margin: var(--admin-space-1) 0; color: var(--workbench-text); font-size: var(--admin-font-caption); } .mapping-load-error p { margin: var(--admin-space-1) 0; color: var(--workbench-text); font-size: var(--admin-font-caption); }
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; } .channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
.channel-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; } .channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
.channel-meta { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); margin: calc(-1 * var(--admin-space-1)) 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) { @media (max-width: 720px) {
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; } .detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
.detail-toolbar { width: 100%; } .detail-toolbar { width: 100%; }
@@ -56,8 +56,8 @@ describe('CarrierRatesView price-book source contract', () => {
}) })
it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => { it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) { for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注']) {
expect(detailSource).toContain(`label="${label}"`) expect(detailSource).toContain(`<dt>${label}</dt>`)
} }
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) { for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
expect(detailSource).toContain(`selectedChannel?.${field}`) expect(detailSource).toContain(`selectedChannel?.${field}`)
@@ -82,15 +82,15 @@ const queryFields = computed<QueryField[]>(() => [
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' }, { key: 'scope', label: '报价范围', minWidth: 160, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' }, { key: 'version', label: '价格表版本', width: 110, slotName: 'version' },
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false }, { key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'contents', label: '解析结果', minWidth: 170, slotName: 'contents' },
{ key: 'contents', label: '解析结果', minWidth: 155, slotName: 'contents' }, { key: 'issues', label: '校验结果', width: 140, slotName: 'issues' },
{ key: 'issues', label: '校验结果', minWidth: 150, slotName: 'issues' },
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) }, { key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) },
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 290, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) }, { key: 'effectivePeriod', label: '实际生效区间', minWidth: 260, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
] ]
const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit')) const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
@@ -382,7 +382,7 @@ onMounted(() => {
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template> <template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template> <template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template> <template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template> <template #contents="{ row }"><div class="metric-cell"><span><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong> 渠道</span><span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong> 价格行</span></div></template>
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template> <template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
<template #empty><el-empty description="暂无船期价格表版本" /></template> <template #empty><el-empty description="暂无船期价格表版本" /></template>
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template> <template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
@@ -423,6 +423,7 @@ onMounted(() => {
.version-cell strong { color: var(--workbench-text); } .version-cell strong { color: var(--workbench-text); }
.version-cell span, .metric-cell span { color: var(--workbench-muted); font-size: var(--admin-font-caption); } .version-cell span, .metric-cell span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1) var(--admin-space-3); } .metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1) var(--admin-space-3); }
.metric-cell > span, .issue-counts > span { white-space: nowrap; }
.metric-cell strong { color: var(--workbench-text); font-size: var(--admin-font-body); } .metric-cell strong { color: var(--workbench-text); font-size: var(--admin-font-body); }
.issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); } .issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; } .issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
+1 -1
View File
@@ -5,7 +5,7 @@
| 页面 | 操作 | 原型承载/布局 | 当前实现及本轮结论 | | 页面 | 操作 | 原型承载/布局 | 当前实现及本轮结论 |
|---|---|---|---| |---|---|---|---|
| 费用项目 | 列表 | 页面内表格,原型无价格列 | 沿用列表,增加默认价格列,空值显示“—” | | 费用项目 | 列表 | 页面内表格,原型无价格列 | 沿用列表,增加默认价格列,空值显示“—” |
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 沿用现有 620px 抽屉与表单,增加可空默认价格及说明 | | 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 2026-10-27 用户确认改为居中弹框(760px,受视口宽度限制),三个分区使用白底和中性边框;保留可空默认价格、字段分组及保存行为 |
| 入库费用 | 新增、编辑、调整时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入单价和金额,支持人工覆盖,回显保留原值 | | 入库费用 | 新增、编辑、调整时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入单价和金额,支持人工覆盖,回显保留原值 |
| 柜次费用 | 新增时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入金额,支持人工覆盖,调整回显保留原值 | | 柜次费用 | 新增时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入金额,支持人工覆盖,调整回显保留原值 |
@@ -5,13 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import FeeItemsView from './FeeItemsView.vue' import FeeItemsView from './FeeItemsView.vue'
import * as api from '../api/platformFeeItems' import * as api from '../api/platformFeeItems'
// Preserve the validator constructor interop through Element Plus's supported Node export. // Preserve the validator constructor interop through Element Plus's supported Node export.
const { default: ElementPlus, ElCheckbox, ElDrawer, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus') const { default: ElementPlus, ElCheckbox, ElDialog, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
vi.mock('../api/platformFeeItems', () => ({ listPlatformFeeItems: vi.fn(), getPlatformFeeItem: vi.fn(), createPlatformFeeItem: vi.fn(), updatePlatformFeeItem: vi.fn(), changePlatformFeeItemStatus: vi.fn(), deletePlatformFeeItem: vi.fn() })) vi.mock('../api/platformFeeItems', () => ({ listPlatformFeeItems: vi.fn(), getPlatformFeeItem: vi.fn(), createPlatformFeeItem: vi.fn(), updatePlatformFeeItem: vi.fn(), changePlatformFeeItemStatus: vi.fn(), deletePlatformFeeItem: vi.fn() }))
let record: api.PlatformFeeItemResource let record: api.PlatformFeeItemResource
@@ -28,7 +27,7 @@ async function openEditor(create = false) {
const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper
} }
async function setText(placeholder: string, value: string) { async function setText(placeholder: string, value: string) {
const input = document.querySelector<HTMLInputElement>(`.el-drawer [placeholder="${placeholder}"]`)! const input = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises() input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
} }
async function check(wrapper: Awaited<ReturnType<typeof openView>>, label: string, value: boolean) { async function check(wrapper: Awaited<ReturnType<typeof openView>>, label: string, value: boolean) {
@@ -51,12 +50,14 @@ beforeEach(() => {
}) })
describe('fee item editing and confirmations', () => { describe('fee item editing and confirmations', () => {
it('creates from the original compact drawer with a zero default and all direction flags', async () => { it('creates from a centered white dialog with a zero default and all direction flags', async () => {
const wrapper = await openEditor(true) const wrapper = await openEditor(true)
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '新增费用项目', size: 'min(620px, 94vw)' }) expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '新增费用项目', width: 'min(760px, 94vw)' })
expect(document.querySelector('.el-drawer')?.classList.contains('admin-density-drawer--editor')).toBe(true) expect(document.querySelector('.el-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
expect(document.querySelectorAll('.el-drawer .density-dialog-section')).toHaveLength(3) expect(document.querySelectorAll('.el-dialog .density-dialog-section')).toHaveLength(3)
expect(document.querySelector('.el-dialog')).toBeNull() expect(document.querySelector('.fee-item-editor .density-dialog-section[data-tone]')).toBeNull()
expect(wrapper.findComponent(ElDialog).props('alignCenter')).toBe(true)
expect(document.querySelector('.el-drawer')).toBeNull()
await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ') await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ')
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0) wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0)
await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true) await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true)
@@ -64,33 +65,33 @@ describe('fee item editing and confirmations', () => {
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING') wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING')
await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true) await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true)
await setText('请输入适用场景或核算说明', ' 新说明 ') await setText('请输入适用场景或核算说明', ' 新说明 ')
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).toHaveBeenCalledWith({ category: '操作费', tradeScope: 'DOMESTIC', feeTypeName: '新增操作费', defaultPrice: 0, supplierCategory: 'TRUCKING', pickupPayable: true, pickupReceivable: true, warehousePayable: true, warehouseReceivable: true, allocationFee: true, description: '新说明' }) expect(api.createPlatformFeeItem).toHaveBeenCalledWith({ category: '操作费', tradeScope: 'DOMESTIC', feeTypeName: '新增操作费', defaultPrice: 0, supplierCategory: 'TRUCKING', pickupPayable: true, pickupReceivable: true, warehousePayable: true, warehouseReceivable: true, allocationFee: true, description: '新说明' })
expect(useUnsavedWorkStore().entries).toHaveLength(0) expect(useUnsavedWorkStore().entries).toHaveLength(0)
}) })
it('validates required fields and payable supplier category without losing entered values', async () => { it('validates required fields and payable supplier category without losing entered values', async () => {
const wrapper = await openEditor(true) const wrapper = await openEditor(true)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).not.toHaveBeenCalled() expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
await vi.waitFor(() => expect(document.querySelector('.el-drawer')?.textContent).toContain('请输入费用名称')) await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('请输入费用名称'))
await setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费') await setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费')
await check(wrapper, '提货环节应付', true) await check(wrapper, '提货环节应付', true)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).not.toHaveBeenCalled() expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('新费用') expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('新费用')
}) })
it('edits the same long ID and clears the supplier payload when payable directions are removed', async () => { it('edits the same long ID and clears the supplier payload when payable directions are removed', async () => {
record = { ...record, pickupPayable: true, supplierCategory: 'TRUCKING', defaultPrice: 25 } record = { ...record, pickupPayable: true, supplierCategory: 'TRUCKING', defaultPrice: 25 }
const wrapper = await openEditor() const wrapper = await openEditor()
expect(wrapper.findComponent(AppDrawer).props('title')).toBe('编辑费用项目 · 测试操作费') expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑费用项目 · 测试操作费')
expect(document.querySelector('.el-drawer')?.textContent).not.toContain(privateId) expect(document.querySelector('.el-dialog')?.textContent).not.toContain(privateId)
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null) wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null)
await check(wrapper, '提货环节应付', false) await check(wrapper, '提货环节应付', false)
expect(document.querySelector('.el-drawer [placeholder="请选择供应商类别"]')).toBeNull() expect(document.querySelector('.el-dialog [placeholder="请选择供应商类别"]')).toBeNull()
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false })) expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false }))
}) })
@@ -99,17 +100,17 @@ describe('fee item editing and confirmations', () => {
vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject })) vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用') const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用')
expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' }) expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' })
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(wrapper.findComponent(ElDrawer).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false }) expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('取消', '.el-drawer__footer button').disabled).toBe(true) expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
rejectSave(new Error('保存失败')); await flushPromises() rejectSave(new Error('保存失败')); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('修改后费用') expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true) expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record) vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updatePlatformFeeItem).toHaveBeenCalledTimes(2) expect(api.updatePlatformFeeItem).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
}) })
it('keeps unsaved registration and the existing permission-change closure', async () => { it('keeps unsaved registration and the existing permission-change closure', async () => {
@@ -118,7 +119,7 @@ describe('fee item editing and confirmations', () => {
await setText('请输入费用名称', '权限变化前的修改') await setText('请输入费用名称', '权限变化前的修改')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true) expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises() useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(useUnsavedWorkStore().entries).toHaveLength(0) expect(useUnsavedWorkStore().entries).toHaveLength(0)
expect(api.updatePlatformFeeItem).not.toHaveBeenCalled() expect(api.updatePlatformFeeItem).not.toHaveBeenCalled()
}) })
+9 -10
View File
@@ -23,7 +23,6 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue' import InlineHelp from '@/shared/components/InlineHelp.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -120,6 +119,7 @@ const queryFields = computed<QueryField[]>(() => [
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'feeTypeName', label: '费用项目', minWidth: 210, slotName: 'feeItem' }, { key: 'feeTypeName', label: '费用项目', minWidth: 210, slotName: 'feeItem' },
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'feeTypeName' },
{ key: 'category', prop: 'category', label: '费用分类', minWidth: 150 }, { key: 'category', prop: 'category', label: '费用分类', minWidth: 150 },
{ key: 'defaultPrice', label: '默认价格(CNY)', width: 150, formatter: (row: PlatformFeeItemResource) => row.defaultPrice == null ? '—' : Number(row.defaultPrice).toFixed(2) }, { key: 'defaultPrice', label: '默认价格(CNY)', width: 150, formatter: (row: PlatformFeeItemResource) => row.defaultPrice == null ? '—' : Number(row.defaultPrice).toFixed(2) },
{ key: 'tradeScope', label: '贸易范围', width: 110, slotName: 'tradeScope' }, { key: 'tradeScope', label: '贸易范围', width: 110, slotName: 'tradeScope' },
@@ -132,7 +132,6 @@ const columns: DataTableColumn[] = [
{ key: 'createdAt', label: '创建日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.createdAt) }, { key: 'createdAt', label: '创建日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.createdAt) },
{ key: 'updatedByName', label: '更新人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.updatedByName || '—' }, { key: 'updatedByName', label: '更新人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.updatedByName || '—' },
{ key: 'updatedAt', label: '更新日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.updatedAt) }, { key: 'updatedAt', label: '更新日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.updatedAt) },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
] ]
function optionalText(value: string | null) { function optionalText(value: string | null) {
@@ -430,22 +429,22 @@ onMounted(() => {
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AppDrawer class="admin-density admin-density-drawer admin-density-drawer--editor admin-workbench is-drawer" <AppDialog class="admin-density-dialog admin-workbench is-dialog fee-item-editor"
v-model="editorOpen" v-model="editorOpen"
:title="editorTitle" :title="editorTitle"
size="min(620px, 94vw)" width="min(760px, 94vw)" align-center
:loading="editorLoading" :loading="editorLoading"
:confirm-loading="saving" :confirm-loading="saving"
> >
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading"> <el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用基本信息"> <section class="density-dialog-section workbench-evidence" aria-label="费用基本信息">
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3> <h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
<table class="density-table density-table--editable fee-item-editor-table"><tbody> <table class="density-table density-table--editable fee-item-editor-table"><tbody>
<tr><td><el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item></td><td><el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item></td></tr> <tr><td><el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item></td><td><el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item></td></tr>
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" /></el-form-item></td></tr> <tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" label-width="120px" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="选填" /></el-form-item></td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用方向与供应商"> <section class="density-dialog-section workbench-evidence" aria-label="费用方向与供应商">
<h3 class="density-dialog-section__title">费用方向 <InlineHelp title="费用方向说明">选择费用在提货和仓库环节的应收、应付属性。</InlineHelp></h3> <h3 class="density-dialog-section__title">费用方向 <InlineHelp title="费用方向说明">选择费用在提货和仓库环节的应收、应付属性。</InlineHelp></h3>
<table class="density-table density-table--editable fee-direction-table"><thead><tr><th scope="col">环节</th><th scope="col">应付</th><th scope="col">应收</th></tr></thead><tbody> <table class="density-table density-table--editable fee-direction-table"><thead><tr><th scope="col">环节</th><th scope="col">应付</th><th scope="col">应收</th></tr></thead><tbody>
<tr><th scope="row">提货环节</th><td><el-checkbox v-model="form.pickupPayable" aria-label="提货环节应付" /></td><td><el-checkbox v-model="form.pickupReceivable" aria-label="提货环节应收" /></td></tr> <tr><th scope="row">提货环节</th><td><el-checkbox v-model="form.pickupPayable" aria-label="提货环节应付" /></td><td><el-checkbox v-model="form.pickupReceivable" aria-label="提货环节应收" /></td></tr>
@@ -454,7 +453,7 @@ onMounted(() => {
<tr v-if="hasPayableDirection"><td colspan="3"><el-form-item label="供应商类别" required><el-select v-model="form.supplierCategory" placeholder="请选择供应商类别"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item></td></tr> <tr v-if="hasPayableDirection"><td colspan="3"><el-form-item label="供应商类别" required><el-select v-model="form.supplierCategory" placeholder="请选择供应商类别"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item></td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用适用说明"> <section class="density-dialog-section workbench-evidence" aria-label="费用适用说明">
<h3 class="density-dialog-section__title">适用说明</h3> <h3 class="density-dialog-section__title">适用说明</h3>
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item></td></tr></tbody></table> <table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item></td></tr></tbody></table>
</section> </section>
@@ -465,7 +464,7 @@ onMounted(() => {
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button> <el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
</div> </div>
</template> </template>
</AppDrawer> </AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" <AppDialog class="admin-density-dialog admin-workbench is-dialog"
v-model="statusOpen" v-model="statusOpen"
+24 -1
View File
@@ -1,5 +1,6 @@
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 { refundFinanceConsumption } from './finance'
import { actionReconciliationStatement, adjustFinanceAccount, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, downloadFinanceRechargeVoucher, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceRechargeRequests, listFinanceStatements, reviewFinanceFeeSource, reviewFinanceRechargeRequest, updateFinanceBill, updateFinanceCreditLimit, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance' import { actionReconciliationStatement, adjustFinanceAccount, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, downloadFinanceRechargeVoucher, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceRechargeRequests, listFinanceStatements, reviewFinanceFeeSource, reviewFinanceRechargeRequest, updateFinanceBill, updateFinanceCreditLimit, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => { describe('finance api', () => {
@@ -88,7 +89,7 @@ describe('finance api', () => {
}) })
it('uses the formal G1 account ledger path and fixed page DTO', async () => { it('uses the formal G1 account ledger path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } }) const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', walletBusinessNo: 'AC202608110001', changeType: 'RECHARGE', walletAmount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' }) const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
@@ -96,6 +97,22 @@ describe('finance api', () => {
expect(result.records[0]).toMatchObject({ ledgerId: '81', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceNo: 'RC-91' }) expect(result.records[0]).toMatchObject({ ledgerId: '81', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceNo: 'RC-91' })
}) })
it('preserves storage rent freeze and settlement amounts from the account ledger DTO', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [
{ ledgerId: '61', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230003', changeType: 'CONSUMPTION', walletAmount: -10.01, actualBalanceAfter: 9905.22, frozenBalanceAfter: 35.47, availableBalanceAfter: 9969.73 },
{ ledgerId: '60', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230002', changeType: 'FREEZE', walletAmount: -10.01, actualBalanceAfter: 9915.23, frozenBalanceAfter: 45.48, availableBalanceAfter: 9969.73 },
{ ledgerId: '59', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230001', changeType: 'FREEZE', walletAmount: 0 },
{ ledgerId: '58', documentType: 'SETTLEMENT' },
], page: { total: 4 } })
const { records } = await listFinanceAccountLedger('10001')
expect(records[0]).toMatchObject({ businessNo: 'AC202610230003', amount: '-10.01', actualBalanceAfter: '9905.22', frozenBalanceAfter: '35.47', availableBalanceAfter: '9969.73' })
expect(records[1]).toMatchObject({ businessNo: 'AC202610230002', amount: '-10.01', actualBalanceAfter: '9915.23', frozenBalanceAfter: '45.48', availableBalanceAfter: '9969.73' })
expect(records[2].amount).toBe('0')
expect(records[3].amount).toBeNull()
})
it('uses frozen recharge review and account adjustment contracts', async () => { it('uses frozen recharge review and account adjustment contracts', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', createdAt: '2026-09-04T10:00:00' }], page: { page: 1, pageSize: 200, total: 1 } }) const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', createdAt: '2026-09-04T10:00:00' }], page: { page: 1, pageSize: 200, total: 1 } })
const post = vi.spyOn(http, 'post') const post = vi.spyOn(http, 'post')
@@ -311,3 +328,9 @@ describe('finance api', () => {
}) })
}) })
it('submits linked consumption refund with the caller retry key', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { changeType: 'REFUND', referenceNo: 'AC-61' } })
await expect(refundFinanceConsumption('9007199254740993', '61', { amount: '12.30', reason: '多扣' }, 'stable-key')).resolves.toMatchObject({ referenceNo: 'AC-61' })
expect(post).toHaveBeenCalledWith('/platform/finance/v1/accounts/9007199254740993/ledger/61/refunds', { amount: '12.30', reason: '多扣' }, { headers: { 'Idempotency-Key': 'stable-key' } })
})
+6 -1
View File
@@ -155,7 +155,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo), documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo),
receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange), receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange),
outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark), outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark),
businessNo: nullableStringValue(item.businessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.amount), businessNo: nullableStringValue(item.walletBusinessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.walletAmount),
actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter), actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter),
availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType), availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType),
creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter), creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter),
@@ -208,6 +208,11 @@ export async function updateFinanceCreditLimit(customerId: string, payload: { cr
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function refundFinanceConsumption(customerId: string, ledgerId: string, payload: { amount: string; reason: string }, key: string) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts/${encodeURIComponent(customerId)}/ledger/${encodeURIComponent(ledgerId)}/refunds`, payload, { headers: { 'Idempotency-Key': key } })
return unwrapApiResponse(response)
}
export async function listFinanceFeeSources(params: { export async function listFinanceFeeSources(params: {
page?: number page?: number
size?: number size?: number
@@ -25,12 +25,12 @@ let requestSerial = 0
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'billNo', prop: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'billNo', prop: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'billNo' },
{ key: 'billDate', prop: 'billDate', label: '账单日期', width: 120 }, { key: 'billDate', prop: 'billDate', label: '账单日期', width: 120 },
{ key: 'dueDate', prop: 'dueDate', label: '到期日', width: 120 }, { key: 'dueDate', prop: 'dueDate', label: '到期日', width: 120 },
{ key: 'totalAmount', label: '应收(CNY)', width: 130, align: 'right', slotName: 'totalAmount' }, { key: 'totalAmount', label: '应收(CNY)', width: 130, align: 'right', slotName: 'totalAmount' },
{ key: 'receivedAmount', label: '已收(CNY)', width: 130, align: 'right', slotName: 'receivedAmount' }, { key: 'receivedAmount', label: '已收(CNY)', width: 130, align: 'right', slotName: 'receivedAmount' },
{ key: 'outstandingAmount', label: '未收(CNY)', width: 130, align: 'right', slotName: 'outstandingAmount' }, { key: 'outstandingAmount', label: '未收(CNY)', width: 130, align: 'right', slotName: 'outstandingAmount' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
] ]
async function load() { async function load() {
+29 -1
View File
@@ -7,7 +7,6 @@
.admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); } .admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); }
.admin-density .finance-detail-context { min-width: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; } .admin-density .finance-detail-context { min-width: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; } .admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; }
.admin-density .settlement-summary.density-detail-overview { padding: 0; border: 0; }
.admin-density .statement-summary-section { padding: var(--admin-space-5); } .admin-density .statement-summary-section { padding: var(--admin-space-5); }
.admin-density .account-metrics .is-negative, .admin-density .account-metrics .is-negative,
.admin-density .density-detail-overview .is-danger, .admin-density .density-detail-overview .is-danger,
@@ -15,3 +14,32 @@
.admin-density .density-detail-overview .is-success, .admin-density .density-detail-overview .is-success,
.admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); } .admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); }
.admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); } .admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); }
/* Outrank AdminPage scoped spacing regardless of stylesheet load order; the toolbar supplies the gap. */
.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,
.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }
.admin-workbench .finance-metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
@media (max-width: 720px) {
.admin-workbench .finance-metric-grid { grid-template-columns: 1fr; }
}
/* The outer AdminPage owns the screen inset for every finance detail section. */
@media screen {
.admin-density .detail-page-layout.finance-density-detail > :is(.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer),
.admin-density .finance-density-detail > .detail-page-layout__header > .density-detail-header,
.admin-density .finance-density-detail > .detail-page-layout__summary > :is(.density-detail-overview, .statement-summary-section),
.admin-density .finance-density-detail > .detail-page-layout__content > .settlement-detail-content {
padding-inline: 0;
}
}
/* Detail sections share one vertical rhythm instead of stacking section padding. */
.admin-density .finance-density-detail > .detail-page-layout__content,
.admin-density .finance-density-detail .settlement-detail-content {
display: flex;
flex-direction: column;
gap: var(--admin-space-5);
}
.admin-density .finance-density-detail > .detail-page-layout__content .density-form-section {
padding-block: 0;
}
@@ -6,7 +6,7 @@ import { ElMessage } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants } from '@/modules/iam' import { listPlatformTenants } from '@/modules/iam'
import * as api from '../api/finance' import * as api from '../api/finance'
import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types' import type { FinanceAccountSummary, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue' import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus') const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
@@ -17,7 +17,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({ vi.mock('../api/finance', () => ({
listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(), listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(), reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(), downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
})) }))
const account: FinanceAccountSummary = { const account: FinanceAccountSummary = {
@@ -287,3 +287,105 @@ describe('customer wallet native dialogs', () => {
expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true) expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true)
}) })
}) })
const consumption: FinanceLedgerEntry = {
ledgerId: '61', businessNo: 'AC-CONSUME-61', changeType: 'CONSUMPTION', amount: '-80.00',
sourceNo: 'RELABEL-1', occurredAt: '', businessType: 'ADJUSTMENT', documentType: '', documentId: '', documentNo: '',
receivableChange: 0, payableChange: 0, outstandingReceivableAfter: 0, operatorName: '', remark: '',
}
async function openRefundDialog() {
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption], total: 1, pages: 1 })
const wrapper = await mountPage()
await click(wrapper, '退款', '.ledger-table button')
return { wrapper, dialog: activeDialog(wrapper) }
}
describe('linked original consumption refunds', () => {
it.each(['', '0', '-1', '80.01', '1.001', 'NaN'])('rejects invalid refund %s', async amount => {
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue(amount)
await dialog.get('textarea').setValue('测试退款')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('requires a reason and keeps the original consumption visible', async () => {
const { dialog } = await openRefundDialog()
expect(dialog.text()).toContain('AC-CONSUME-61')
expect(dialog.text()).toContain('RELABEL-1')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('locks a pending refund, preserves failures, and retries the identical payload with the same key', async () => {
const pending = deferred<Record<string, unknown>>()
vi.mocked(api.refundFinanceConsumption).mockReturnValueOnce(pending.promise).mockResolvedValueOnce({})
const { wrapper, dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('12.3')
await dialog.get('textarea').setValue(' 原单多扣 ')
await click(dialog, '确认退款')
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
await click(dialog, '取消')
expect(dialog.props('modelValue')).toBe(true)
pending.reject(failure())
await flushPromises()
expect(dialog.text()).toContain('测试操作未成功')
const first = vi.mocked(api.refundFinanceConsumption).mock.calls[0]!
expect(first).toEqual([account.customerId, '61', { amount: '12.30', reason: '原单多扣' }, expect.stringMatching(/^finance-refund-/)])
await click(dialog, '确认退款')
expect(vi.mocked(api.refundFinanceConsumption).mock.calls[1]).toEqual(first)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('账户流水')
})
it('creates a new key for changed amount after a rejected request', async () => {
vi.mocked(api.refundFinanceConsumption).mockRejectedValue(failure())
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await dialog.get('textarea').setValue('退款')
await click(dialog, '确认退款')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('11')
await click(dialog, '确认退款')
const calls = vi.mocked(api.refundFinanceConsumption).mock.calls
expect(calls[0]![3]).not.toBe(calls[1]![3])
})
it('hides refunds without adjustment permission and labels refund original references', async () => {
can.mockImplementation(permission => permission !== 'platform:finance:customer-account:adjust')
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption, { ...consumption, ledgerId: '62', changeType: 'REFUND', amount: '10.00', referenceNo: 'AC-CONSUME-61' }], total: 2, pages: 1 })
const wrapper = await mountPage()
expect(wrapper.findAll('.ledger-table button').some(button => button.text() === '退款')).toBe(false)
expect(wrapper.text()).toContain('消费退款')
expect(wrapper.text()).toContain('原消费:')
})
})
describe('customer wallet detail error recovery', () => {
it.each(['ACCOUNT', 'LEDGER'] as const)('reloads the current detail after %s fails without same-route navigation', async failurePoint => {
if (failurePoint === 'ACCOUNT') vi.mocked(api.listFinanceAccounts).mockRejectedValueOnce(failure())
else vi.mocked(api.listFinanceAccountLedger).mockRejectedValueOnce(failure())
const wrapper = await mountPage()
expect(wrapper.get('.load-error-state').text()).toContain('测试操作未成功')
const pending = deferred<Awaited<ReturnType<typeof api.listFinanceAccounts>>>()
vi.mocked(api.listFinanceAccounts).mockReturnValueOnce(pending.promise)
await click(wrapper, '重新加载', '.load-error-state button')
expect(api.listFinanceAccounts).toHaveBeenCalledTimes(2)
pending.resolve({ records: [{ ...account, actualBalance: '90.00' }], total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('.load-error-state').exists()).toBe(false)
expect(wrapper.get('[aria-label="客户资金账户余额"]').text()).toContain('¥90.00')
expect(wrapper.find('.ledger-table').exists()).toBe(true)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
})
})
it('enforces the refund contract reason limit before submitting', async () => {
const { wrapper, dialog } = await openRefundDialog()
expect(dialog.get('textarea').attributes('maxlength')).toBe('500')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
const vm = wrapper.vm as unknown as { refundForm: { reason: string } }
vm.refundForm.reason = '退'.repeat(501)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
vm.refundForm.reason = '退'.repeat(500)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).toHaveBeenCalledWith(account.customerId, consumption.ledgerId, { amount: '10.00', reason: '退'.repeat(500) }, expect.any(String))
})
@@ -18,7 +18,7 @@ vi.mock('../api/finance', () => ({
listFinanceAccountLedger: vi.fn(), listFinanceAccountLedger: vi.fn(),
listFinanceRechargeRequests: vi.fn(), listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), reviewFinanceRechargeRequest: vi.fn(),
adjustFinanceAccount: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(),
updateFinanceCreditLimit: vi.fn(), updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(), downloadFinanceRechargeVoucher: vi.fn(),
getFinanceCustomerTerms: vi.fn(), getFinanceCustomerTerms: vi.fn(),
@@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('v-else-if="!detailOpen"') expect(page).toContain('v-else-if="!detailOpen"')
expect(page).toContain('返回客户账户') expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout') expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="density-detail-overview account-metrics"') expect(page).toContain('class="account-metrics"')
for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric) for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric)
expect(page).toContain('class="ledger-query workbench-query"') expect(page).toContain('class="ledger-query workbench-query"')
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"') expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
@@ -83,22 +83,22 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps term editing aligned with the account summary and nearby actions', () => { it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker) for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('density-detail-overview account-metrics') expect(page).toContain('account-metrics')
for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column) for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column)
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)') expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
expect(page).toContain('justify-content: start') expect(page).toContain('justify-content: start')
expect(page).toContain('class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"') expect(page).toContain('class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"')
expect(page).toContain('class="density-detail-header"') expect(page).toContain('class="density-detail-header"')
expect(page).toContain('padding: var(--admin-space-4) 0') expect(page).toContain('padding: var(--admin-space-4) 0')
expect(page).toContain('class="detail-table-scroll workbench-table"') expect(page).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(page).toContain('class="ledger-identifier"') expect(page).toContain('class="ledger-identifier"')
expect(page).toContain('class="ledger-document"') expect(page).toContain('class="ledger-document"')
expect(page).toContain('text-overflow: ellipsis') expect(page).toContain('overflow-wrap: anywhere')
expect(page).toContain('white-space: nowrap') expect(page).not.toContain('text-overflow: ellipsis')
}) })
it('keeps account detail and term editing readable across the 1920 workspace', () => { it('keeps account detail and term editing readable across the full workspace width', () => {
expect(page).toContain('width: min(100%, 1920px)') expect(page).not.toContain('width: min(100%, 1920px)')
expect(page).toContain('@media (min-width: 1920px)') expect(page).toContain('@media (min-width: 1920px)')
expect(page).toContain('finance-density-detail') expect(page).toContain('finance-density-detail')
expect(page).toContain('../financeDensity.css') expect(page).toContain('../financeDensity.css')
@@ -186,7 +186,10 @@ describe('FinanceCustomerAccountsView delivery', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('客户A') expect(wrapper.text()).toContain('客户A')
expect(wrapper.text()).toContain('C-001') const profile = wrapper.find('.customer-profile-section')
expect(profile.text()).toContain('客户名称客户A')
expect(profile.text()).not.toContain('客户编号')
expect(profile.text()).not.toContain('C-001')
expect(wrapper.text()).toContain('张三') expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('13800000000') expect(wrapper.text()).toContain('13800000000')
const metrics = wrapper.find('.account-metrics') const metrics = wrapper.find('.account-metrics')
@@ -194,7 +197,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(metrics.text()).toContain('实际余额¥126580.50 CNY') expect(metrics.text()).toContain('实际余额¥126580.50 CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00 CNY') expect(metrics.text()).toContain('冻结金额¥8500.00 CNY')
expect(metrics.text()).toContain('总可用余额¥138080.50 CNY') expect(metrics.text()).toContain('总可用余额¥138080.50 CNY')
expect(metrics.text()).toContain('授信额度¥20000.00 2028-09-10 到期') expect(metrics.text()).toContain('授信额度¥20000.00 CNY2028-09-10 到期')
expect(metrics.text()).toContain('已用授信¥0.00 CNY') expect(metrics.text()).toContain('已用授信¥0.00 CNY')
expect(metrics.text()).toContain('可用授信¥20000.00 CNY') expect(metrics.text()).toContain('可用授信¥20000.00 CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary) for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css' import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue' import { ArrowLeft, CircleCheck, CreditCard, Download, LockKeyhole, MinusCircle, PlusCircle, Save, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
@@ -22,7 +22,7 @@ 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 RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance' import { refundFinanceConsumption, adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status' import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types' import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
@@ -45,6 +45,7 @@ const saving = ref(false)
const selected = ref<FinanceAccountSummary | null>(null) const selected = ref<FinanceAccountSummary | null>(null)
const ledger = ref<FinanceLedgerEntry[]>([]) const ledger = ref<FinanceLedgerEntry[]>([])
const ledgerLoading = ref(false) const ledgerLoading = ref(false)
const detailLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null) const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null) const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30) const creditDays = ref(30)
@@ -57,6 +58,12 @@ const rechargeLoading = ref(false)
const rechargeError = ref<ApiErrorDetails | null>(null) const rechargeError = ref<ApiErrorDetails | null>(null)
const accountExportBusy = ref(false) const accountExportBusy = ref(false)
const ledgerExportBusy = ref(false) const ledgerExportBusy = ref(false)
const refundOpen = ref(false)
const refundSaving = ref(false)
const refundSource = ref<{ customerId: string; entry: FinanceLedgerEntry } | null>(null)
const refundForm = reactive({ amount: '', reason: '' })
const refundError = ref<ApiErrorDetails | null>(null)
let refundAttempt = { payload: '', key: '' }
const adjustmentOpen = ref(false) const adjustmentOpen = ref(false)
const adjustmentSaving = ref(false) const adjustmentSaving = ref(false)
const adjustmentAccount = ref<FinanceAccountSummary | null>(null) const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
@@ -91,16 +98,16 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' }, { key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status', afterColumn: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' }, { key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' }, { key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' }, { key: 'actualBalance', label: '实际余额(CNY)', align: 'right', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' }, { key: 'frozenBalance', label: '冻结金额(CNY)', align: 'right', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' }, { key: 'availableBalance', label: '总可用余额(CNY)', align: 'right', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' }, { key: 'creditLimit', label: '授信额度(CNY)', align: 'right', width: 155, slotName: 'creditLimit' },
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' }, { key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' }, { key: 'creditUsed', label: '已用授信(CNY)', align: 'right', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' }, { key: 'pendingRechargeCount', label: '待审核充值', align: 'right', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' }, { key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
] ]
const ledgerQueryFields: QueryField[] = [ const ledgerQueryFields: QueryField[] = [
@@ -114,21 +121,22 @@ const ledgerQueryFields: QueryField[] = [
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 }, { key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
] ]
const ledgerColumns: DataTableColumn[] = [ const ledgerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' }, { key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' }, { key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, align: 'right', slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' }, { key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' }, { key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, align: 'right', slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' }, { key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, align: 'right', slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' }, { key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, align: 'right', slotName: 'availableBalanceAfter' },
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, slotName: 'creditUsedAfter' }, { key: 'creditUsedAfter', label: '变动后已用授信', width: 160, align: 'right', slotName: 'creditUsedAfter' },
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
] ]
const rechargeColumns: DataTableColumn[] = [ const rechargeColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' }, { key: 'requestNo', label: '申请单号', minWidth: 190 },
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' }, { key: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'requestNo' }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 }, { key: 'amount', label: '充值金额(CNY)', width: 150, align: 'right', slotName: 'amount' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 }, { key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 }, { key: 'reviewedByName', label: '审核人', width: 130 },
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' }, { key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
] ]
const sortedRechargeRequests = computed(() => [...rechargeRequests.value].sort((left, right) => Number(right.status === 'PENDING') - Number(left.status === 'PENDING'))) const sortedRechargeRequests = computed(() => [...rechargeRequests.value].sort((left, right) => Number(right.status === 'PENDING') - Number(left.status === 'PENDING')))
@@ -204,7 +212,15 @@ async function refreshWalletDetail() {
if (!selected.value) return if (!selected.value) return
await Promise.all([loadRechargeRequests(), loadLedger()]) await Promise.all([loadRechargeRequests(), loadLedger()])
} }
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) } async function retryDrawer() {
if (!canView.value || detailLoading.value) return
if (editing.value) {
if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, true)
return
}
const customerId = String(route.params.customerId || selected.value?.customerId || '')
if (customerId) await restoreAccountDetail(customerId)
}
async function saveTerms() { async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return } if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
saving.value = true saving.value = true
@@ -237,7 +253,41 @@ function paymentMethodLabel(value: unknown) {
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他' return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
} }
function walletBusinessType(value: unknown) { function walletBusinessType(value: unknown) {
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value) return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', REFUND: '消费退款', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
}
function canRefund(entry: FinanceLedgerEntry) {
return canAdjust.value && entry.changeType === 'CONSUMPTION' && !!entry.ledgerId && Number(entry.amount) < 0
}
function openRefund(entry: FinanceLedgerEntry) {
if (!selected.value || !canRefund(entry) || refundSaving.value) return
refundSource.value = { customerId: selected.value.customerId, entry: { ...entry } }
Object.assign(refundForm, { amount: '', reason: '' })
refundError.value = null
refundAttempt = { payload: '', key: '' }
refundOpen.value = true
}
async function submitRefund() {
const source = refundSource.value
if (refundSaving.value || !source || !canRefund(source.entry) || selected.value?.customerId !== source.customerId) return
const raw = refundForm.amount.trim()
const amount = Number(raw)
if (!/^\d+(?:\.\d{1,2})?$/.test(raw) || !Number.isFinite(amount) || amount <= 0 || amount > Math.abs(Number(source.entry.amount))) {
ElMessage.warning('退款金额必须大于 0、最多两位小数,且不能超过原消费金额'); return
}
const reason = refundForm.reason.trim()
if (!reason || reason.length > 500) { ElMessage.warning('请填写退款说明(最多 500 字)'); return }
const payload = { amount: amount.toFixed(2), reason }
const signature = JSON.stringify([source.customerId, source.entry.ledgerId, payload])
if (refundAttempt.payload !== signature) refundAttempt = { payload: signature, key: 'finance-refund-' + crypto.randomUUID() }
refundSaving.value = true
refundError.value = null
try {
await refundFinanceConsumption(source.customerId, source.entry.ledgerId, payload, refundAttempt.key)
refundOpen.value = false
ElMessage.success('消费退款已入账')
if (selected.value?.customerId === source.customerId) await refreshWalletDetail()
} catch (error) { refundError.value = getApiErrorDetails(error, '退款失败,请重试') }
finally { refundSaving.value = false }
} }
function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') { function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') {
adjustmentAccount.value = account adjustmentAccount.value = account
@@ -425,12 +475,15 @@ async function closeDetail() {
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' }) if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
} }
async function restoreAccountDetail(customerId: string) { async function restoreAccountDetail(customerId: string) {
if (!canView.value || detailLoading.value) return
detailLoading.value = true
detailOpen.value = true; editing.value = false; ledgerError.value = null detailOpen.value = true; editing.value = false; ledgerError.value = null
try { try {
selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return } if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
await Promise.all([loadLedger(), loadRechargeRequests()]) await Promise.all([loadLedger(), loadRechargeRequests()])
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') } } catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
finally { detailLoading.value = false }
} }
onMounted(() => { onMounted(() => {
const customerId = String(route.params.customerId || '') const customerId = String(route.params.customerId || '')
@@ -463,18 +516,43 @@ onBeforeUnmount(clearVoucherPreview)
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template> <template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal"> <AdminPage v-else as="div" class="finance-account-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"> <section class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'"> <DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template> <template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额">
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState> <article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">实际余额</h2></div>
<div class="workbench-metric-value"><strong :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><LockKeyhole aria-hidden="true" /></span><h2 class="workbench-metric-label">冻结金额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.frozenBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">总可用余额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.availableBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">授信额度</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditLimit) }}</strong> <span>CNY</span></div><div class="workbench-note" :class="{ 'credit-expired': selected.creditExpiresOn && !selected.creditActive }">{{ creditExpiryLabel(selected) }}</div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">已用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditUsed) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">可用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.availableCredit) }}</strong> <span>CNY</span></div>
</article>
</section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected"> <template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection> <FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions account-profile" :column="3"><el-descriptions-item label="客户名称">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false"> <FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" /> <LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
<div v-else class="detail-table-scroll workbench-table"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide"> <div v-else class="detail-table-scroll workbench-table is-page-surface"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template> <template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template> <template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template> <template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
@@ -482,8 +560,7 @@ onBeforeUnmount(clearVoucherPreview)
</DataTable></div> </DataTable></div>
</FormSection> </FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false"> <FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" /> <div class="detail-table-scroll workbench-table is-page-surface"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" appearance="integrated" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #filters><QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" /></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="canRefund(row as FinanceLedgerEntry)" class="density-action" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></div></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection> </FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection> <FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template> </template>
@@ -491,6 +568,21 @@ onBeforeUnmount(clearVoucherPreview)
</DetailPageLayout> </DetailPageLayout>
</section> </section>
</AdminPage> </AdminPage>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="refundOpen" title="原消费退款" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退款" :confirm-loading="refundSaving" @confirm="submitRefund">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="source" aria-label="原消费资料"><h3 class="density-dialog-section__title">原消费(CNY)</h3><table class="density-table wallet-summary"><tbody>
<tr><th scope="row">原消费流水</th><td>{{ refundSource?.entry.businessNo || refundSource?.entry.ledgerId }}</td></tr>
<tr><th scope="row">关联业务单据</th><td>{{ refundSource?.entry.sourceNo || refundSource?.entry.documentNo || '—' }}</td></tr>
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment"> <AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<div class="density-dialog-sections"> <div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料"> <section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料">
@@ -554,7 +646,7 @@ onBeforeUnmount(clearVoucherPreview)
} }
.finance-account-detail__surface { .finance-account-detail__surface {
width: min(100%, 1920px); width: 100%;
margin: 0 auto; margin: 0 auto;
overflow: hidden; overflow: hidden;
} }
@@ -602,27 +694,26 @@ onBeforeUnmount(clearVoucherPreview)
object-fit: contain; object-fit: contain;
} }
.ledger-section { .account-metrics {
padding: var(--admin-space-4) 0 0; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--admin-space-5);
padding-block: var(--admin-space-3) 0;
} }
.recharge-section { .account-metrics .workbench-metric-value > .is-negative,
padding: var(--admin-space-4) 0 0; .account-metrics .credit-expired {
border-bottom: 1px solid var(--workbench-border); color: var(--el-color-danger);
}
.account-profile :deep(.el-descriptions__table) {
table-layout: fixed;
} }
.recharge-table { .recharge-table {
min-width: 0; min-width: 0;
} }
.ledger-query {
margin-bottom: var(--admin-space-3);
}
:deep(.ledger-query.query-toolbar) {
padding: 0;
}
.detail-table-scroll { .detail-table-scroll {
overflow-x: auto; overflow-x: auto;
} }
@@ -634,9 +725,7 @@ onBeforeUnmount(clearVoucherPreview)
.ledger-identifier, .ledger-identifier,
.ledger-document { .ledger-document {
display: block; display: block;
overflow: hidden; overflow-wrap: anywhere;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -693,12 +782,6 @@ onBeforeUnmount(clearVoucherPreview)
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
border-left: 0;
}
.finance-account-detail__surface { .finance-account-detail__surface {
width: 100%; width: 100%;
} }
@@ -727,16 +810,7 @@ onBeforeUnmount(clearVoucherPreview)
gap: 12px; gap: 12px;
} }
.account-metric,
.account-metric:first-child,
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
}
.account-metric + .account-metric {
border-left: 0;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@@ -127,6 +127,26 @@ describe('FinanceCustomerArapView G2 operations', () => {
} }
}) })
it.each([['PAYABLE_BILLED', '已生成应付单'], ['PARTIALLY_PAID', '部分付款'], ['PAID', '已付款']])(
'shows payable progress and prevents editing occupied or paid sources: %s', async (status, label) => {
const row = { ...feeSummary, direction: 'PAYABLE', sourceSystem: 'FINANCE', sourceType: 'MANUAL',
reviewStatus: 'APPROVED', sourceStatus: status, status, statementGenerated: false } as FinanceFeeSourceSummary
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [row], total: 1, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
await loadRows(wrapper)
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain(label)
expect(body.text()).not.toContain('可结算')
expect(body.text()).not.toContain('未生成')
expect(body.findAll('button').map(button => button.text().trim())).toEqual(['详情'])
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, ...row })
await body.get('button').trigger('click')
await flushPromises()
expect(wrapper.get('.arap-metrics').text()).toContain(label)
expect(wrapper.get('.arap-information').text()).toContain('不适用')
},
)
it('shows detail from the list permission but keeps G2 operations independent', async () => { it('shows detail from the list permission but keeps G2 operations independent', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list']) const wrapper = await mountView(['platform:finance:customer-arap:list'])
await loadRows(wrapper) await loadRows(wrapper)
@@ -253,17 +273,19 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.find('.arap-metrics').exists()).toBe(true) expect(wrapper.find('.arap-metrics').exists()).toBe(true)
expect(wrapper.findAll('.arap-metric')).toHaveLength(4) expect(wrapper.findAll('.arap-metric')).toHaveLength(4)
expect(wrapper.find('.arap-information').text()).toContain('单据信息') expect(wrapper.find('.arap-information').text()).toContain('单据信息')
expect(wrapper.find('.arap-settlement').text()).toContain('审核与结算') expect(wrapper.find('.arap-settlement').exists()).toBe(false)
expect(wrapper.find('.arap-information').text()).toContain('已生成对账单')
expect(wrapper.find('.arap-information').text()).toContain('审核原因')
expect(wrapper.find('.operation-log').text()).toContain('操作记录') expect(wrapper.find('.operation-log').text()).toContain('操作记录')
expect(wrapper.find('.arap-information').text()).toContain('业务日期') expect(wrapper.find('.arap-information').text()).toContain('业务日期')
expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00') expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00')
}) })
it('keeps the fee-source detail readable across the 1920 workspace', () => { it('keeps the fee-source detail readable across the full workspace width', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8') const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(page).toContain('width: min(100%, 1920px)') expect(page).not.toContain('width: min(100%, 1920px)')
expect(page).toContain('@media (min-width: 1920px)') expect(page).toContain('<el-descriptions border :column="3" class="arap-detail density-descriptions">')
expect(page).toContain('grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr)') expect(page).not.toContain('arap-detail-grid')
expect(page).toContain('finance-density-detail') expect(page).toContain('finance-density-detail')
expect(page).not.toContain('max-width: 520px') expect(page).not.toContain('max-width: 520px')
}) })
@@ -126,7 +126,7 @@ const queryFields = computed<QueryField[]>(() => [
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
}, },
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] }, { key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] }, { key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已生成应付单', value: 'PAYABLE_BILLED' }, { label: '部分付款', value: 'PARTIALLY_PAID' }, { label: '已付款', value: 'PAID' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions }, { key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] }, { key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 }, { key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
@@ -141,12 +141,19 @@ function closeCustomerSelectOnScroll() {
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) { function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG' return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
} }
// Eight fixed fields precede settlement status; optional source fields also occupy a cell.
const settlementStatusSpan = computed(() => {
const source = detail.value
const precedingFields = 8 + Number(source?.direction === 'PAYABLE') + Number(source?.quantity != null) + Number(Boolean(source?.periodStart))
return 3 - precedingFields % 3
})
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' }, { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' }, { key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus', afterColumn: 'businessReference' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' }, { key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status', afterColumn: 'businessReference' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated', afterColumn: 'businessReference' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' }, { key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', align: 'right', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
] ]
function customerName(customerId: string, snapshot?: string) { function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim() const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
@@ -191,7 +198,7 @@ function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 's
} }
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' } function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
function sourceStatusDisplay(value: unknown) { function sourceStatusDisplay(value: unknown) {
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' } const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', PAYABLE_BILLED: '已生成应付单', PARTIALLY_PAID: '部分付款', PAID: '已付款', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
return labels[String(value || '').toUpperCase()] || '状态待确认' return labels[String(value || '').toUpperCase()] || '状态待确认'
} }
function formatDateTime(value: unknown) { function formatDateTime(value: unknown) {
@@ -414,17 +421,17 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template> <template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template> <template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template> <template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template> <template #generated="{ row }">{{ row.direction === 'PAYABLE' ? '不适用' : row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template> <template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal"> <AdminPage v-else as="div" class="finance-arap-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-arap-detail__surface workbench-panel is-flush is-page-surface"> <section class="surface-panel finance-arap-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情"> <DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template> <template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template> <template v-else-if="detail"><FormSection class="arap-information density-form-section" title="单据信息与审核结算" :bordered="false"><el-descriptions border :column="3" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单" :span="settlementStatusSpan">{{ detail.direction === 'PAYABLE' ? '不适用' : detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="3">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table is-page-surface"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
</DetailPageLayout> </DetailPageLayout>
</section> </section>
</AdminPage> </AdminPage>
@@ -502,7 +509,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
} }
.finance-arap-detail__surface { .finance-arap-detail__surface {
width: min(100%, 1920px); width: 100%;
margin: 0 auto; margin: 0 auto;
overflow: hidden; overflow: hidden;
} }
@@ -522,23 +529,12 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted); color: var(--workbench-muted);
} }
.arap-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: var(--admin-space-6);
border-bottom: 1px solid var(--workbench-border);
}
.arap-information, .arap-information,
.arap-settlement,
.operation-log { .operation-log {
padding: var(--admin-space-4) 0; padding: var(--admin-space-4) 0;
} }
.arap-settlement {
padding-left: var(--admin-space-6);
border-left: 1px solid var(--workbench-border);
}
@@ -566,16 +562,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted); color: var(--workbench-muted);
} }
@media (min-width: 1920px) {
.arap-detail-grid {
gap: var(--admin-space-4);
}
.arap-settlement {
padding-left: var(--admin-space-4);
}
}
@media (max-width: 1080px) { @media (max-width: 1080px) {
.finance-arap-detail__surface { .finance-arap-detail__surface {
@@ -591,16 +577,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
border-left: 0; border-left: 0;
} }
.arap-detail-grid {
grid-template-columns: 1fr;
gap: 0;
}
.arap-settlement {
padding-left: 0;
border-left: 0;
border-top: 1px solid var(--workbench-border);
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -108,13 +108,37 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull() expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull() expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY') expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20) expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(22)
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人') expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00') expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501') expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501')
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy() expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy() expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.el-drawer')).toBeNull() expect(document.querySelector('.el-drawer')).toBeNull()
expect(document.querySelector('.statement-detail-page .el-tabs')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户A')
expect(wrapper.find('[aria-label="调整记录"]').isVisible()).toBe(true)
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
})
it('collapses advanced criteria without losing values and resets hidden filters', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
const toolbar = () => wrapper.find('#statement-query-filters')
const toggle = () => toolbar().find('button[aria-controls="statement-query-filters"]')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(3)
await toggle().trigger('click')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(9)
vm.filters.containerNo = 'CONT-31'
vm.filters.createdRange = ['2026-08-01', '2026-08-31']
await toggle().trigger('click')
expect(vm.filters.containerNo).toBe('CONT-31')
const reset = toolbar().findAll('button').find((button) => button.text() === '重置')!
await reset.trigger('click')
await flushPromises()
expect(vm.filters.containerNo).toBe('')
expect(vm.filters.createdRange).toEqual([])
expect(vm.appliedFilters.containerNo).toBe('')
}) })
it('uses the shared four-group summary grid with long remarks spanning two groups', () => { it('uses the shared four-group summary grid with long remarks spanning two groups', () => {
@@ -163,9 +187,8 @@ describe('FinanceCustomerStatementsView', () => {
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => { it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' } const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
await vm.openDetail(statement) await vm.openDetail(statement)
vm.activeDetailTab = 'payments'
await flushPromises() await flushPromises()
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701') expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
@@ -279,7 +302,7 @@ describe('FinanceCustomerStatementsView', () => {
{ label: '已结清', value: 'PAID' }, { label: '已结清', value: 'PAID' },
{ label: '已作废', value: 'VOIDED' }, { label: '已作废', value: 'VOIDED' },
]) ])
expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status' }) expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status', afterColumn: 'billNo' })
}) })
it('creates a formal statement export task with only non-empty filters', async () => { it('creates a formal statement export task with only non-empty filters', async () => {
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus } from '@lucide/vue' import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -15,6 +15,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue' import InlineHelp from '@/shared/components/InlineHelp.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.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'
@@ -65,7 +66,7 @@ const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null) const detail = ref<FinanceReconciliationDetail | null>(null)
const detailLoading = ref(false) const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null) const detailError = ref<ApiErrorDetails | null>(null)
const activeDetailTab = ref<'lines' | 'payments'>('lines') const showAdvancedFilters = ref(false)
const detailCache = new Map<string, FinanceReconciliationDetail>() const detailCache = new Map<string, FinanceReconciliationDetail>()
const createOpen = ref(false) const createOpen = ref(false)
const createBusy = ref(false) const createBusy = ref(false)
@@ -118,25 +119,32 @@ const fields = computed<QueryField[]>(() => [
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, { key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, { key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
]) ])
const visibleFields = computed(() => showAdvancedFilters.value ? fields.value : fields.value.slice(0, 3))
function resetFilters() {
filters.value = { status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [], settled: '', lastPaymentRange: [], createdRange: [] }
void load()
}
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
{ key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status', afterColumn: 'billNo' },
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus', afterColumn: 'billNo' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled', afterColumn: 'billNo' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'periodStart', label: '结算起始日', width: 130 }, { key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 }, { key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 }, { key: 'creditDays', label: '账期(天)', align: 'right', width: 100 },
{ key: 'lineCount', label: '费用明细数', width: 110 }, { key: 'lineCount', label: '费用明细数', width: 110 },
{ key: 'sourceCount', label: '来源数量', width: 100 }, { key: 'sourceCount', label: '来源数量', width: 100 },
{ key: 'currency', label: '货币', width: 86 }, { key: 'currency', label: '货币', width: 86 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 }, { key: 'sourceAmount', label: '调整前金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 }, { key: 'adjustmentAmount', label: '调整金额(CNY)', align: 'right', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 }, { key: 'totalAmount', label: '总应收金额(CNY)', align: 'right', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 }, { key: 'receivedAmount', label: '已收金额(CNY)', align: 'right', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 }, { key: 'outstandingAmount', label: '未收金额(CNY)', align: 'right', width: 145 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' }, { key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 180 },
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' }, { key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' }, { key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
@@ -157,10 +165,10 @@ const detailLineColumns: DataTableColumn[] = [
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' }, { key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' }, { key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
{ key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 },
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' }, { key: 'quantity', label: '计费量', align: 'right', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' }, { key: 'unitPrice', label: '单价(CNY)', align: 'right', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 }, { key: 'sourceAmount', label: '来源金额(CNY)', align: 'right', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', width: 150 }, { key: 'amount', label: '账单金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 }, { key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' }, { key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' }, { key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
@@ -169,7 +177,7 @@ const detailLineColumns: DataTableColumn[] = [
const paymentColumns: DataTableColumn[] = [ const paymentColumns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' }, { key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
{ key: 'amount', label: '支付金额(CNY)', width: 150 }, { key: 'amount', label: '支付金额(CNY)', align: 'right', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' }, { key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 }, { key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' }, { key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
@@ -612,9 +620,10 @@ watch(detailRouteId, (statementId) => {
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情"> <DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template>
<template v-if="detail" #summary> <template v-if="detail" #summary>
<dl class="density-detail-overview"><div><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div><div><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div></dl>
<FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要"> <FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要">
<dl class="density-fields statement-summary-grid"> <dl class="density-fields statement-summary-grid">
<div class="statement-summary-item"><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div>
<div class="statement-summary-item"><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div>
<div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div> <div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div> <div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div> <div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
@@ -638,12 +647,11 @@ watch(detailRouteId, (statementId) => {
</dl> </dl>
</FormSection> </FormSection>
</template> </template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="lines"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>账单明细表</span></template>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated /> <el-skeleton v-else-if="detailLoading" :rows="4" animated />
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false"> <template v-else-if="detail">
<div class="detail-table-scroll workbench-table"> <FormSection class="density-form-section" :bordered="false" title="账单明细表" aria-label="账单明细">
<div class="detail-table-scroll workbench-table is-page-surface">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细"> <DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template> <template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template> <template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
@@ -658,12 +666,9 @@ watch(detailRouteId, (statementId) => {
<template #periodEnd>{{ detail.periodEnd || '—' }}</template> <template #periodEnd>{{ detail.periodEnd || '—' }}</template>
</DataTable> </DataTable>
</div> </div>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><div class="workbench-table"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></div></el-collapse-item></el-collapse>
</FormSection> </FormSection>
</el-tab-pane> <FormSection class="density-form-section" :bordered="false" title="账单支付历史表" aria-label="支付历史">
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template> <div class="detail-table-scroll workbench-table is-page-surface">
<FormSection class="density-form-section" v-if="detail" title="支付历史" :bordered="false">
<div class="detail-table-scroll workbench-table">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史"> <DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template> <template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template> <template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
@@ -674,15 +679,27 @@ watch(detailRouteId, (statementId) => {
</DataTable> </DataTable>
</div> </div>
</FormSection> </FormSection>
</el-tab-pane> <FormSection v-if="detail.revisions.length" class="density-form-section" :bordered="false" title="调整记录" aria-label="调整记录">
</el-tabs> <div class="workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe>
<el-table-column prop="revisionNo" label="修订" width="72" />
<el-table-column label="动作" width="120"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前" align="right" width="130" />
<el-table-column prop="afterAmount" label="调整后" align="right" width="130" />
<el-table-column prop="reason" label="原因" min-width="280"><template #default="{ row }"><LineClampText :content="row.reason" :lines="1" /></template></el-table-column>
<el-table-column label="操作人" width="170"><template #default="{ row }"><LineClampText :content="operatorDisplayName(row.operatorName)" /></template></el-table-column>
<el-table-column label="发生时间" width="180"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
</el-table>
</div>
</FormSection>
</template>
</DetailPageLayout> </DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单"> <ListPageLayout v-else aria-label="客户对账单">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail"> <section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template> <template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template> <template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template> <template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
@@ -711,7 +728,7 @@ watch(detailRouteId, (statementId) => {
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement"> <AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement">
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert> <el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="对账单生成设置"> <section class="density-dialog-section workbench-evidence" aria-label="对账单生成设置">
<h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3> <h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3>
<el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody> <el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody>
<tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr>
@@ -748,15 +765,11 @@ watch(detailRouteId, (statementId) => {
.statement-create-table td:not([colspan]) { width: 50%; } .statement-create-table td:not([colspan]) { width: 50%; }
.statement-dialog-facts th { width: var(--admin-label-width); } .statement-dialog-facts th { width: var(--admin-label-width); }
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); } .statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; } .statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); } .statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); } .statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; } .statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; } .detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: var(--admin-space-5); }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); } :deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (min-width: 1920px) { @media (min-width: 1920px) {
@@ -765,13 +778,6 @@ watch(detailRouteId, (statementId) => {
padding: var(--admin-space-3) var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-5);
} }
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
.revision-collapse {
margin-top: var(--admin-space-3);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -6,11 +6,11 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi
const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8') const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
describe('Finance formal settlement desktop presentation', () => { describe('Finance formal settlement desktop presentation', () => {
it('keeps the title and actions in the toolbar and settlement totals before results', () => { it('keeps the title and actions in the toolbar and settlement totals above the list', () => {
expect(source).toContain('<template #toolbar-start>') expect(source).toContain('<template #toolbar-start>')
expect(source).toContain('<h1>{{ title }}</h1>') expect(source).toContain('<h1>{{ title }}</h1>')
expect(source).toContain('<template #before-results>') expect(source.indexOf('<section class="finance-metric-grid"')).toBeLessThan(source.indexOf('<ListPageLayout :aria-label="title">'))
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">') expect(source).toContain('<section class="finance-metric-grid" aria-label="结算汇总"')
expect(source).toContain('<template #toolbar-actions>') expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('<div class="settlement-list-actions">') expect(source).toContain('<div class="settlement-list-actions">')
expect(source).not.toContain('class="settlement-list-toolbar"') expect(source).not.toContain('class="settlement-list-toolbar"')
@@ -44,7 +44,7 @@ describe('Finance formal settlement desktop presentation', () => {
it('uses a continuous detail layout with locally scrollable wide tables', () => { it('uses a continuous detail layout with locally scrollable wide tables', () => {
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface"') expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface"')
expect(source).toContain('class="settlement-detail-content"') expect(source).toContain('class="settlement-detail-content"')
expect(source).toContain('class="detail-table-scroll workbench-table"') expect(source.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }') expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(source).not.toContain('class="settlement-detail-card"') expect(source).not.toContain('class="settlement-detail-card"')
}) })
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus' import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, CreditCard, Download, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue' import { ArrowLeft, CircleCheck, Clock3, CreditCard, Download, HandCoins, Plus, RotateCcw, Wallet, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -139,40 +139,40 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
? [ ? [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'settlementNo', label: '结算单号', minWidth: 180 }, { key: 'settlementNo', label: '结算单号', minWidth: 180 },
{ key: 'paymentStatus', label: '收款状态', width: 120, slotName: 'paymentStatus', afterColumn: 'settlementNo' },
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus', afterColumn: 'settlementNo' },
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' }, { key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' }, { key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 }, { key: 'currency', label: '币种', width: 80 },
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' }, { key: 'receivableAmount', label: '应收金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' }, { key: 'receivedAmount', label: '已收', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' }, { key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' }, { key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' }, { key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' }, { key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' }, { key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
{ key: 'paymentStatus', label: '收款状态', width: 120, slotName: 'paymentStatus' },
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' }, { key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' }, { key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
{ key: 'lastPaymentAt', label: '最后收款时间', width: 170, slotName: 'lastPaymentAt' }, { key: 'lastPaymentAt', label: '最后收款时间', width: 170, slotName: 'lastPaymentAt' },
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
] ]
: [ : [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'settlementNo', label: '结算单号', minWidth: 180 }, { key: 'settlementNo', label: '结算单号', minWidth: 180 },
{ key: 'paymentStatus', label: '付款状态', width: 120, slotName: 'paymentStatus', afterColumn: 'settlementNo' },
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus', afterColumn: 'settlementNo' },
{ key: 'supplierName', label: '供应商', minWidth: 180, slotName: 'supplier' }, { key: 'supplierName', label: '供应商', minWidth: 180, slotName: 'supplier' },
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' }, { key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' }, { key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 }, { key: 'currency', label: '币种', width: 80 },
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' }, { key: 'payableAmount', label: '应付金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' }, { key: 'paidAmount', label: '已付', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' }, { key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' }, { key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' }, { key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' }, { key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
{ key: 'paymentStatus', label: '付款状态', width: 120, slotName: 'paymentStatus' },
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' }, { key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' }, { key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
{ key: 'lastPaymentAt', label: '最后付款时间', width: 170, slotName: 'lastPaymentAt' }, { key: 'lastPaymentAt', label: '最后付款时间', width: 170, slotName: 'lastPaymentAt' },
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
]) ])
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
@@ -520,7 +520,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" spacing="normal"> <AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" :spacing="detailMode ? 'normal' : 'compact'">
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" /> <OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" /> <OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" /> <OperationErrorToast :details="receiptError" />
@@ -534,17 +534,25 @@ onMounted(() => {
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button> <el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div></div></template> </div></div></template>
<template v-if="detail" #summary> <template v-if="detail" #summary>
<dl class="density-detail-overview settlement-detail-summary" aria-label="结算金额汇总"> <section class="finance-metric-grid" aria-label="结算金额汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</dt><dd>{{ money(detail.amount) }}</dd></div> <article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div> <div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</h2></div>
<div class="summary-item"><dt>未结余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div> <div class="workbench-metric-value"><strong>{{ money(detail.amount) }}</strong><span>CNY</span></div>
<div class="summary-item"><dt>到期日</dt><dd class="is-date">{{ displayDate(detail.dueDate) }}</dd></div> </article>
</dl> <article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.paidAmount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">未结余额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.outstandingAmount) }}</strong><span>CNY</span></div>
</article>
</section>
</template> </template>
<LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated /> <el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
<section v-else-if="detail" class="settlement-detail-content"> <section v-else-if="detail" class="settlement-detail-content">
<FormSection class="density-form-section" title="基本信息"> <FormSection class="density-form-section" title="基本信息" :bordered="false">
<el-descriptions border :column="4" class="settlement-detail-info density-descriptions"> <el-descriptions border :column="4" class="settlement-detail-info density-descriptions">
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item> <el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item> <el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
@@ -562,8 +570,8 @@ onMounted(() => {
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item> <el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</FormSection> </FormSection>
<FormSection class="density-form-section" title="费用明细"> <FormSection class="density-form-section" title="费用明细" :bordered="false">
<div class="detail-table-scroll workbench-table"> <div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细"> <el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" /> <el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column> <el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
@@ -575,8 +583,8 @@ onMounted(() => {
</el-table> </el-table>
</div> </div>
</FormSection> </FormSection>
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'"> <FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'" :bordered="false">
<div class="detail-table-scroll workbench-table"> <div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'"> <el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" /> <el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column> <el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
@@ -590,7 +598,7 @@ onMounted(() => {
</div> </div>
</FormSection> </FormSection>
<FormSection class="density-form-section" title="修订记录" :bordered="false"> <FormSection class="density-form-section" title="修订记录" :bordered="false">
<div class="detail-table-scroll workbench-table"> <div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录"> <el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
<el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column> <el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column> <el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
@@ -603,7 +611,22 @@ onMounted(() => {
</FormSection> </FormSection>
</section> </section>
</DetailPageLayout> </DetailPageLayout>
<ListPageLayout v-else :aria-label="title"> <template v-else>
<section class="finance-metric-grid" aria-label="结算汇总" v-if="!customerError && !error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.amount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收' : '已付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.paid) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '待收' : '待付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.outstanding) }}</strong><span>CNY</span></div>
</article>
</section>
<ListPageLayout :aria-label="title">
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单"> <section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
@@ -614,13 +637,7 @@ onMounted(() => {
<template #notices><template v-if="customerError || error"> <template #notices><template v-if="customerError || error">
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState> <LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
</template></template> </template></template>
<template #before-results>
<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div>
</dl>
</template>
<template #toolbar-actions> <template #toolbar-actions>
@@ -654,6 +671,7 @@ onMounted(() => {
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template> <template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate"> <AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3> <section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
@@ -700,8 +718,8 @@ onMounted(() => {
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr> <tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3> <section class="density-dialog-section workbench-evidence" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody> <el-form class="settlement-operation-form density-form density-form--outlined" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
<tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr> <tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
@@ -717,11 +735,10 @@ onMounted(() => {
<style scoped> <style scoped>
.finance-settlement-page { min-width: 0; } .finance-settlement-page { min-width: 0; }
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; } .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
.settlement-summary { gap: var(--admin-space-2) 0; }
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); } .settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); }
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; } .settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; } .settlement-detail-page { display: flex; width: 100%; margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
.settlement-detail-actions { display: flex; align-items: center; gap: var(--admin-space-4); min-width: 0; flex-wrap: wrap; } .settlement-detail-actions { display: flex; align-items: center; gap: var(--admin-space-4); min-width: 0; flex-wrap: wrap; }
.settlement-detail-state { margin: var(--admin-space-5) var(--admin-space-6); } .settlement-detail-state { margin: var(--admin-space-5) var(--admin-space-6); }
.settlement-detail-content { min-width: 0; padding: 0 var(--admin-space-6) var(--admin-space-5); overflow: hidden; } .settlement-detail-content { min-width: 0; padding: 0 var(--admin-space-6) var(--admin-space-5); overflow: hidden; }
@@ -754,21 +771,11 @@ onMounted(() => {
padding: 0 var(--admin-space-5) var(--admin-space-4); padding: 0 var(--admin-space-5) var(--admin-space-4);
} }
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.settlement-detail-summary { grid-template-columns: 1fr; }
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
.settlement-detail-content { padding: 0 16px 14px; } .settlement-detail-content { padding: 0 16px 14px; }
.settlement-detail-state { margin: 14px; } .settlement-detail-state { margin: 14px; }
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
.settlement-list-actions { width: 100%; justify-content: flex-start; } .settlement-list-actions { width: 100%; justify-content: flex-start; }
.summary-item strong { font-size: 21px; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; } .settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.settlement-detail-actions { width: 100%; } .settlement-detail-actions { width: 100%; }
.settlement-detail-actions :deep(.el-button) { min-height: 40px; } .settlement-detail-actions :deep(.el-button) { min-height: 40px; }
@@ -39,21 +39,21 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('class="settlement-detail-info density-descriptions"') expect(settlement).toContain('class="settlement-detail-info density-descriptions"')
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"') for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"')
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"') expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
expect(settlement.match(/class="detail-table-scroll workbench-table"/g)).toHaveLength(3) expect(settlement.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }') expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(settlement).not.toContain('class="detail-section surface-panel"') expect(settlement).not.toContain('class="detail-section surface-panel"')
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2) expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2) expect(settlement.match(/class="settlement-operation-form density-form(?: density-form--outlined)?"/g)).toHaveLength(2)
expect(settlement).toContain('density-table density-table--editable settlement-operation-table') expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }') expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
}) })
it('keeps settlement totals and list actions in the table toolbar', () => { it('keeps settlement totals and list actions in the table toolbar', () => {
expect(settlement).toContain('<template #toolbar-start>') expect(settlement).toContain('<template #toolbar-start>')
expect(settlement).toContain('class="density-detail-overview settlement-summary" aria-label="结算汇总"') expect(settlement).toContain('class="finance-metric-grid" aria-label="结算汇总"')
expect(settlement).toContain('<template #toolbar-actions>') expect(settlement).toContain('<template #toolbar-actions>')
expect(settlement).toContain('class="settlement-list-actions"') expect(settlement).toContain('class="settlement-list-actions"')
expect(financeDensity).toContain('.settlement-summary.density-detail-overview { padding: 0; border: 0; }') expect(financeDensity).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
expect(settlement).not.toContain('class="settlement-list-toolbar"') expect(settlement).not.toContain('class="settlement-list-toolbar"')
}) })
@@ -148,15 +148,13 @@ describe('finance presentation governance', () => {
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。') expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
}) })
it('uses a shared compact financial summary row for customer accounts', () => { it('uses shared compact financial cards for customer accounts', () => {
expect(accounts).toContain('class="density-detail-overview account-metrics"') expect(accounts).toContain('class="account-metrics"')
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }') expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
expect(density).toContain('.density-detail-overview') expect(density).toContain('.density-detail-overview')
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)') expect(accounts).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
expect(accounts).toContain('padding: 0;') expect(financeDensity).toContain('padding-block: 0;')
const summaryStyles = density.match(/\.density-detail-overview \{([^}]*)\}/)?.[1] expect(accounts.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
expect(summaryStyles).toContain('display: flex;')
expect(summaryStyles).toContain('flex-wrap: wrap;')
const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1] const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1]
expect(accounts).toContain('class="ledger-query workbench-query"') expect(accounts).toContain('class="ledger-query workbench-query"')
expect(queryStyles).toContain('border-color: var(--workbench-border);') expect(queryStyles).toContain('border-color: var(--workbench-border);')
@@ -168,9 +166,11 @@ describe('finance presentation governance', () => {
it('keeps fee-source details continuous without nested metric and section cards', () => { it('keeps fee-source details continuous without nested metric and section cards', () => {
expect(arap).toContain('class="density-detail-overview arap-metrics"') expect(arap).toContain('class="density-detail-overview arap-metrics"')
expect(arap).toContain('class="arap-information density-form-section"') expect(arap).toContain('class="arap-information density-form-section"')
expect(arap).toContain('class="arap-settlement density-form-section"') expect(arap).toContain('title="单据信息与审核结算"')
expect(arap).toContain('padding-left: var(--admin-space-6);') expect(arap).toContain('label="审核原因" :span="3"')
expect(arap).toContain('border-left: 1px solid') expect(arap).not.toContain('arap-detail-grid')
expect(arap).not.toContain('arap-settlement')
expect(arap).not.toContain('border-left: 1px solid')
expect(arap).not.toContain('.arap-metric.is-amount { border-color:') expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"') for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"')
}) })
@@ -9,6 +9,9 @@ describe('receivable statistics page', () => {
for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label) for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'") expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
expect(page).toContain('window.print()') expect(page).toContain('window.print()')
expect(page).toContain('aria-label="统计月份"')
expect(page).toContain('aria-label="应收单位"')
for (const removed of ['<span>统计月份</span>', '<span>应收单位</span>', '#before-results', '应收款明细', '仅统计CNY正式应收结算单']) expect(page).not.toContain(removed)
expect(page).toContain('displayCustomerName(row)') expect(page).toContain('displayCustomerName(row)')
expect(page).toContain('/^客户\\s*\\d+$/.test(snapshot)') expect(page).toContain('/^客户\\s*\\d+$/.test(snapshot)')
expect(page).toContain("return masterName || '—'") expect(page).toContain("return masterName || '—'")
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import '../financeDensity.css' import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { Download, Printer, RotateCcw, Search } from '@lucide/vue' import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -39,9 +39,9 @@ const customerNameById = computed(() => new Map(customers.value.map((item) => [S
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' }, { key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
{ key: 'currency', label: '币种', width: 100 }, { key: 'currency', label: '币种', width: 100 },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' }, { key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' }, { key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' }, { key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
] ]
async function load() { async function load() {
@@ -90,22 +90,31 @@ onMounted(() => { void loadCustomers(); void load() })
</script> </script>
<template> <template>
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal"> <AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" /> <el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else> <template v-else>
<section class="finance-metric-grid" aria-label="CNY应收汇总" v-if="!error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">应收总额</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceivable) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已收合计</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceived) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">余额未收</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalOutstanding) : '—' }}</strong><span>CNY</span></div>
</article>
</section>
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()"> <div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" 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><section class="query-toolbar statistics-query" aria-label="应收款统计筛选"> <template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div> <div class="query-field"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div> <div class="query-field"><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div> <div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
</section></template> </section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #before-results><dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
<div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div>
<div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div>
<div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div>
</dl><div class="statistics-report-heading"><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></div></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template> <template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template> <template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
@@ -117,20 +126,17 @@ onMounted(() => { void loadCustomers(); void load() })
<style scoped> <style scoped>
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); } .statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
.query-field { display: grid; min-width: 220px; gap: var(--admin-space-2); color: var(--workbench-muted); font-size: var(--admin-font-body); } .query-field { min-width: 220px; }
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; } .query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); } .query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
.report-actions { margin-left: auto; } .report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; } .query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; } .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; } .statistics-table { overflow: hidden; }
.statistics-report-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statistics-table h2 { margin: 0; font-size: var(--admin-font-section); }
.statistics-report-heading span { color: var(--workbench-muted); font-size: var(--admin-font-body); }
@media print { @media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; } .statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; } .receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; } .workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; } .finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
} }
</style> </style>
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listSuppliers, type Supplier } from '@/modules/suppliers' import { listSuppliers, type Supplier } from '@/modules/suppliers'
import * as api from '../api/finance' import * as api from '../api/finance'
@@ -110,6 +111,7 @@ function errorDetails(wrapper: VueWrapper) {
} }
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
beforeEach(() => { beforeEach(() => {
localStorage.clear()
vi.resetAllMocks() vi.resetAllMocks()
can.mockReturnValue(true) can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
@@ -129,6 +131,19 @@ beforeEach(() => {
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('formal settlement native dialogs', () => { describe('formal settlement native dialogs', () => {
it.each(['receivable', 'payable'] as const)('keeps %s statuses after the settlement number with saved old column order', async (kind) => {
localStorage.setItem(preferenceStorageKey('table', 'anonymous', `finance-settlement-${kind}-table`), JSON.stringify({
version: 1,
order: ['actions', 'settlementNo', 'customerId', 'feeItemSummary', 'currency', 'paymentStatus', 'status'],
hidden: ['currency'],
widths: { settlementNo: 240 },
}))
const wrapper = await mountPage(kind)
const headers = wrapper.findAll('.el-table__header-wrapper th .cell').map(header => header.text())
expect(headers.slice(0, 4)).toEqual(['操作', '结算单号', kind === 'receivable' ? '收款状态' : '付款状态', '结算状态'])
expect(headers).not.toContain('币种')
})
it('creates receivables from issued statements with original long IDs and CNY', async () => { it('creates receivables from issued statements with original long IDs and CNY', async () => {
const { dialog } = await openCreate('receivable') const { dialog } = await openCreate('receivable')
expect(dialog.props('width')).toContain('600px') expect(dialog.props('width')).toContain('600px')
@@ -19,5 +19,6 @@ describe('Finance settlement route 1920px layout', () => {
const shared = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8') const shared = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
expect(shared).toContain(':column="4" class="settlement-detail-info density-descriptions"') expect(shared).toContain(':column="4" class="settlement-detail-info density-descriptions"')
expect(shared).toContain('finance-density-detail') expect(shared).toContain('finance-density-detail')
expect(shared).not.toContain('width: min(100%, 1920px)')
}) })
}) })
+5 -5
View File
@@ -156,9 +156,9 @@ const formRules = computed<FormRules>(() => ({
const userColumns: DataTableColumn[] = [ const userColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'username', prop: 'username', label: '用户名', minWidth: 145 }, { key: 'username', prop: 'username', label: '用户名', minWidth: 145 },
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'username' },
{ key: 'displayName', prop: 'displayName', label: '姓名', minWidth: 130 }, { key: 'displayName', prop: 'displayName', label: '姓名', minWidth: 130 },
{ key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' }, { key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' }, { key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
] ]
@@ -434,8 +434,8 @@ onMounted(loadInitialData)
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser"> <AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving"> <el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户基本资料"> <section class="density-dialog-section workbench-evidence" aria-label="用户基本资料">
<h3 class="density-dialog-section__title">用户资料</h3> <h3 class="density-dialog-section__title">用户资料</h3>
<table class="density-table density-table--editable user-editor-table"><tbody> <table class="density-table density-table--editable user-editor-table"><tbody>
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr> <tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
@@ -444,7 +444,7 @@ onMounted(loadInitialData)
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr> <tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户作业仓库"> <section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" aria-label="用户作业仓库">
<h3 class="density-dialog-section__title">作业仓库</h3> <h3 class="density-dialog-section__title">作业仓库</h3>
<table class="density-table density-table--editable"><tbody> <table class="density-table density-table--editable"><tbody>
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required> <tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
@@ -459,7 +459,7 @@ onMounted(loadInitialData)
</el-form-item></td></tr> </el-form-item></td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户登录密码"> <section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" aria-label="用户登录密码">
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3> <h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
<table class="density-table density-table--editable user-editor-table"><tbody> <table class="density-table density-table--editable user-editor-table"><tbody>
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr> <tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
@@ -85,14 +85,15 @@ describe('PermissionsView IAM mode', () => {
it('keeps the shared status badge prop defined during table slot initialization', () => { it('keeps the shared status badge prop defined during table slot initialization', () => {
expect(permissionsSource).toContain("(row as Permission).status || '—'") expect(permissionsSource).toContain("(row as Permission).status || '—'")
}) })
it('uses outlined business action buttons while keeping tree view helpers as links', () => { it('uses outlined business action buttons without global tree view helpers', () => {
expect(permissionsSource).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'") expect(permissionsSource).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400") expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400")
expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300") expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'") expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true") expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true")
expect(permissionsSource).toContain('<RowActions text-actions v-if="permissionActions(row as Permission).some') expect(permissionsSource).toContain('<RowActions text-actions v-if="permissionActions(row as Permission).some')
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>') expect(permissionsSource).not.toContain('展开全部')
expect(permissionsSource).not.toContain('收起操作权限')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link') expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link') expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
expect(permissionsSource).not.toContain('canChangeStatusRow(row as Permission)" link') expect(permissionsSource).not.toContain('canChangeStatusRow(row as Permission)" link')
@@ -103,7 +104,10 @@ describe('PermissionsView IAM mode', () => {
}) })
it('keeps technical permission fields out of the default list and labels the table', () => { it('keeps technical permission fields out of the default list and labels the table', () => {
expect(permissionsSource).toContain("{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' }") expect(permissionsSource).not.toContain("key: 'code', label: '权限编码'")
expect(permissionsSource).not.toContain('<FolderTree')
expect(permissionsSource).not.toContain('<ShieldCheck')
expect(permissionsSource).not.toContain('<Braces')
expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }") expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }")
expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }") expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }")
expect(permissionsSource).toContain('aria-label="平台权限资源列表"') expect(permissionsSource).toContain('aria-label="平台权限资源列表"')
+14 -29
View File
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
import '../iamDensity.css' import '../iamDensity.css'
import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { FolderTree, Plus, ShieldCheck } from '@lucide/vue' import { Plus } from '@lucide/vue'
import StatusBadge from '@/shared/components/StatusBadge.vue' import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -90,13 +90,14 @@ const parentOptions = computed(() => permissions.value.filter((item) => (
['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id) ['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
))) )))
const permissionColumns: DataTableColumn[] = [ const permissionColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions', className: 'permission-actions-column' },
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' }, { key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' }, { key: 'status', label: '状态', width: 88, slotName: 'status', afterColumn: 'name' },
{ key: 'menuCount', label: '菜单数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).menuCount },
{ key: 'actionCount', label: '操作数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).actionCount },
{ key: 'type', prop: 'type', label: '类型', width: 80 }, { key: 'type', prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }, { key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }, { key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
] ]
async function loadPermissions() { async function loadPermissions() {
@@ -128,25 +129,16 @@ function collectDescendantIds(nodes: PermissionTreeNode[], ids: string[] = []) {
return ids return ids
} }
function permissionSummary(row: PermissionTreeNode) { function permissionCounts(row: PermissionTreeNode) {
const descendants = collectDescendantIds(row.children ?? []) const descendants = collectDescendantIds(row.children ?? [])
.map((id) => permissions.value.find((item) => item.id === id)) .map((id) => permissions.value.find((item) => item.id === id))
.filter((item): item is Permission => Boolean(item)) .filter((item): item is Permission => Boolean(item))
const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
if (menuCount === 0 && actionCount === 0) return '无下级权限' return { menuCount, actionCount }
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
} }
function expandAllRows() {
expandedRowKeys.value = collectDescendantIds(treeRows.value)
tableRenderKey.value += 1
}
function collapseActionRows() {
expandedRowKeys.value = rootRowKeys.value
tableRenderKey.value += 1
}
function openCreate(parentId: string | null = null) { function openCreate(parentId: string | null = null) {
Object.assign(form, emptyForm(), { parentId }) Object.assign(form, emptyForm(), { parentId })
@@ -235,7 +227,6 @@ async function removePermission(row: Permission) {
rowActionId.value = '' rowActionId.value = ''
} }
} }
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
function canAddChild(row: Permission) { function canAddChild(row: Permission) {
return canCreate.value && ['菜单', '页面'].includes(row.type) return canCreate.value && ['菜单', '页面'].includes(row.type)
@@ -287,13 +278,11 @@ onMounted(loadPermissions)
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template></template> </template></template>
<template #before-results><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template> <template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }"><RowActions text-actions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template> <template #actions="{ row }"><RowActions text-actions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template> <template #name="{ row }"><strong>{{ (row as Permission).name }}</strong></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template> <template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
<template #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template> <template #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
@@ -317,23 +306,19 @@ onMounted(loadPermissions)
</template> </template>
<style scoped> <style scoped>
:deep(td.permission-actions-column > .cell) { display: flex; align-items: center; white-space: nowrap; }
:deep(td.permission-actions-column > .cell > .row-actions) { display: inline-flex; flex-wrap: nowrap; flex-shrink: 0; }
:deep(td.permission-actions-column > .cell > .el-table__indent),
:deep(td.permission-actions-column > .cell > .el-table__expand-icon),
:deep(td.permission-actions-column > .cell > .el-table__placeholder) { flex-shrink: 0; }
.permission-editor-table { table-layout: fixed; } .permission-editor-table { table-layout: fixed; }
.permission-editor-table td:not([colspan="2"]) { width: 50%; } .permission-editor-table td:not([colspan="2"]) { width: 50%; }
.permission-tree-tools { display: flex; align-items: center; gap: var(--admin-space-6); }
.permission-protection-hint { color: var(--workbench-muted); }
.tree-view-actions { display: flex; align-items: center; gap: var(--admin-space-1); white-space: nowrap; }
.tree-view-actions .el-button + .el-button { margin-left: 0; }
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.permission-name small { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.35; }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); } :deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (max-width: 720px) { @media (max-width: 720px) {
.permission-editor-table tr { display: grid; } .permission-editor-table tr { display: grid; }
.permission-editor-table td:not([colspan="2"]) { width: auto; } .permission-editor-table td:not([colspan="2"]) { width: auto; }
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
} }
@media (max-width: 420px) {
.tree-view-actions { width: 100%; justify-content: flex-start; }
}
</style> </style>
@@ -68,7 +68,7 @@ beforeEach(() => {
describe('roles and permission dialogs', () => { describe('roles and permission dialogs', () => {
it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => { it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => {
const wrapper = await roleEditor(); const dialog = wrapper.findComponent(AppDialog); const tree = wrapper.findComponent(ElTree) const wrapper = await roleEditor(); const dialog = wrapper.findComponent(AppDialog); const tree = wrapper.findComponent(ElTree)
expect(dialog.props()).toMatchObject({ title: '配置角色 · TEST_OPERATOR', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true }) expect(dialog.props()).toMatchObject({ title: '配置角色 · 测试角色', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
expect(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull() expect(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull()
expect(tree.vm.getCheckedKeys()).toContain(childId) expect(tree.vm.getCheckedKeys()).toContain(childId)
expect(tree.vm.getNode(hiddenId)).toBeTruthy() expect(tree.vm.getNode(hiddenId)).toBeTruthy()
+2 -2
View File
@@ -117,11 +117,11 @@ describe('RolesView', () => {
expect(rolesSource).toContain('aria-label="平台角色列表"') expect(rolesSource).toContain('aria-label="平台角色列表"')
}) })
it('uses the prototype-sized centered role editor with responsive split panes', () => { it('uses the prototype-sized centered role editor with stacked information and permissions', () => {
expect(rolesSource).toContain('<AppDialog v-model="editorOpen"') expect(rolesSource).toContain('<AppDialog v-model="editorOpen"')
expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"') expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"')
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"') expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"')
expect(rolesSource).toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)') expect(rolesSource).not.toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }') expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"') expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"')
expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))') expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))')
+13 -11
View File
@@ -63,7 +63,7 @@ const formRules: FormRules = {
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }], name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }], code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
} }
const editorLabel = computed(() => form.id ? `配置角色 · ${form.code.trim() || form.name.trim() || '—'}` : '新增角色') const editorLabel = computed(() => form.id ? `配置角色 · ${form.name.trim() || '—'}` : '新增角色')
const canEdit = computed(() => access.can('platform:role:edit')) const canEdit = computed(() => access.can('platform:role:edit'))
const canGrant = computed(() => access.can('platform:role:grant')) const canGrant = computed(() => access.can('platform:role:grant'))
const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create') const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
@@ -85,11 +85,11 @@ const roleColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'role', label: '角色名称', minWidth: 180, slotName: 'role' }, { key: 'role', label: '角色名称', minWidth: 180, slotName: 'role' },
{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true }, { key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true },
{ key: 'status', label: '状态', width: 88, slotName: 'status', afterColumn: 'roleCode' },
{ key: 'description', prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true }, { key: 'description', prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true },
{ key: 'permissionSummary', label: '授权范围', minWidth: 170, slotName: 'permissionSummary' }, { key: 'permissionSummary', label: '授权范围', minWidth: 170, slotName: 'permissionSummary' },
{ key: 'userCount', prop: 'userCount', label: '用户数', width: 86, align: 'right' }, { key: 'userCount', prop: 'userCount', label: '用户数', width: 86, align: 'right' },
{ key: 'roleType', label: '类型', width: 95, slotName: 'roleType' }, { key: 'roleType', label: '类型', width: 95, slotName: 'roleType' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
] ]
const availablePermissions = computed(() => serverPermissions.value) const availablePermissions = computed(() => serverPermissions.value)
const permissionTree = computed(() => buildNavigationPermissionTree(availablePermissions.value, adminNavigation)) const permissionTree = computed(() => buildNavigationPermissionTree(availablePermissions.value, adminNavigation))
@@ -263,13 +263,13 @@ onMounted(loadRoles)
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole"> <AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-dialog-sections role-editor-layout" :disabled="saving"> <el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-form--outlined density-dialog-sections role-editor-layout" :disabled="saving">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="角色基本资料"> <section class="density-dialog-section workbench-evidence" aria-label="角色基本资料">
<h3 class="density-dialog-section__title">基本信息</h3> <h3 class="density-dialog-section__title">基本信息</h3>
<table class="density-table density-table--editable"><tbody> <table class="density-table density-table--editable role-basic-table"><tbody>
<tr><td><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td></tr> <tr><td :colspan="form.id ? 2 : 1"><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td>
<tr><td><el-form-item label="角色编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr> <td v-if="!form.id"><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
<tr><td><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限"> <section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限">
@@ -282,10 +282,12 @@ onMounted(loadRoles)
</template> </template>
<style scoped> <style scoped>
.role-editor-layout { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); align-items: start; } .role-editor-layout { grid-template-columns: minmax(0, 1fr); }
.role-editor-layout:not(:has(.role-editor-permissions)) { grid-template-columns: minmax(0, 1fr); } .role-basic-table { table-layout: fixed; }
.role-basic-table td:not([colspan]) { width: 50%; }
.role-editor-permissions .permission-tree { max-height: min(320px, 35vh); overflow: auto; overscroll-behavior: contain; }
.permission-node span { min-width: 0; } .permission-node span { min-width: 0; }
.permission-node.is-navigation-group span { font-weight: 650; } .permission-node.is-navigation-group span { font-weight: 650; }
:global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); } :global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
@media (max-width: 720px) { .role-editor-layout { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 720px) { .role-basic-table td { display: block; width: 100%; box-sizing: border-box; } }
</style> </style>
@@ -102,8 +102,8 @@ const columns = computed<DataTableColumn[]>(() => [
selectable: (row: InboundOrderSummary) => row.measureReviewStatus === 'PENDING' && !batchReviewLoading.value, selectable: (row: InboundOrderSummary) => row.measureReviewStatus === 'PENDING' && !batchReviewLoading.value,
} satisfies DataTableColumn] : []), } satisfies DataTableColumn] : []),
{ key: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'inboundCode' }, { key: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'inboundCode' },
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' }, { key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus', afterColumn: 'inboundCode' },
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 120, slotName: 'measureReviewStatus' }, { key: 'measureReviewStatus', label: '测量审核状态', minWidth: 120, slotName: 'measureReviewStatus', afterColumn: 'inboundCode' },
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 140, slotName: 'expectedArrivalAt' }, { key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 140, slotName: 'expectedArrivalAt' },
{ key: 'customerName', label: '客户', minWidth: 140, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 140, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 120, slotName: 'warehouseName' }, { key: 'warehouseName', label: '仓库', minWidth: 120, slotName: 'warehouseName' },
+10
View File
@@ -7,6 +7,7 @@ import {
createFeeActionIdempotencyKey, createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey, createFeeAdjustmentIdempotencyKey,
getLatestInboundFee, getLatestInboundFee,
previewInboundFee,
sendInboundFee, sendInboundFee,
} from './api' } from './api'
@@ -32,6 +33,15 @@ describe('inbound fee API', () => {
expect(http.get).not.toHaveBeenCalled() expect(http.get).not.toHaveBeenCalled()
}) })
it('previews selected outer billing values without a write idempotency header', async () => {
const preview = { actualWeightKg: 570.5, actualVolumeCbm: 3.894, chargeableQuantity: 650.298, priceMatched: true }
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: preview })
const payload = { selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, selectedUnitPrice: 13.6, weightSource: 'ACTUAL', volumeSource: 'OUTER' }
await expect(previewInboundFee('10001', '88', payload)).resolves.toEqual(preview)
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/preview', payload, { params: { tenantId: '10001' } })
})
it('loads the latest immutable fee snapshot with string IDs', async () => { it('loads the latest immutable fee snapshot with string IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot }) vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
+9 -2
View File
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types' import type { AdjustPublishedInboundFeeRequest, InboundFeePreview, InboundFeePreviewRequest, InboundFeeSelectionRequest, InboundFeeSnapshot } from './types'
function feeBasePath(inboundId: string) { function feeBasePath(inboundId: string) {
const identifier = String(inboundId ?? '').trim() const identifier = String(inboundId ?? '').trim()
@@ -15,7 +15,14 @@ export async function getLatestInboundFee(tenantId: string, inboundId: string) {
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function calculateInboundFee(tenantId: string, inboundId: string, payload: { selectedWeightKg: number; selectedVolumeCbm: number; weightSource: string; volumeSource: string }, idempotencyKey: string) { export async function previewInboundFee(tenantId: string, inboundId: string, payload: InboundFeePreviewRequest) {
const response = await http.post<unknown, ApiEnvelope<InboundFeePreview>>(
`${feeBasePath(inboundId)}/preview`, payload, { params: { tenantId } },
)
return unwrapApiResponse(response)
}
export async function calculateInboundFee(tenantId: string, inboundId: string, payload: InboundFeeSelectionRequest, idempotencyKey: string) {
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>( const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
`${feeBasePath(inboundId)}/calculate`, payload, `${feeBasePath(inboundId)}/calculate`, payload,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } }, { params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
+26
View File
@@ -1,6 +1,32 @@
export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string
export type InboundChargeMode = 'KG' | 'CBM' | string export type InboundChargeMode = 'KG' | 'CBM' | string
export interface InboundFeeSelectionRequest {
selectedWeightKg: number
selectedVolumeCbm: number
weightSource: string
volumeSource: string
}
export interface InboundFeePreview {
actualWeightKg: number
actualVolumeCbm: number
chargeableQuantity: number
chargeUnit: string
conversionFactor: number
roundingScale: number
roundingMode: string
unitPrice: number | null
additionalAmount: number
totalAmount: number | null
currency: string
priceMatched: boolean
}
export interface InboundFeePreviewRequest extends InboundFeeSelectionRequest {
selectedUnitPrice: number | null
}
export interface InboundFeeSnapshot { export interface InboundFeeSnapshot {
feeId: string feeId: string
feeNo: string feeNo: string
+2 -1
View File
@@ -14,9 +14,10 @@ export {
createFeeAdjustmentIdempotencyKey, createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey, createFeeActionIdempotencyKey,
getLatestInboundFee, getLatestInboundFee,
previewInboundFee,
sendInboundFee, sendInboundFee,
} from './fee/api' } from './fee/api'
export type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './fee/types' export type { AdjustPublishedInboundFeeRequest, InboundFeePreview, InboundFeeSnapshot } from './fee/types'
export { downloadWeightImage } from './weight/api/weightOcr' export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types' export type { InboundMeasurementDetail } from './measurement/types'
export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types' export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types'
@@ -13,7 +13,7 @@ import {
submitInboundMeasurement, submitInboundMeasurement,
updateConfirmedPackageCount, updateConfirmedPackageCount,
} from './api' } from './api'
import type { InboundMeasurementDetail } from './types' import type { InboundMeasurementDetail, SaveInboundMeasurementItemRequest } from './types'
import InboundMeasurementPanel from './InboundMeasurementPanel.vue' import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
vi.mock('./api', () => ({ vi.mock('./api', () => ({
@@ -152,6 +152,10 @@ async function mountPanel(permissions = [
return wrapper return wrapper
} }
function measurementDraft(wrapper: Awaited<ReturnType<typeof mountPanel>>) {
return (wrapper.vm.$ as unknown as { setupState: { form: { items: SaveInboundMeasurementItemRequest[] } } }).setupState.form
}
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals()) afterAll(() => vi.unstubAllGlobals())
@@ -317,6 +321,50 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('.measurement-declaration').element.parentElement).toBe(wrapper.find('.measurement-summary-section').element.parentElement) expect(wrapper.find('.measurement-declaration').element.parentElement).toBe(wrapper.find('.measurement-summary-section').element.parentElement)
}) })
it('shows raw measured volume rather than customer weighted billing volume in the rows and comparison', async () => {
const value = measurement({ originalVolumeCbm: 3.3, originalPackageCount: 22, actualPackageCount: 22, actualVolumeCbm: 7.205, volumeWeightKg: 1203.235 })
Object.assign(value.items[0]!, {
lengthCm: 62, widthCm: 52, heightCm: 52, packageCount: 22,
coefficientMode: 'MULTIPLY', chargeRuleId: '6001',
calculatedOuterLengthCm: 77.5, calculatedOuterWidthCm: 65, calculatedOuterHeightCm: 65,
unitVolumeCbm: 0.3275, totalVolumeCbm: 7.205,
})
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(value)
const wrapper = await mountPanel()
const comparisonVolume = () => wrapper.get('.measurement-comparison tbody tr:last-child').findAll('td').map(cell => cell.text())
expect(comparisonVolume()).toEqual(['3.300', '3.688', '差异 +0.388 m³'])
expect(wrapper.get('.measurement-table').text()).toContain('3.688')
expect(wrapper.get('.measurement-table').text()).not.toContain('7.205')
expect(wrapper.get('.measurement-extra-facts').text()).toContain('计费材积重')
await wrapper.get('input[aria-label="仓库修改外箱长"]').setValue('80')
expect(comparisonVolume()[1]).toBe('3.688')
await wrapper.get('.measurement-count-editor input').setValue('11')
expect(comparisonVolume()).toEqual(['3.300', '1.844', '差异 -1.456 m³'])
expect(wrapper.get('.measurement-table').text()).toContain('1.844')
expect(value.actualVolumeCbm).toBe(7.205)
expect(saveInboundMeasurementDraft).not.toHaveBeenCalled()
expect(updateConfirmedPackageCount).not.toHaveBeenCalled()
})
it('sums measured volumes across rows and does not substitute billing totals for incomplete dimensions', async () => {
const value = measurement({ actualVolumeCbm: 9.999 })
value.items.push({ ...value.items[0]!, id: '9102', itemNo: '002', boxCode: 'RK202608040001#10-2', lengthCm: 10, widthCm: 20, heightCm: 30, packageCount: 2, totalVolumeCbm: 8.888 })
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(value)
const wrapper = await mountPanel()
expect(wrapper.get('.measurement-comparison tbody tr:last-child').findAll('td')[1]!.text()).toBe('0.612')
value.items[1]!.lengthCm = 0
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(value)
await wrapper.setProps({ inboundId: '7002' })
await flushPromises()
const cells = wrapper.get('.measurement-comparison tbody tr:last-child').findAll('td')
expect(cells[1]!.text()).toBe('未测量')
expect(cells[2]!.text()).toBe('待测量')
expect(wrapper.get('.measurement-table').text()).not.toContain('8.888')
})
it('shows pending comparison rather than a negative count when no boxes have been measured', async () => { it('shows pending comparison rather than a negative count when no boxes have been measured', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
items: [], actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null, items: [], actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
@@ -404,7 +452,7 @@ describe('InboundMeasurementPanel', () => {
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2] const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2]
expect(payload?.items[0]).toMatchObject({ expect(payload?.items[0]).toMatchObject({
itemNo: '001', outerLengthCm: 55, outerWidthCm: null, outerHeightCm: null, itemNo: '001', outerLengthCm: 55, outerWidthCm: null, outerHeightCm: null, coefficientMode: 'ACTUAL',
}) })
}) })
@@ -424,7 +472,7 @@ describe('InboundMeasurementPanel', () => {
it('retains server-calculated facts in the draft without the removed expandable row', async () => { it('retains server-calculated facts in the draft without the removed expandable row', async () => {
const wrapper = await mountPanel() const wrapper = await mountPanel()
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false) expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
expect(wrapper.vm.form.items[0]).toMatchObject({ expect(measurementDraft(wrapper).items[0]).toMatchObject({
boxSpecification: '50×40×30cm', coefficientMode: 'ACTUAL', boxSpecification: '50×40×30cm', coefficientMode: 'ACTUAL',
calculatedOuterLengthCm: 50, calculatedOuterWidthCm: 40, calculatedOuterHeightCm: 30, calculatedOuterLengthCm: 50, calculatedOuterWidthCm: 40, calculatedOuterHeightCm: 30,
calculationRuleVersion: 'DIMENSION_ACTUAL_V1', calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
@@ -621,7 +669,7 @@ describe('InboundMeasurementPanel', () => {
it('retains the formal deviceMeasuredAt response field in the measurement draft', async () => { it('retains the formal deviceMeasuredAt response field in the measurement draft', async () => {
const wrapper = await mountPanel() const wrapper = await mountPanel()
expect(wrapper.vm.form.items[0].measuredAt).toBe('2026-08-04T10:30:00+08:00') expect(measurementDraft(wrapper).items[0]!.measuredAt).toBe('2026-08-04T10:30:00+08:00')
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default) const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
expect(source).toContain('measuredAt: item.deviceMeasuredAt') expect(source).toContain('measuredAt: item.deviceMeasuredAt')
expect(source).not.toContain('measuredAt: item.measuredAt') expect(source).not.toContain('measuredAt: item.measuredAt')
@@ -120,11 +120,28 @@ 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. // 实测展示只使用原始尺寸与确认箱数,不套用计费规则或仓库外箱修正,也不回写计费快照。
function measuredVolumeCbm(item: Pick<EditableMeasurementItem, 'lengthCm' | 'widthCm' | 'heightCm' | 'packageCount'>) {
if (![item.lengthCm, item.widthCm, item.heightCm].every(value => Number.isFinite(value) && value > 0)
|| !Number.isInteger(item.packageCount) || item.packageCount < 1) return null
const volume = item.lengthCm * item.widthCm * item.heightCm * item.packageCount / 1_000_000
return Number.isFinite(volume) ? volume : null
}
const measuredTotalVolumeCbm = computed(() => {
if (!form.items.length) return null
let total = 0
for (const item of form.items) {
const volume = measuredVolumeCbm(item)
if (volume == null) return null
total += volume
}
return total
})
// 尚无扫描行时保持未测量,不把缺失的实测事实显示为零。
const comparisonMetrics = computed(() => [ const comparisonMetrics = computed(() => [
{ label: '箱数', declared: detail.value?.originalPackageCount, actual: form.items.length ? confirmedPackageCount.value : null, digits: 0, unit: '' }, { 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: '毛重(kg)', declared: detail.value?.originalGrossWeightKg, actual: detail.value?.actualGrossWeightKg, digits: 3, unit: ' kg' },
{ label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: detail.value?.actualVolumeCbm, digits: 3, unit: ' m³' }, { label: '总体积(m³)', declared: detail.value?.originalVolumeCbm, actual: measuredTotalVolumeCbm.value, digits: 3, unit: ' m³' },
]) ])
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null) const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
@@ -492,7 +509,7 @@ async function saveDraft() {
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning, intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm, outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.coefficientMode === 'MULTIPLY' ? item.chargeRuleId : null, chargeRuleId: item.coefficientMode === 'MULTIPLY' ? item.chargeRuleId : null,
coefficientMode: item.coefficientMode === 'ACTUAL' ? null : item.coefficientMode, remark: item.remark, coefficientMode: item.coefficientMode, remark: item.remark,
})), })),
}) })
version = saved.version version = saved.version
@@ -734,8 +751,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</table></div> </table></div>
<div class="measurement-extra-facts"> <div class="measurement-extra-facts">
<span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span> <span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
<span>材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span> <span>计费材积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span>
<el-tooltip content="净重、材积重为平台数据,不与客户申报毛重比较"><span tabindex="0">平台补充数据</span></el-tooltip> <el-tooltip content="实测体积按原始尺寸和确认箱数展示;计费材积重沿用服务端按客户计费规则计算的结果,不与客户申报毛重比较"><span tabindex="0">平台补充数据</span></el-tooltip>
</div> </div>
</section> </section>
@@ -827,7 +844,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="总体积(m³)" width="108" align="right"> <el-table-column label="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 3, 3) }}</template> <template #default="{ row }">{{ formatNumber(measuredVolumeCbm(row as EditableMeasurementItem), 3, 3) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="图片" width="82" align="center"> <el-table-column label="图片" width="82" align="center">
<template #default="{ row }"> <template #default="{ row }">
@@ -117,17 +117,17 @@ const queryFields = computed(() => showAdvancedFilters.value
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 228, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 228, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', label: '入仓码', minWidth: 166, slotName: 'inbound' }, { key: 'inboundCode', label: '入仓码', minWidth: 166, slotName: 'inbound' },
{ key: 'processStatus', label: '入库流程状态', minWidth: 132, slotName: 'processStatus' }, { key: 'processStatus', label: '入库流程状态', minWidth: 132, slotName: 'processStatus', afterColumn: 'inboundCode' },
{ key: 'orderStatus', label: '订单状态', minWidth: 120, slotName: 'orderStatus' }, { key: 'orderStatus', label: '订单状态', minWidth: 120, slotName: 'orderStatus', afterColumn: 'inboundCode' },
{ key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus' }, { key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus', afterColumn: 'inboundCode' },
{ key: 'feeStatus', label: '费用状态', minWidth: 112, slotName: 'feeStatus', afterColumn: 'inboundCode' },
{ key: 'feeReviewStatus', label: '费用审核状态', minWidth: 132, slotName: 'feeReviewStatus', afterColumn: 'inboundCode' },
{ key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus', defaultVisible: false, afterColumn: 'inboundCode' },
{ key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag', defaultVisible: false, afterColumn: 'inboundCode' },
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' }, { key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
{ key: 'feeStatus', label: '费用状态', minWidth: 112, slotName: 'feeStatus' },
{ key: 'feeReviewStatus', label: '费用审核状态', minWidth: 132, slotName: 'feeReviewStatus' },
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' }, { key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' }, { key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
{ key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus', defaultVisible: false },
{ key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag', defaultVisible: false },
{ key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason', defaultVisible: false }, { key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason', defaultVisible: false },
{ key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo', defaultVisible: false }, { key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo', defaultVisible: false },
{ key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo', defaultVisible: false }, { key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo', defaultVisible: false },
@@ -213,7 +213,7 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<tbody> <tbody>
<tr><th scope="row">箱数</th><td>{{ formatNumber(detail.originalPackageCount, 0) }}</td><td>{{ formatNumber(detail.actualPackageCount, 0) }}</td><td>{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</td></tr> <tr><th scope="row">箱数</th><td>{{ formatNumber(detail.originalPackageCount, 0) }}</td><td>{{ formatNumber(detail.actualPackageCount, 0) }}</td><td>{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</td></tr>
<tr><th scope="row">毛重(kg)</th><td>{{ formatNumber(detail.originalGrossWeightKg) }}</td><td>{{ formatNumber(detail.actualGrossWeightKg) }}</td><td>{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</td></tr> <tr><th scope="row">毛重(kg)</th><td>{{ formatNumber(detail.originalGrossWeightKg) }}</td><td>{{ formatNumber(detail.actualGrossWeightKg) }}</td><td>{{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</td></tr>
<tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 4) }}</td><td>{{ formatNumber(detail.actualVolumeCbm, 4) }}</td><td>{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</td></tr> <tr><th scope="row">体积(m³)</th><td>{{ formatNumber(detail.originalVolumeCbm, 3) }}</td><td>{{ formatNumber(detail.actualVolumeCbm, 3) }}</td><td>{{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 3, ' m³') }}</td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div> <div class="review-extra-facts"><span>货物净重 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span><span>体积重 <strong>{{ formatNumber(detail.volumeWeightKg) }}</strong> kg</span></div>
@@ -249,10 +249,10 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template> <template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="单箱体积(m³)" width="106" align="right"> <el-table-column label="单箱体积(m³)" width="106" align="right">
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 4) }}</template> <template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 3) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="总体积(m³)" width="96" align="right"> <el-table-column label="总体积(m³)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 4) }}</template> <template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 3) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="体积重(kg)" width="96" align="right"> <el-table-column label="体积重(kg)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template> <template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
@@ -96,6 +96,8 @@ const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id ===
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 270, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 270, slotName: 'actions', hideable: false, movable: false },
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus', afterColumn: 'feeItemName' },
{ key: 'sourceStatus', label: '来源状态', width: 100, slotName: 'sourceStatus', afterColumn: 'feeItemName' },
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' }, { key: 'direction', label: '方向', width: 90, slotName: 'direction' },
{ key: 'settlementMode', label: '结算方式', width: 120, slotName: 'settlementMode' }, { key: 'settlementMode', label: '结算方式', width: 120, slotName: 'settlementMode' },
{ key: 'paidSummary', label: '已付摘要', minWidth: 290, slotName: 'paidSummary' }, { key: 'paidSummary', label: '已付摘要', minWidth: 290, slotName: 'paidSummary' },
@@ -103,8 +105,6 @@ const columns: DataTableColumn[] = [
{ key: 'businessReference', label: '费用归属', minWidth: 220, slotName: 'businessReference' }, { key: 'businessReference', label: '费用归属', minWidth: 220, slotName: 'businessReference' },
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' }, { key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 }, { key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' },
{ key: 'sourceStatus', label: '来源状态', width: 100, slotName: 'sourceStatus' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
] ]
const allocationColumns: DataTableColumn[] = [ const allocationColumns: DataTableColumn[] = [
@@ -60,13 +60,13 @@ const fields = computed<QueryField[]>(() => [
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 88, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 88, slotName: 'actions', hideable: false, movable: false },
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 120 }, { key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 120 },
{ key: 'status', label: '状态', width: 108, slotName: 'status', afterColumn: 'preloadNo' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 120 }, { key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 120 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 140 }, { key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 140 },
// { key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' }, // { key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 80 }, { key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 80 },
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' }, { key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
{ key: 'containerType', prop: 'containerType', label: '箱型', width: 80 }, { key: 'containerType', prop: 'containerType', label: '箱型', width: 80 },
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' }, { key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
{ key: 'skuCount', prop: 'skuCount', label: 'SKU 数', width: 90, align: 'right' }, { key: 'skuCount', prop: 'skuCount', label: 'SKU 数', width: 90, align: 'right' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', width: 90, align: 'right', slotName: 'volume' }, { key: 'totalVolumeCbm', label: '总体积(m³)', width: 90, align: 'right', slotName: 'volume' },
@@ -75,10 +75,10 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
] : [ ] : [
{ key: 'actions', label: '操作', fixed: 'left', width: 150, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 150, slotName: 'actions', hideable: false, movable: false },
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 }, { key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
{ key: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'loadingListNo' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 }, { key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 }, { key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 }, { key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' }, { key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 125, align: 'right', slotName: 'grossWeight' }, { key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 125, align: 'right', slotName: 'grossWeight' },
{ key: 'totalNetWeightKg', label: '总净重(kg)', width: 125, align: 'right', slotName: 'netWeight' }, { key: 'totalNetWeightKg', label: '总净重(kg)', width: 125, align: 'right', slotName: 'netWeight' },
@@ -40,6 +40,9 @@ describe('PalletLabelPrintDialog', () => {
await flushPromises() await flushPromises()
const dialog = wrapper.getComponent(AppDialog) const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe(dense ? '散货托盘码 · HLCU1723867' : '散货托盘码') expect(dialog.props('title')).toBe(dense ? '散货托盘码 · HLCU1723867' : '散货托盘码')
expect(dialog.text()).not.toContain('编号规则')
expect(dialog.text()).not.toContain('一件代发托盘不在此清单')
expect(dialog.find('section[data-tone]').exists()).toBe(false)
wrapper.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'WAREHOUSE') wrapper.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'WAREHOUSE')
await flushPromises() await flushPromises()
wrapper.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1) wrapper.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1)
@@ -58,7 +61,7 @@ describe('PalletLabelPrintDialog', () => {
click.mockRestore() click.mockRestore()
}) })
it('shows the server-generated internal code rule and both warehouse codes', async () => { it('keeps server-generated label numbers and warehouse codes without the extra numbering explanation', async () => {
const wrapper = mount(PalletLabelPrintDialog, { const wrapper = mount(PalletLabelPrintDialog, {
props: { planId: '10', warehouseId: '20' }, props: { planId: '10', warehouseId: '20' },
global: { global: {
@@ -67,7 +70,6 @@ describe('PalletLabelPrintDialog', () => {
LoadErrorState: true, LoadErrorState: true,
Printer: true, Printer: true,
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' }, 'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
'el-alert': { props: ['title'], template: '<p>{{ title }}</p>' },
'el-radio-group': { template: '<div><slot /></div>' }, 'el-radio-group': { template: '<div><slot /></div>' },
'el-radio': { template: '<span><slot /></span>' }, 'el-radio': { template: '<span><slot /></span>' },
'el-select': { template: '<div><slot /></div>' }, 'el-select': { template: '<div><slot /></div>' },
@@ -83,7 +85,7 @@ describe('PalletLabelPrintDialog', () => {
await nextTick() await nextTick()
expect(preparePalletLabels).toHaveBeenCalledWith('10', '20') expect(preparePalletLabels).toHaveBeenCalledWith('10', '20')
expect(wrapper.text()).toContain('HLCU1723867-FBA地址内部编号-三位序号') expect(wrapper.text()).not.toContain('编号规则')
expect(wrapper.text()).toContain('ACY2') expect(wrapper.text()).toContain('ACY2')
expect(wrapper.text()).toContain('101') expect(wrapper.text()).toContain('101')
expect(wrapper.text()).toContain('HLCU1723867-101-001') expect(wrapper.text()).toContain('HLCU1723867-101-001')
@@ -56,12 +56,11 @@ async function print() {
<AppDialog class="admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="open" :title="dialogTitle" :width="dense ? 'min(920px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :fullscreenable="dense" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print"> <AppDialog class="admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="open" :title="dialogTitle" :width="dense ? 'min(920px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :fullscreenable="dense" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
<LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" /> <LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" />
<div class="admin-workbench" v-if="manifest" :class="{ 'density-dialog-sections': dense }"> <div class="admin-workbench" v-if="manifest" :class="{ 'density-dialog-sections': dense }">
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'work' : undefined"> <section :class="{ 'density-dialog-section': dense }">
<el-alert type="info" :closable="false" :title="`编号规则:${manifest.containerNo}-FBA地址内部编号-三位序号;一件代发托盘不在此清单。`" />
<el-radio-group v-model="scope" class="print-scope" :class="{ 'print-scope--dense': dense }"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group> <el-radio-group v-model="scope" class="print-scope" :class="{ 'print-scope--dense': dense }"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group>
<div v-if="scope === 'WAREHOUSE'" class="print-options" :class="{ 'density-form': dense }"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div> <div v-if="scope === 'WAREHOUSE'" class="print-options" :class="{ 'density-form': dense }"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div>
</section> </section>
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'source' : undefined"> <section :class="{ 'density-dialog-section': dense }">
<el-table :data="manifest.groups" border :max-height="dense ? undefined : 300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160" :show-overflow-tooltip="dense"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table> <el-table :data="manifest.groups" border :max-height="dense ? undefined : 300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160" :show-overflow-tooltip="dense"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table>
</section> </section>
</div> </div>
@@ -48,7 +48,7 @@ watch([planId, warehouseId], () => void loadDetail())
</template> </template>
<style scoped> <style scoped>
.confirmed-loading-detail-page { display: grid; min-width: 0; max-width: 1280px; gap: 16px; } .confirmed-loading-detail-page { display: grid; min-width: 0; width: 100%; gap: 16px; }
@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } } @media (min-width: 1920px) { .confirmed-loading-detail-page { gap: 12px; } }
@media print { .admin-page.admin-workbench { padding: 0; } } @media print { .admin-page.admin-workbench { padding: 0; } }
</style> </style>
@@ -157,12 +157,12 @@ const partnerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'partner', label: '单位名称', minWidth: 210, slotName: 'partner' }, { key: 'partner', label: '单位名称', minWidth: 210, slotName: 'partner' },
{ key: 'partnerCode', prop: 'partnerCode', label: '单位编码', minWidth: 130, defaultVisible: false }, { key: 'partnerCode', prop: 'partnerCode', label: '单位编码', minWidth: 130, defaultVisible: false },
{ key: 'status', label: '状态', width: 90, slotName: 'status', afterColumn: 'partnerCode' },
{ key: 'type', label: '类型', minWidth: 115, slotName: 'type' }, { key: 'type', label: '类型', minWidth: 115, slotName: 'type' },
{ key: 'contact', prop: 'contactName', label: '联系人', minWidth: 130 }, { key: 'contact', prop: 'contactName', label: '联系人', minWidth: 130 },
{ key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 145 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 145 },
{ key: 'contactEmail', prop: 'contactEmail', label: '邮箱', minWidth: 190, defaultVisible: false, formatter: (row: PlatformPartnerResource) => row.contactEmail || '—' }, { key: 'contactEmail', prop: 'contactEmail', label: '邮箱', minWidth: 190, defaultVisible: false, formatter: (row: PlatformPartnerResource) => row.contactEmail || '—' },
{ key: 'address', label: '地址', minWidth: 240, slotName: 'address', defaultVisible: false }, { key: 'address', label: '地址', minWidth: 240, slotName: 'address', defaultVisible: false },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 140, defaultVisible: false, formatter: (row: PlatformPartnerResource) => formatUpdatedAt(row.updatedAt) }, { key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 140, defaultVisible: false, formatter: (row: PlatformPartnerResource) => formatUpdatedAt(row.updatedAt) },
] ]
@@ -124,9 +124,9 @@ const expressCompanyColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'company', label: '公司名称', minWidth: 210, slotName: 'company' }, { key: 'company', label: '公司名称', minWidth: 210, slotName: 'company' },
{ key: 'companyCode', prop: 'companyCode', label: '编码', minWidth: 120 }, { key: 'companyCode', prop: 'companyCode', label: '编码', minWidth: 120 },
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'companyCode' },
{ key: 'website', label: '官方网站', minWidth: 210, slotName: 'website' }, { key: 'website', label: '官方网站', minWidth: 210, slotName: 'website' },
{ key: 'servicePhone', prop: 'servicePhone', label: '客服电话', minWidth: 130, formatter: (row: PlatformExpressCompanyResource) => row.servicePhone || '—' }, { key: 'servicePhone', prop: 'servicePhone', label: '客服电话', minWidth: 130, formatter: (row: PlatformExpressCompanyResource) => row.servicePhone || '—' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, defaultVisible: false, formatter: (row: PlatformExpressCompanyResource) => formatUpdatedAt(row.updatedAt) }, { key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, defaultVisible: false, formatter: (row: PlatformExpressCompanyResource) => formatUpdatedAt(row.updatedAt) },
] ]
@@ -9,7 +9,7 @@ import MessageComposerDialog from '../components/MessageComposerDialog.vue'; imp
import { notificationAppLabel, notificationCategoryLabel, notificationRecipientStatusLabel } from '../presentation' import { notificationAppLabel, notificationCategoryLabel, notificationRecipientStatusLabel } from '../presentation'
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),composerOpen=ref(false),detailOpen=ref(false),detailLoading=ref(false),detailError=ref<ApiErrorDetails|null>(null),detail=ref<NotificationTaskDetail|null>(null),templates=ref<NotificationTemplate[]>([]),result=ref({records:[] as NotificationTask[],total:0,pages:0}),filters=ref<QueryModel>({category:'',status:'',keyword:''}) const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),composerOpen=ref(false),detailOpen=ref(false),detailLoading=ref(false),detailError=ref<ApiErrorDetails|null>(null),detail=ref<NotificationTaskDetail|null>(null),templates=ref<NotificationTemplate[]>([]),result=ref({records:[] as NotificationTask[],total:0,pages:0}),filters=ref<QueryModel>({category:'',status:'',keyword:''})
const queryFields:QueryField[]=[{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'status',type:'select',label:'任务状态',placeholder:'全部状态',options:['CREATED','SCHEDULED','DISPATCHING','RETRYING','SENT','DEAD','CANCELLED'].map(value=>({label:statusLabel(value as NotificationTaskStatus),value}))},{key:'keyword',type:'input',label:'任务号或标题',placeholder:'任务号/消息标题'}] const queryFields:QueryField[]=[{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'status',type:'select',label:'任务状态',placeholder:'全部状态',options:['CREATED','SCHEDULED','DISPATCHING','RETRYING','SENT','DEAD','CANCELLED'].map(value=>({label:statusLabel(value as NotificationTaskStatus),value}))},{key:'keyword',type:'input',label:'任务号或标题',placeholder:'任务号/消息标题'}]
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:240,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',label:'业务分类',width:130,slotName:'category'},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:110,slotName:'status'}] const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:240,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'status',label:'状态',width:110,slotName:'status', afterColumn: 'taskNo' },{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',label:'业务分类',width:130,slotName:'category'},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'}]
const detailRow = ref<NotificationTask | null>(null) const detailRow = ref<NotificationTask | null>(null)
let detailRequest = 0 let detailRequest = 0
watch(detailOpen, value => { if (!value) { detailRequest++; detailRow.value = null } }) watch(detailOpen, value => { if (!value) { detailRequest++; detailRow.value = null } })
@@ -43,8 +43,8 @@ watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
<h3 class="density-dialog-section__title">收件人投递明细</h3> <h3 class="density-dialog-section__title">收件人投递明细</h3>
<div class="workbench-table"><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"> <div class="workbench-table"><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small">
<el-table-column prop="displayName" label="收件人" min-width="150" show-overflow-tooltip /> <el-table-column prop="displayName" label="收件人" min-width="150" show-overflow-tooltip />
<el-table-column label="应用端" width="110"><template #default="{row}">{{notificationAppLabel(row.appCode)}}</template></el-table-column>
<el-table-column label="投递状态" width="120"><template #default="{row}">{{notificationRecipientStatusLabel(row.status)}}</template></el-table-column> <el-table-column label="投递状态" width="120"><template #default="{row}">{{notificationRecipientStatusLabel(row.status)}}</template></el-table-column>
<el-table-column label="应用端" width="110"><template #default="{row}">{{notificationAppLabel(row.appCode)}}</template></el-table-column>
<el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column> <el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column>
<el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}"><span class="message-body">{{row.failureReason||'—'}}</span></template></el-table-column> <el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}"><span class="message-body">{{row.failureReason||'—'}}</span></template></el-table-column>
</el-table></div> </el-table></div>
@@ -5,7 +5,7 @@ import { onMounted, ref, watch } from 'vue'; import { Plus } from '@lucide/vue';
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import TemplateEditorDialog from '../components/TemplateEditorDialog.vue'; import { changeTemplateStatus, listTemplates } from '../api/notificationCenter'; import type { NotificationTemplate, TemplateStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview' 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 LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import TemplateEditorDialog from '../components/TemplateEditorDialog.vue'; import { changeTemplateStatus, listTemplates } from '../api/notificationCenter'; import type { NotificationTemplate, TemplateStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editing=ref<NotificationTemplate|null>(null),result=ref({records:[] as NotificationTemplate[],total:0,pages:0}),filters=ref<QueryModel>({code:'',category:'',title:'',status:''}) const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editing=ref<NotificationTemplate|null>(null),result=ref({records:[] as NotificationTemplate[],total:0,pages:0}),filters=ref<QueryModel>({code:'',category:'',title:'',status:''})
const queryFields:QueryField[]=[{key:'code',type:'input',label:'模板CODE',placeholder:'模板CODE'},{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'title',type:'input',label:'名称或标题',placeholder:'模板名称/标题'},{key:'status',type:'select',label:'状态',placeholder:'全部状态',options:[{label:'草稿',value:'DRAFT'},{label:'已激活',value:'ACTIVE'},{label:'已禁用',value:'DISABLED'}]}] const queryFields:QueryField[]=[{key:'code',type:'input',label:'模板CODE',placeholder:'模板CODE'},{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'title',type:'input',label:'名称或标题',placeholder:'模板名称/标题'},{key:'status',type:'select',label:'状态',placeholder:'全部状态',options:[{label:'草稿',value:'DRAFT'},{label:'已激活',value:'ACTIVE'},{label:'已禁用',value:'DISABLED'}]}]
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:190,fixed:'left',slotName:'actions'},{key:'templateCode',prop:'templateCode',label:'模板CODE',minWidth:190},{key:'templateName',prop:'templateName',label:'模板名称',minWidth:170},{key:'category',prop:'category',label:'业务分类',width:130},{key:'titleTemplate',prop:'titleTemplate',label:'消息标题模板',minWidth:220},{key:'bodyTemplate',prop:'bodyTemplate',label:'正文模板',minWidth:320,showOverflowTooltip:true},{key:'status',label:'状态',width:100,slotName:'status'},{key:'version',prop:'version',label:'版本',width:80}] const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:190,fixed:'left',slotName:'actions'},{key:'templateCode',prop:'templateCode',label:'模板CODE',minWidth:190},{key:'status',label:'状态',width:100,slotName:'status', afterColumn: 'templateCode' },{key:'templateName',prop:'templateName',label:'模板名称',minWidth:170},{key:'category',prop:'category',label:'业务分类',width:130},{key:'titleTemplate',prop:'titleTemplate',label:'消息标题模板',minWidth:220},{key:'bodyTemplate',prop:'bodyTemplate',label:'正文模板',minWidth:320,showOverflowTooltip:true},{key:'version',prop:'version',label:'版本',width:80}]
function statusLabel(v:TemplateStatus){return {DRAFT:'草稿',ACTIVE:'已激活',DISABLED:'已禁用'}[v]} function statusLabel(v:TemplateStatus){return {DRAFT:'草稿',ACTIVE:'已激活',DISABLED:'已禁用'}[v]}
async function load(){loading.value=true;error.value=null;try{const d=await listTemplates({page:page.value,size:pageSize.value,templateCode:String(filters.value.code||'').trim()||undefined,category:String(filters.value.category||'').trim()||undefined,title:String(filters.value.title||'').trim()||undefined,status:(filters.value.status||undefined) as TemplateStatus|undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'模板加载失败')}finally{loading.value=false}} async function load(){loading.value=true;error.value=null;try{const d=await listTemplates({page:page.value,size:pageSize.value,templateCode:String(filters.value.code||'').trim()||undefined,category:String(filters.value.category||'').trim()||undefined,title:String(filters.value.title||'').trim()||undefined,status:(filters.value.status||undefined) as TemplateStatus|undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'模板加载失败')}finally{loading.value=false}}
function search(){page.value===1?void load():page.value=1}function create(){editing.value=null;editorOpen.value=true}function edit(row:NotificationTemplate){editing.value=row;editorOpen.value=true} function search(){page.value===1?void load():page.value=1}function create(){editing.value=null;editorOpen.value=true}function edit(row:NotificationTemplate){editing.value=row;editorOpen.value=true}
@@ -41,10 +41,10 @@ const queryFields: QueryField[] = [
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'title', prop: 'title', label: '消息标题', minWidth: 220 }, { key: 'title', prop: 'title', label: '消息标题', minWidth: 220 },
{ key: 'status', label: '状态', width: 90, slotName: 'status', afterColumn: 'title' },
{ key: 'summary', prop: 'summary', label: '内容摘要', minWidth: 300, showOverflowTooltip: true }, { key: 'summary', prop: 'summary', label: '内容摘要', minWidth: 300, showOverflowTooltip: true },
{ key: 'category', label: '分类', width: 130, slotName: 'category' }, { key: 'category', label: '分类', width: 130, slotName: 'category' },
{ key: 'sentAt', label: '发送时间', width: 180, slotName: 'sentAt' }, { key: 'sentAt', label: '发送时间', width: 180, slotName: 'sentAt' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
] ]
function statusLabel(status: NotificationInboxStatus) { return status === 'UNREAD' ? '未读' : '已读' } function statusLabel(status: NotificationInboxStatus) { return status === 'UNREAD' ? '未读' : '已读' }
@@ -43,6 +43,7 @@ const fields = computed<QueryField[]>(() => [
const columns = [ const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false },
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 }, { prop: 'bookingNo', label: '订舱号', minWidth: 150 },
{ prop: 'status', label: '状态', minWidth: 110, slotName: 'status', afterColumn: 'bookingNo' },
{ prop: 'customerName', label: '客户', minWidth: 160, formatter: (row: OceanBookingResource) => row.customerName || '—' }, { prop: 'customerName', label: '客户', minWidth: 160, formatter: (row: OceanBookingResource) => row.customerName || '—' },
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' }, { prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' },
{ prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' }, { prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' },
@@ -55,7 +56,6 @@ const columns = [
{ prop: 'pod', label: '目的港', minWidth: 125 }, { prop: 'pod', label: '目的港', minWidth: 125 },
{ prop: 'etd', label: '预计开船', minWidth: 155, slotName: 'etd' }, { prop: 'etd', label: '预计开船', minWidth: 155, slotName: 'etd' },
{ prop: 'eta', label: '预计到港', minWidth: 155, slotName: 'eta' }, { prop: 'eta', label: '预计到港', minWidth: 155, slotName: 'eta' },
{ prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
{ prop: 'updatedAt', label: '更新时间', minWidth: 155, slotName: 'updatedAt' }, { prop: 'updatedAt', label: '更新时间', minWidth: 155, slotName: 'updatedAt' },
] ]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
@@ -49,13 +49,13 @@ const fields = computed<QueryField[]>(() => [
const columns = [ const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false },
{ prop: 'inboundCode', label: '入仓码', minWidth: 145 }, { prop: 'inboundCode', label: '入仓码', minWidth: 145 },
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking', afterColumn: 'inboundCode' },
{ prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck', afterColumn: 'inboundCode' },
{ prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick', afterColumn: 'inboundCode' },
{ prop: 'dispatchStatus', label: '出仓状态', width: 115, slotName: 'status', afterColumn: 'inboundCode' },
{ prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'customerName', label: '客户', minWidth: 140 },
{ prop: 'cabinetNo', label: '柜次号', minWidth: 145, formatter: (row: OutboundDispatchResource) => row.cabinetNo || '—' }, { prop: 'cabinetNo', label: '柜次号', minWidth: 145, formatter: (row: OutboundDispatchResource) => row.cabinetNo || '—' },
{ prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: OutboundDispatchResource) => row.containerNo || '—' }, { prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: OutboundDispatchResource) => row.containerNo || '—' },
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' },
{ prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' },
{ prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' },
{ prop: 'dispatchStatus', label: '出仓状态', width: 115, slotName: 'status' },
{ key: 'gate', label: '门禁', width: 90, slotName: 'gate' }, { key: 'gate', label: '门禁', width: 90, slotName: 'gate' },
] ]
+1 -1
View File
@@ -22,7 +22,7 @@
## 关键边界 ## 关键边界
- 采购样品履约是一条独立链路:下单、包装、面单、交寄、国内核费、寄客户;改节点或费用前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`,不套用普通头程或尾程流程。 - 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。
- 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。 - 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。
- 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。 - 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。
- 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。 - 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。
@@ -130,3 +130,6 @@
| 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 | | 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 |
本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。 本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。
2026-10-29 用户截图调整:采购详情显示客户驳回原因,收货批次提供重新上传照片;各批旧图全部替换前不可再次采购完成。本土卡车新增/编辑数量改托数,托数和预算均须大于0。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { actOnOverseasFee, createOverseasFee, getOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, listOverseasFeeItems, listOverseasFees, listOverseasFeeRetries, type SaveOverseasFee } from './overseasFees'
const base = '/platform/v1/last-leg/finance-fee-sources'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const row = { id: '9007199254740993', sourceFeeId: '9007199254740993', customerId: '1', businessId: '9007199254740995', direction: 'RECEIVABLE', allowedActions: ['APPROVE'] }
describe('海外费用真实接口', () => {
beforeEach(() => vi.clearAllMocks())
it('分页不退回旧非分页接口,长整型身份保持字符串', async () => {
client.get.mockResolvedValue(envelope({ records: [row], total: '1', pages: '1', page: 1, size: 20 }))
const result = await listOverseasFees({ page: 1, size: 20, feeItemCode: 'F1' })
expect(client.get).toHaveBeenCalledWith(`${base}/page`, { params: { page: 1, size: 20, feeItemCode: 'F1' } })
expect(result.records[0]?.businessId).toBe('9007199254740995')
expect(result.total).toBe(1)
client.get.mockResolvedValue(envelope(row))
expect((await getOverseasFee(row.sourceFeeId)).sourceFeeId).toBe(row.sourceFeeId)
})
it('创建传正式业务订单及幂等键,审核和锁款重试各用正式命令', async () => {
client.post.mockResolvedValue(envelope(row))
const payload = { businessNo: 'WT1', businessType: 'WAREHOUSE_TRANSFER', amount: 10 } as SaveOverseasFee
await createOverseasFee(payload, 'create-1')
expect(client.post).toHaveBeenCalledWith(base, payload, { headers: { 'Idempotency-Key': 'create-1' } })
await actOnOverseasFee(row.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/review`, { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_FREEZE', { idempotencyKey: 'retry-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-funding`, { idempotencyKey: 'retry-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_PUBLISH', { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-delivery`, { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
})
it('目录和业务订单选项读取真实数据,不套用人民币默认价', async () => {
client.get.mockResolvedValueOnce(envelope([{ code: 'F1', name: '操作费', defaultPrice: 10 }]))
.mockResolvedValueOnce(envelope([{ businessId: '9007199254740995', customerId: '1', orderId: null, warehouseId: '2' }]))
expect(await listOverseasFeeItems()).toEqual([{ feeCode: 'F1', feeTypeName: '操作费', defaultPrice: 10 }])
expect(client.get).toHaveBeenLastCalledWith(`${base}/fee-items`, { params: { direction: 'RECEIVABLE' } })
expect((await listBusinessFeeOptions({ businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' }))[0]?.businessId).toBe('9007199254740995')
expect(client.get).toHaveBeenLastCalledWith(`${base}/business-options`, { params: { businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' } })
})
it('应付项目明确传递方向', async () => {
client.get.mockResolvedValue(envelope([]))
await listOverseasFeeItems('PAYABLE')
expect(client.get).toHaveBeenCalledWith(`${base}/fee-items`, { params: { direction: 'PAYABLE' } })
})
it('按稳定费用身份查询完整重试记录', async () => {
client.get.mockResolvedValue(envelope([]))
expect(await listOverseasFeeRetries(row.sourceFeeId)).toEqual([])
expect(client.get).toHaveBeenCalledWith(`${base}/${row.sourceFeeId}/retries`)
})
it('单据费用先严格匹配客户、类型及ID,合并去重后分页', async () => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '测试客户', orderId: null, warehouseId: null }
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
const first = { ...row, businessType: business.businessType, createdAt: '2026-10-22T10:00:00' }
const second = { ...first, sourceFeeId: '9007199254740994', businessId: related.businessId, businessType: related.businessType }
client.get.mockResolvedValue(envelope([first, second, first,
{ ...first, sourceFeeId: 'wrong-id', businessId: 'another' },
{ ...first, sourceFeeId: 'wrong-type', businessType: 'RETURN_ORDER' },
{ ...first, sourceFeeId: 'wrong-customer', customerId: '2' },
]))
const result = await listBusinessOverseasFees([business, related], { page: 2, size: 1 })
expect(client.get).toHaveBeenCalledWith(base, { params: { customerId: '1', businessType: 'LASTLEG_ORDER', businessNo: 'TB1' } })
expect(result).toMatchObject({ total: 2, pages: 2, page: 2, size: 1 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('混合返回时先按 %s 分开,再统计分页,不遗漏第二页数据', async direction => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '客户', orderId: null, warehouseId: null }
const first = { ...row, businessType: 'LASTLEG_ORDER', direction, createdAt: '2026-10-23T10:00:00' }
const second = { ...first, sourceFeeId: 'second', createdAt: '2026-10-23T11:00:00' }
const opposite = { ...first, sourceFeeId: 'opposite', direction: direction === 'RECEIVABLE' ? 'PAYABLE' : 'RECEIVABLE' }
client.get.mockResolvedValue(envelope([first, opposite, second, { ...first, sourceFeeId: 'unknown', direction: null }]))
const result = await listBusinessOverseasFees(business, { page: 2, size: 1 }, direction)
expect(result).toMatchObject({ total: 2, pages: 2 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
expect(result.records.every(fee => fee.direction === direction)).toBe(true)
})
})
@@ -0,0 +1,99 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface FeePage<T> { records: T[]; page: number; size: number; total: number; pages: number }
export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface OverseasFee {
storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null;
lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number;
sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string
sourceType: string; businessType: string; businessId: string | null; businessNo: string
displayBusinessType?: string
direction: OverseasFeeDirection
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
orderId: string | null; warehouseId: string | null; feeItemCode: string; feeItemName: string
feeRemark?: string | null
businessDate: string; currency: string; amount: number; originalCurrency: string; originalAmount: number
exchangeRate?: number | null; exchangeRateMonth?: string | null
chargeableQuantity: number | null; quantityUnit: string | null; unitPrice: number | null
periodStart: string | null; periodEnd: string | null; reviewStatus: string; sourceStatus: string
publicationStatus: string; collectionMode: 'BILL' | 'WALLET'; walletStatus: string | null
deliveryStatus?: 'PENDING' | 'PROCESSING' | 'PUBLISHED' | 'DEAD' | null
deliveryKind?: 'FEE' | 'FUNDING' | null; deliveryEventId?: string | null; deliveryErrorMessage?: string | null
walletErrorMessage: string | null; reviewReason: string | null; reviewerId?: string | null; reviewerName?: string | null; reviewedAt: string | null
createdAt: string; updatedAt: string; allowedActions: string[]
}
export interface BusinessFeeOption {
businessId: string; businessType: string; businessNo: string; customerId: string; customerName: string
warehouseId: string | null; orderId: string | null; orderType?: string
}
export interface SaveOverseasFee {
customerId: string; customerName: string; sourceType: string; businessType: string
businessId: string; businessNo: string; orderId: string | null; warehouseId: string | null
feeItemCode: string; feeItemName: string; direction: OverseasFeeDirection; businessDate: string
feeRemark?: string | null
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
currency: 'CNY' | 'USD'; amount: number
chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
}
const base = '/platform/v1/last-leg/finance-fee-sources'
export interface OverseasFeeRetryRecord {
requestId: string; requestedAt: string; operatorName: string; action: string
result: 'SUCCESS' | 'FAILED' | 'SUBMITTED'; errorMessage: string | null
}
export async function listOverseasFeeRetries(id: string): Promise<OverseasFeeRetryRecord[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<OverseasFeeRetryRecord[]>, ApiEnvelope<OverseasFeeRetryRecord[]>>(`${base}/${encodeURIComponent(id)}/retries`))
}
export async function listOverseasFeeItems(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') {
const response = await http.get<ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>, ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>>(`${base}/fee-items`, { params: { direction } })
return unwrapApiResponse(response).map(item => ({ feeCode: item.code, feeTypeName: item.name, defaultPrice: item.defaultPrice }))
}
export async function listOverseasFeeCustomers() {
const response = await http.get<ApiEnvelope<Array<{ customerId: string; customerName: string }>>, ApiEnvelope<Array<{ customerId: string; customerName: string }>>>(`${base}/customer-options`)
return unwrapApiResponse(response).map(row => ({ ...row, customerId: String(row.customerId) }))
}
function normalizeFee(value: OverseasFee): OverseasFee {
return { ...value, id: String(value.id), sourceFeeId: String(value.sourceFeeId), customerId: String(value.customerId),
businessId: value.businessId == null ? null : String(value.businessId), orderId: value.orderId == null ? null : String(value.orderId),
warehouseId: value.warehouseId == null ? null : String(value.warehouseId), allowedActions: value.allowedActions || [] }
}
export async function listOverseasFees(query: Record<string, string | number | undefined>): Promise<FeePage<OverseasFee>> {
const response = await http.get<ApiEnvelope<FeePage<OverseasFee>>, ApiEnvelope<FeePage<OverseasFee>>>(`${base}/page`, { params: query })
const data = unwrapApiResponse(response)
return { ...data, records: data.records.map(normalizeFee), total: Number(data.total), pages: Number(data.pages) }
}
export async function listBusinessOverseasFees(business: BusinessFeeOption | BusinessFeeOption[], pagination: { page: number; size: number }, direction: OverseasFeeDirection = 'RECEIVABLE'): Promise<FeePage<OverseasFee>> {
const { page, size } = pagination
const scopes = Array.isArray(business) ? business : [business]
if (!scopes.length || scopes.some(scope => !scope.businessId || !scope.customerId || !scope.businessType)) throw new Error('业务单据信息不完整')
// 非分页接口返回两种方向;先按客户、业务身份及收付方向匹配,再合并分页。
const groups = await Promise.all(scopes.map(async scope => {
const response = await http.get<ApiEnvelope<OverseasFee[]>, ApiEnvelope<OverseasFee[]>>(base, {
params: { customerId: scope.customerId, businessType: scope.businessType, businessNo: scope.businessNo },
})
return unwrapApiResponse(response).map(normalizeFee).filter(row => row.customerId === scope.customerId && row.businessType === scope.businessType && row.businessId === scope.businessId && row.direction === direction)
}))
const records = [...new Map(groups.flat().map(row => [row.sourceFeeId, row])).values()]
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.sourceFeeId.localeCompare(left.sourceFeeId))
return { records: records.slice((page - 1) * size, page * size), total: records.length, pages: Math.ceil(records.length / size), page, size }
}
export async function getOverseasFee(id: string) {
return normalizeFee(unwrapApiResponse(await http.get<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}`)))
}
export async function listBusinessFeeOptions(params: { businessType: string; keyword?: string; customerId?: string }) {
const response = await http.get<ApiEnvelope<BusinessFeeOption[]>, ApiEnvelope<BusinessFeeOption[]>>(`${base}/business-options`, { params })
return unwrapApiResponse(response).map(row => ({ ...row, businessId: String(row.businessId), customerId: String(row.customerId), orderId: row.orderId == null ? null : String(row.orderId), warehouseId: row.warehouseId == null ? null : String(row.warehouseId) }))
}
export async function createOverseasFee(payload: SaveOverseasFee, key: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(base, payload, { headers: { 'Idempotency-Key': key } })))
}
export async function actOnOverseasFee(id: string, action: string, body: Record<string, unknown>) {
const endpoint = action === 'APPROVE' || action === 'REJECT' ? 'review'
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? 'retry-delivery'
: action.startsWith('RETRY_') ? 'retry-funding' : action.toLowerCase()
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}/${endpoint}`, body)))
}
@@ -36,6 +36,24 @@ const looseboardSummary = {
describe('platform overseas business API adapter', () => { describe('platform overseas business API adapter', () => {
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
it('loads the six-table procurement projection without requiring legacy facts', async () => {
overseas.get.mockResolvedValue(envelope({
customerView: { order: { ...purchaseSummary, status: 'RECEIVING', version: '1', purchaseQuantity: '10' }, boxes: [], shipments: [], fees: [] },
purchaseFacts: {}, physical: { dispositions: [], receipts: [], cartons: [] },
transport: { overseasCargo: [], domesticCargo: [], shipments: [] },
controls: { notes: [], remaining: [], closures: [], closureItems: [] },
fees: [], feeCorrections: [], operations: [], actions: [], reviewHistory: [], internalRemark: null,
files: [{ id: '901', fileId: '9007199254740995', sizeBytes: '123' }],
}))
const detail = await getOverseasPurchase('OP-1', '10001')
expect(detail.customerView.order.version).toBe(1)
expect(detail.customerView.order.purchaseQuantity).toBe(10)
expect(detail.files[0]).toMatchObject({ id: '901', fileId: '9007199254740995', sizeBytes: 123 })
expect(detail.physical.receipts).toEqual([])
expect(detail.actions).toEqual([])
expect(overseas.get).toHaveBeenCalledTimes(1)
})
it('normalizes wire integer strings before building replayable procurement commands', async () => { it('normalizes wire integer strings before building replayable procurement commands', async () => {
overseas.get.mockResolvedValue(envelope({ overseas.get.mockResolvedValue(envelope({
customerView: { order: { ...purchaseSummary, version: '0', purchaseQuantity: '1' }, boxes: [{ quantity: '1' }], shipments: [{ quantity: '1' }], fees: [{ version: '2' }] }, customerView: { order: { ...purchaseSummary, version: '0', purchaseQuantity: '1' }, boxes: [{ quantity: '1' }], shipments: [{ quantity: '1' }], fees: [{ version: '2' }] },
@@ -57,8 +57,8 @@ export async function listOverseasPurchaseCustomerOptions(keyword = '', limit =
const response = await http.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } }) const response = await http.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function listOverseasPurchaseWarehouseOptions() { export async function listOverseasPurchaseWarehouseOptions(procurementDirection?: import('../procurementDirection').ProcurementDirection) {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`) const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`, { params: { procurementDirection } })
return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) })) return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) }))
} }
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) { export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
@@ -3,6 +3,7 @@ import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind
export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close' export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close'
export interface ProcurementCommandBody { export interface ProcurementCommandBody {
procurementDirection?: import('../procurementDirection').ProcurementDirection
name?: string name?: string
targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string
resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string
@@ -0,0 +1,14 @@
import { beforeEach, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async importOriginal => ({ ...await importOriginal<typeof import('@/shared/api/http')>(), http: api }))
import { getProcurementWorkflow, submitProcurementWorkflow } from './procurementWorkflow'
beforeEach(() => { vi.clearAllMocks(); api.get.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }); api.post.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }) })
it('scopes workflow and every batch operation to the exact order and customer', async () => {
await getProcurementWorkflow('OP/1', '1001')
expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/workflow', { params: { customerId: '1001' } })
for (const action of ['receipts', 'request-confirmation', 'outbounds', 'outbounds/9007199254740993/dispatch', 'outbounds/9007199254740993/complete']) {
const command = { version: 3, idempotencyKey: 'retry-same-key' }
await submitProcurementWorkflow('OP/1', '1001', action, command)
expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${action}`, command, { params: { customerId: '1001' } })
}
})
@@ -0,0 +1,20 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface ProcurementWorkflowReceipt { id: string; quantity: number; fileIds: string[]; receivedAt: string }
export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null }
export interface ProcurementWorkflow {
orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number
rejectionReason: string | null; photosNeedUpdate: boolean
receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[]
}
export interface WorkflowCommand { version: number; idempotencyKey: string; receiptId?: string; quantity?: number; trackingNo?: string; fileIds?: string[] }
const base = '/platform/v1/last-leg/overseas-procurements'
export async function getProcurementWorkflow(orderNo: string, customerId: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } }))
}
export async function submitProcurementWorkflow(orderNo: string, customerId: string, action: string, payload: WorkflowCommand) {
return unwrapApiResponse(await http.post<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/${action}`, payload, { params: { customerId } }))
}
export async function editProcurementOutbound(orderNo: string, customerId: string, batchId: string, payload: WorkflowCommand) {
return unwrapApiResponse(await http.put<ApiEnvelope<ProcurementWorkflow>, ApiEnvelope<ProcurementWorkflow>>(`${base}/${encodeURIComponent(orderNo)}/outbounds/${encodeURIComponent(batchId)}`, payload, { params: { customerId } }))
}
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses } from './storageRent'
const base = '/platform/v1/last-leg/storage-rent'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const lot = { id: '9223372036854775001', customerId: '9223372036854775002', warehouseId: '9223372036854775003', businessNo: 'WT1' }
describe('仓租查询接口', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the paginated root endpoint with server filters and preserves long IDs', async () => {
client.get.mockResolvedValue(envelope({ records: [lot], total: '1', page: 1, size: 20, pages: 1 }))
const params = { page: 1, size: 20, businessType: 'WAREHOUSE_TRANSFER', warehouseId: lot.warehouseId, status: 'IN_STOCK' }
const result = await listStorageRentLots(params)
expect(client.get).toHaveBeenCalledWith(base, { params })
expect(result.total).toBe(1)
expect(result.records[0]).toMatchObject(lot)
})
it('reads a lot with its actual charges and warehouse options from dedicated endpoints', async () => {
const charge = { id: '9223372036854775004', outboundNo: 'OUT1', quantity: 2, originalAmount: 23, currency: 'CNY', settlementAmount: 23 }
const currencyLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] }
client.get.mockResolvedValueOnce(envelope({ lot: currencyLot, charges: [charge] }))
.mockResolvedValueOnce(envelope([{ id: lot.warehouseId, warehouseName: '测试仓' }]))
const detail = await getStorageRentLot(lot.id)
expect(client.get).toHaveBeenNthCalledWith(1, `${base}/${lot.id}`)
expect(detail.lot.id).toBe(lot.id)
expect(detail.lot).toMatchObject(currencyLot)
expect(detail.charges).toEqual([charge])
expect(await listStorageRentWarehouses()).toEqual([{ id: lot.warehouseId, warehouseName: '测试仓' }])
expect(client.get).toHaveBeenNthCalledWith(2, `${base}/warehouse-options`)
})
})
@@ -0,0 +1,39 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FeePage } from './overseasFees'
import type { OverseasFee } from './overseasFees'
export async function adjustStorageRentFee(id: string, body: { expectedVersion: number; amount: number; reason: string; idempotencyKey: string }) {
return unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`/platform/v1/last-leg/storage-rent/fees/${encodeURIComponent(id)}/adjust`, body))
}
export interface StorageRentLot {
id: string; customerId: string; customerName: string; businessType: string; businessNo: string
warehouseId: string; warehouseName: string; batchNo: string; inboundAt: string; storageDays: number
originalQuantity: number; remainingQuantity: number; quantityUnit: string; billingQuantity: number
billingUnit: string; ruleCode: string; status: string; generatedAmount: number | null
generatedCurrency: string | null; generatedAmounts: Array<{ currency: string; amount: number }>
settledAmount?: number; chargeCount?: number; settledChargeCount?: number
}
export interface StorageRentCharge {
id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number
originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string
createdAt: string; outboundAt: string
}
export interface StorageRentDetail { lot: StorageRentLot; charges: StorageRentCharge[] }
const base = '/platform/v1/last-leg/storage-rent'
function normalizeLot(row: StorageRentLot): StorageRentLot {
return { ...row, id: String(row.id), customerId: String(row.customerId), warehouseId: String(row.warehouseId) }
}
export async function listStorageRentLots(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<FeePage<StorageRentLot>>, ApiEnvelope<FeePage<StorageRentLot>>>(base, { params })
const data = unwrapApiResponse(response)
return { ...data, total: Number(data.total), records: data.records.map(normalizeLot) }
}
export async function getStorageRentLot(id: string) {
const data = unwrapApiResponse(await http.get<ApiEnvelope<StorageRentDetail>, ApiEnvelope<StorageRentDetail>>(`${base}/${encodeURIComponent(id)}`))
return { lot: normalizeLot(data.lot), charges: data.charges.map(row => ({ ...row, id: String(row.id) })) }
}
export async function listStorageRentWarehouses() {
const data = unwrapApiResponse(await http.get<ApiEnvelope<Array<{ id: string; warehouseName: string }>>, ApiEnvelope<Array<{ id: string; warehouseName: string }>>>(`${base}/warehouse-options`))
return data.map(row => ({ ...row, id: String(row.id) }))
}
@@ -0,0 +1,59 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BusinessDocumentFees from './BusinessDocumentFees.vue'
import { listBusinessFeeOptions } from '../api/overseasFees'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({ listBusinessFeeOptions: vi.fn() }))
vi.mock('./BusinessOrderFees.vue', () => ({ default: { name: 'BusinessOrderFees', props: ['business', 'relatedBusinesses', 'canCreate', 'readonly', 'direction'], template: '<div><slot name="toolbar-actions" /></div>' } }))
const business = { businessType: 'LASTLEG_ORDER', businessId: '9223372036854775001', businessNo: 'TB-01', customerId: 'C1', customerName: '客户', orderId: '9223372036854775001', warehouseId: null }
function page(props = {}) {
return mount(BusinessDocumentFees, { props: { ...business, ...props }, global: { stubs: {
LoadErrorState: { props: ['message'], template: '<div>{{ message }}</div>' }, ApiErrorMeta: true, ElSkeleton: true, ElAlert: true,
} } })
}
beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true); vi.mocked(listBusinessFeeOptions).mockResolvedValue([business]) })
describe('单据费用归属', () => {
it('passes the payable direction to the review panel', async () => {
const wrapper = page({ direction: 'PAYABLE', canCreate: false }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props()).toMatchObject({ direction: 'PAYABLE', canCreate: false })
})
it('requires exact customer, type, number and long identity', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessNo: 'TB-010' }, { ...business, customerId: 'C2' }, business])
const wrapper = page(); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('canCreate')).toBe(true)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('readonly')).toBe(false)
expect(listBusinessFeeOptions).toHaveBeenCalledWith({ businessType: 'LASTLEG_ORDER', customerId: 'C1', keyword: 'TB-01' })
})
it('does not permit a missing or ambiguous match to create charges', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessId: 'another' }])
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
expect(wrapper.text()).toContain('未找到唯一关联单据')
})
it('resolves source order charges alongside tail charges', async () => {
const source = { ...business, businessType: 'RETURN_ORDER', businessId: 'return-id', businessNo: 'TH-01', orderId: null }
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([source])
const wrapper = page({ relatedDocuments: [source] }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([source])
})
it('can resolve a unique relabel id without inventing a customer', async () => {
const wrapper = page({ customerId: undefined }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business').customerId).toBe('C1')
})
it('keeps the real tail order usable when its source number is only an external reference', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([])
const wrapper = page({ relatedDocuments: [{ businessType: 'RELABEL_ORDER', businessNo: 'external-reference', customerId: 'C1' }] })
await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([])
})
it('does not query fees without read permission', async () => {
can.mockReturnValue(false)
const wrapper = page(); await flushPromises()
expect(listBusinessFeeOptions).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
})
})
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFeeDirection } from '../api/overseasFees'
import { feePermissions } from '../overseasFeePresentation'
interface DocumentIdentity { businessType: string; businessNo: string; customerId?: string; businessId?: string }
class DocumentIdentityError extends Error {}
const props = withDefaults(defineProps<DocumentIdentity & { relatedDocuments?: DocumentIdentity[]; canCreate?: boolean; readonly?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { canCreate: true, readonly: false, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can(feePermissions.list) || access.can('platform:finance:customer-arap:list'))
const business = ref<BusinessFeeOption | null>(null), related = ref<BusinessFeeOption[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const panel = ref<InstanceType<typeof BusinessOrderFees>>()
let generation = 0
async function load() {
const request = ++generation
business.value = null; related.value = []; error.value = null; loading.value = false
if (!canRead.value) return
loading.value = true
try {
const identities: DocumentIdentity[] = [props, ...(props.relatedDocuments || [])]
const resolved = await Promise.all(identities.map(async (identity, index) => {
if ((!identity.customerId && !identity.businessId) || !identity.businessNo) throw new DocumentIdentityError('单据信息不完整,无法读取费用')
const options = await listBusinessFeeOptions({ businessType: identity.businessType, customerId: identity.customerId, keyword: identity.businessNo })
// 候选搜索是模糊查询,只有当前客户和完整单号一致时才能承载收费。
const matches = options.filter(option => (!identity.customerId || option.customerId === identity.customerId) && option.businessType === identity.businessType
&& option.businessNo === identity.businessNo && (!identity.businessId || option.businessId === identity.businessId))
// 尾程来源号可能只是外部参考号;没有内部业务实体时不能据此造出费用归属。
if (index > 0 && matches.length === 0) return null
if (matches.length !== 1) throw new DocumentIdentityError(`未找到唯一关联单据:${identity.businessNo}`)
return matches[0]!
}))
if (request === generation) { business.value = resolved[0]!; related.value = resolved.slice(1).filter((value): value is BusinessFeeOption => value !== null) }
} catch (failure) { if (request === generation) error.value = failure instanceof DocumentIdentityError ? { message: failure.message } : getApiErrorDetails(failure, '单据费用加载失败') }
finally { if (request === generation) loading.value = false }
}
watch(() => [props.businessType, props.businessNo, props.customerId, props.businessId, props.relatedDocuments, canRead.value], load, { immediate: true, deep: true })
async function refresh() { if (business.value) await panel.value?.refresh(); else await load() }
/** 新增前读取完整单据范围,重复项目提示不受当前表格分页影响。 */
async function getFees() {
if (!business.value) await load()
if (!business.value || error.value) throw new Error(error.value?.message || '单据费用尚未加载完成')
return (await listBusinessOverseasFees([business.value, ...related.value], { page: 1, size: Number.MAX_SAFE_INTEGER }, props.direction)).records
}
defineExpose({ refresh, getFees })
</script>
<template>
<section aria-label="单据费用">
<el-alert v-if="!canRead" title="暂无费用查看权限" type="info" :closable="false" />
<LoadErrorState v-else-if="error" title="单据费用加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="3" animated />
<BusinessOrderFees v-else-if="business" ref="panel" :business="business" :related-businesses="related" :direction="direction" :can-create="canCreate" :readonly="readonly" :hide-when-empty="hideWhenEmpty" :heading="heading" @changed="emit('changed')">
<template v-if="$slots['toolbar-actions']" #toolbar-actions><slot name="toolbar-actions" /></template>
</BusinessOrderFees>
</section>
</template>

Some files were not shown because too many files have changed in this diff Show More