Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698531dc26 | ||
|
|
536308f3ec |
@@ -120,8 +120,7 @@ pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
|
||||
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
|
||||
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||
|
||||
@@ -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/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/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/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/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/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` 状态 |
|
||||
@@ -37,7 +37,7 @@
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后预计到港经 `/estimated-arrival` 按编辑权限和版本单独维护,其余运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`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: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(60)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: PrototypeCustomerServiceIcon,
|
||||
children: [
|
||||
{ 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' },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -165,7 +165,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
title: '财务管理',
|
||||
icon: PrototypeFinanceIcon,
|
||||
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-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' },
|
||||
|
||||
@@ -11,7 +11,6 @@ import { batchContext } from './batch.test-support'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
import { getWorkDetail, type WorkAction } from './workApi'
|
||||
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>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
@@ -20,7 +19,6 @@ vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArriv
|
||||
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('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
|
||||
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
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 }
|
||||
@@ -37,7 +35,6 @@ beforeEach(() => {
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
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(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(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
|
||||
@@ -55,7 +52,6 @@ async function create(wrapper: Awaited<ReturnType<typeof open>>) {
|
||||
const content = creationBody()
|
||||
await content.get('input[type="checkbox"]').setValue(true)
|
||||
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
|
||||
}
|
||||
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
|
||||
@@ -76,19 +72,17 @@ 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()
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
|
||||
})
|
||||
it('keeps the real arrival input editable after a failed attempt and submits the edited time with a fresh key', async () => {
|
||||
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||
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()
|
||||
expect(dialog.get('input').attributes('disabled')).toBeUndefined()
|
||||
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()
|
||||
expect(dialog.get('input').attributes('disabled')).toBeDefined()
|
||||
expect(dialog.text()).toContain('上次提交结果尚未确认')
|
||||
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
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 })
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
})
|
||||
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()
|
||||
@@ -120,7 +114,7 @@ it('preserves real FBA fields and entered values when expanding the create dialo
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(date.props('modelValue')).toBe('2026-10-25')
|
||||
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: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }), 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: 'PENDING' }), idempotencyKey: expect.any(String) }))
|
||||
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(creationBody().text()).toContain('AP-001')
|
||||
})
|
||||
@@ -147,10 +141,9 @@ it('uses the same real continuous form through the original work detail deep lin
|
||||
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('原业务预约办理'))!)
|
||||
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(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
|
||||
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: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }) }))
|
||||
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
|
||||
expect(content.text()).toContain('本次保存已完成')
|
||||
})
|
||||
|
||||
@@ -8,11 +8,11 @@ const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key:
|
||||
const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
function reportedDispatch() {
|
||||
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
|
||||
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
|
||||
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
|
||||
}
|
||||
describe('four-type appointment calendar', () => {
|
||||
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 ', 'D1821', ' d1821 ', 'D1822', '1821'])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
|
||||
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
|
||||
const wrapper = render(reportedDispatch(), '2026-10-23')
|
||||
const search = wrapper.get('[aria-label="搜索当前日历"]')
|
||||
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
|
||||
@@ -26,7 +26,6 @@ describe('four-type appointment calendar', () => {
|
||||
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('09:00')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('D1821、D1822')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
@@ -109,8 +108,7 @@ describe('four-type appointment calendar', () => {
|
||||
expect(wrapper.text()).toContain('正在读取安排')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
await wrapper.setProps({ loading: false, incomplete: true })
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('当前已加载 0 项安排')
|
||||
expect(wrapper.text()).not.toContain('部分数据不可用')
|
||||
expect(wrapper.text()).toContain('部分数据不可用')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,9 +70,9 @@ function changeMode(next: CalendarMode) {
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail workbench-panel is-flush" aria-label="当天安排">
|
||||
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排</p></header>
|
||||
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
|
||||
<div class="detail-events" aria-live="polite">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
|
||||
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||
|
||||
@@ -14,10 +14,10 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, plannedDate: '2026-10-21', isDefault: true, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||
}
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
@@ -34,7 +34,7 @@ export function renderSchedule() {
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
@@ -110,24 +110,29 @@ describe('simplified appointment entry', () => {
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('releases an unknown save and sends the edited date with a fresh key', async () => {
|
||||
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-23T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
const calls = vi.mocked(saveWarehouseArrival).mock.calls
|
||||
expect(calls[1]![2]).not.toBe(calls[0]![2]); expect(calls[1]![1].estimatedWarehouseArrivalAt).toBe('2026-10-23T09:30:00')
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false); wrapper.unmount()
|
||||
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('提交结果尚未确认')
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('allows editing after a conflict and preserves input across an optional manual refresh', async () => {
|
||||
it('requires an explicit reread after a conflict and retains the entered date', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
@@ -156,7 +161,7 @@ describe('simplified appointment entry', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains a complete dispatch with its container search fields without showing an unresolved dispatch notice', async () => {
|
||||
it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', async () => {
|
||||
const good = dispatchEntry()
|
||||
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
|
||||
@@ -168,7 +173,11 @@ describe('simplified appointment entry', () => {
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||
expect(panel.props('incomplete')).toBe(true)
|
||||
expect(wrapper.find('.data-notice').exists()).toBe(false)
|
||||
const notice = wrapper.get('.data-notice').text()
|
||||
expect(notice).toContain('1 条排车未能定位到日历日期')
|
||||
expect(notice).toContain('PC-TIMEZONE-MISSING:起运仓时区待核实')
|
||||
expect(notice).toContain('无法确定当地日期')
|
||||
expect(notice).not.toContain('PC-CALENDAR-001')
|
||||
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
|
||||
expect(listTripCalendar).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -16,7 +16,7 @@ import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||
import { listTripCalendar, projectTripCalendar, tripIssues } from './tripCalendar'
|
||||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import type { WorkAction } from './workApi'
|
||||
@@ -37,8 +37,7 @@ const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mo
|
||||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||||
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||||
const hasUnresolvedTrips = ref(false)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length || hasUnresolvedTrips.value)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
|
||||
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||
let generation = 0, controller: AbortController | null = null
|
||||
@@ -47,7 +46,6 @@ async function load(next = range.value) {
|
||||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||||
hasUnresolvedTrips.value = false
|
||||
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
@@ -73,7 +71,10 @@ async function load(next = range.value) {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
hasUnresolvedTrips.value = result.unresolved.length > 0
|
||||
if (result.unresolved.length) {
|
||||
const details = result.unresolved.slice(0, 3).map(row => `${row.dispatchNo?.trim() || '排车单号缺失'}:${tripIssues(row) || '资料待核实'}`).join(';')
|
||||
notices.value.push(`${result.unresolved.length} 条排车未能定位到日历日期(${details}${result.unresolved.length > 3 ? ';其余请到排车列表核对' : ''})。`)
|
||||
}
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
@@ -87,27 +88,30 @@ async function openSource(event: ScheduleEvent) {
|
||||
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||||
}
|
||||
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||||
const saveError = ref<ApiErrorDetails | null>(null), conflict = ref(false)
|
||||
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
|
||||
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
|
||||
function edit(event: ScheduleEvent) {
|
||||
if (!event.canEdit || !event.warehouse) return
|
||||
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||||
saveError.value = null; conflict.value = false
|
||||
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
|
||||
}
|
||||
async function save() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row || saving.value || !plannedAt.value) return
|
||||
const request = { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
if (!row || saving.value || conflict.value || !plannedAt.value) return
|
||||
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveWarehouseArrival(request.id, request.body, request.key)
|
||||
editor.value = null
|
||||
await saveWarehouseArrival(pending.id, pending.body, pending.key)
|
||||
pending = null; unknownResult.value = false; editor.value = null
|
||||
ElMessage.success('计划到仓已保存')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||||
unknownResult.value = !rejectedCommand(cause)
|
||||
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||||
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||||
if (!unknownResult.value) pending = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function rereadEditor() {
|
||||
@@ -125,7 +129,7 @@ async function rereadEditor() {
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||
async function canLeave() {
|
||||
if (saving.value) return false
|
||||
if (saving.value || unknownResult.value) return false
|
||||
for (const guard of guards) if (!await guard()) return false
|
||||
if (editor.value && plannedAt.value !== originalAt.value) {
|
||||
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
|
||||
@@ -176,17 +180,18 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt" :before-close="closeEditor" confirm-text="保存" @confirm="save">
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<div v-if="editor" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||||
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||||
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
|
||||
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving || unknownResult">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +82,9 @@ async function saveArrangement() {
|
||||
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
|
||||
} catch (cause) {
|
||||
createError.value = getApiErrorDetails(cause, '保存失败,请重新操作')
|
||||
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
|
||||
} finally { pendingCreate.value = null; creating.value = false }
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
let followupRequest = 0
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
@@ -202,7 +202,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
|
||||
</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="'保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanning
|
||||
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 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, effectiveShipmentReferences: [], availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
|
||||
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
|
||||
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
|
||||
function render(active = true, disabled = false) {
|
||||
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
|
||||
@@ -52,10 +52,8 @@ it('leaves pending arrangements lazy and reads only when formal confirmation is
|
||||
})
|
||||
it('does not ask for known materials to be entered again', async () => {
|
||||
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
|
||||
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||
expect(wrapper.text()).toContain('FBA-ORIGINAL、TRACK-ORIGINAL')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
|
||||
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -63,17 +63,16 @@ watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) =
|
||||
onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||
</script>
|
||||
<template>
|
||||
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||
<section v-show="active || editing" class="density-dialog-section" data-tone="source" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
|
||||
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
|
||||
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
|
||||
<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>FBA 货件号 / 追踪号</th><th>需要补充的资料</th></tr></thead><tbody>
|
||||
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody>
|
||||
<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>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
|
||||
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
|
||||
<td>{{ row.cargo.effectiveShipmentReferences.join('、') || '—' }}</td>
|
||||
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
@@ -81,6 +80,6 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
|
||||
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
|
||||
</div>
|
||||
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。</p>
|
||||
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -30,12 +30,6 @@ async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const fields = wrapper.findComponent({ name: 'FbaAppointmentFields' })
|
||||
if (fields.exists()) {
|
||||
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-25T09:30:00' })
|
||||
fields.vm.$emit('confirmationReady', true)
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); guards.clear()
|
||||
|
||||
@@ -37,12 +37,12 @@ let generation = 0, selectionGeneration = 0, controller: AbortController | null
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility', afterColumn: 'inboundCode' },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ 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 reason(row: PlanningCargo) {
|
||||
@@ -131,8 +131,8 @@ 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.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</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 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">对方确认开始时间</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><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
|
||||
|
||||
@@ -78,17 +78,24 @@ describe('appointment impact handling', () => {
|
||||
expect(wrapper.text()).toContain('认领跟进')
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('unlocks close and navigation after uncertainty and permits a fresh click', async () => {
|
||||
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
|
||||
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(handleImpact).mock.calls[0]![1]
|
||||
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
|
||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
|
||||
const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(false)
|
||||
await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
||||
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
|
||||
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(getImpact).toHaveBeenCalledTimes(2)
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(true)
|
||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
const done = vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).toHaveBeenCalledOnce()
|
||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(handleImpact).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('does not resolve without an actual handling result', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
|
||||
@@ -26,8 +26,8 @@ async function save(action: ImpactCommand['action']) {
|
||||
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
|
||||
saving.value = true; error.value = null
|
||||
try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '操作失败,请重新操作'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
||||
finally { pending.value = null; saving.value = false }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
@@ -65,7 +65,7 @@ onMounted(load)
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('CLAIM')">认领跟进</el-button>
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('RECORD')">记录处理</el-button>
|
||||
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
|
||||
|
||||
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
|
||||
</div>
|
||||
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCa
|
||||
const source = { sourceKey: 'ORDER:9007199254740993', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }
|
||||
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: [] },
|
||||
verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, effectiveShipmentReferences: [], availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [],
|
||||
verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, 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 } } }) }
|
||||
beforeEach(() => {
|
||||
@@ -26,15 +26,6 @@ it('saves independent cargo verification before any appointment exists, preservi
|
||||
expect(vi.mocked(saveCargoVerification).mock.calls[0]![1]).not.toHaveProperty('appointmentStatus')
|
||||
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 () => {
|
||||
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('network lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
|
||||
@@ -27,7 +27,7 @@ async function load(preserve = false) {
|
||||
context.value = await getCargoVerification(props.source)
|
||||
if (!preserve) {
|
||||
const record = context.value.verification
|
||||
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: context.value.effectiveShipmentReferences.join('\n'), remark: record?.remark || '' })
|
||||
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
|
||||
baseline.value = JSON.stringify(form)
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
|
||||
|
||||
@@ -49,16 +49,14 @@ it('does not edit or cancel replaced snapshots and offers the stable batch conte
|
||||
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount()
|
||||
})
|
||||
const cancelled: FbaAppointmentDto = { ...detail, version: 4, appointmentStatus: 'CANCELLED', confirmationNo: null, plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, appointmentStartUtc: null, appointmentEndUtc: null }
|
||||
it('rebooks the original cargo using a new confirmed appointment without reallocating cargo', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...detail, version: 5 })
|
||||
it('rebooks the cancelled original batch as undated pending without creating or reallocating cargo', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('货物分配仍保留')
|
||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
||||
expect(model.appointmentStatus).toBe('SUCCESS'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
|
||||
Object.assign(model, { confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
|
||||
wrapper.getComponent({ name: 'FbaAppointmentFields' }).vm.$emit('confirmationReady', true)
|
||||
expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'SUCCESS', plannedDate: null, confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00', appointmentEndAt: null }) }))
|
||||
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: null, confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
|
||||
const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
|
||||
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
|
||||
@@ -30,7 +30,7 @@ async function load(preserve = false) {
|
||||
try {
|
||||
detail.value = await getFbaAppointment(props.appointmentNo)
|
||||
if (!preserve || !initialized) {
|
||||
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'SUCCESS'
|
||||
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
|
||||
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,29 +18,22 @@ beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPla
|
||||
it('shows readable preview requirements without exposing missing-field machine codes', async () => {
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('货物备妥窗口待核实')
|
||||
expect(wrapper.text()).toContain('货物备妥窗口待核实')
|
||||
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
|
||||
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
|
||||
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
|
||||
wrapper.unmount()
|
||||
})
|
||||
async function confirmRegistration(wrapper: ReturnType<typeof render>) {
|
||||
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
|
||||
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED' })
|
||||
fields.vm.$emit('confirmationReady', true)
|
||||
await flushPromises()
|
||||
}
|
||||
it('previews and creates the exact canonical external source and customer with real confirmation', async () => {
|
||||
it('previews and creates the exact canonical external source and customer without inventing times', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await confirmRegistration(wrapper)
|
||||
expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
|
||||
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, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: 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, appointmentStatus: 'PENDING' }) }))
|
||||
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 () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render(); await flushPromises(); await confirmRegistration(wrapper); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
const request = vi.mocked(createSourceAppointment).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
@@ -49,7 +42,6 @@ it('retains the same immutable request after an unknown outcome and blocks leavi
|
||||
it('refreshes qualification after conflict while keeping user-entered dates', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16')
|
||||
await confirmRegistration(wrapper)
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledTimes(2)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16')
|
||||
|
||||
@@ -19,7 +19,6 @@ const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(fo
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<SourceAppointmentPayload | null>(null)
|
||||
const writable = computed(() => props.canWrite && access.can(overseasWarehousePermissions.inboundOrderAppointment))
|
||||
const visibleMissingFields = computed(() => preview.value?.missingFields.filter(field => field !== 'READY_WINDOW_UNVERIFIED') || [])
|
||||
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||
async function load() {
|
||||
if (!props.sources.length || props.sources.some(source => !source.customerId || !source.sourceKey || !source.destinationKey)) { error.value = { message: '缺少完整货源、客户或目的仓范围,无法新建预约。' }; return }
|
||||
@@ -61,9 +60,10 @@ onMounted(load)
|
||||
<tr><th scope="row">票数</th><td>{{ preview.group.sourceCount }}</td><th scope="row">箱数</th><td>{{ preview.group.packages ?? '箱数待核实' }}</td></tr>
|
||||
<tr><th scope="row">货物业务号</th><td colspan="3"><template v-for="(source, index) in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ index ? '、' : '' }}{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</template></td></tr>
|
||||
</tbody></table>
|
||||
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
|
||||
</section>
|
||||
<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" />
|
||||
<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" />
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import { createDispatch, getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
@@ -12,7 +12,7 @@ const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), createDispatch: vi.fn(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const batch = 'FBA_APPOINTMENT:1'
|
||||
@@ -21,7 +21,7 @@ function row(id = '9007199254740993', customerId = 'customer-a', key = batch): B
|
||||
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
|
||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||
return { publishLooseboardDemand: false, dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], exceptions: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
}
|
||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||
@@ -33,7 +33,7 @@ it.each([0, '0', '9007199254740993'])('preserves authoritative allocation versio
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations[0]!.recordVersion).toBe(recordVersion)
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
|
||||
@@ -49,7 +49,6 @@ beforeEach(() => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
||||
vi.mocked(createDispatch).mockImplementation(async body => proof(body.idempotencyKey))
|
||||
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
|
||||
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
|
||||
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
|
||||
@@ -65,8 +64,8 @@ it('creates the whole dispatch when native Long candidate quantities arrive as s
|
||||
try {
|
||||
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatch).toHaveBeenCalledOnce(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations)
|
||||
expect(vi.mocked(createDispatch).mock.calls[0]![0].cargo[0]!.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
|
||||
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
} finally { wrapper.unmount(); get.mockRestore() }
|
||||
})
|
||||
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
|
||||
@@ -75,7 +74,7 @@ it('restores existing batch dispatches after remount and navigates to the same w
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
|
||||
}
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(createDispatch).not.toHaveBeenCalled()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
|
||||
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
|
||||
@@ -85,52 +84,71 @@ it('does not fall back to new creation when transport relations fail or their ne
|
||||
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(createDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('creates the whole dispatch with native source versions and exact batch allocations', async () => {
|
||||
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ publishLooseboardDemand: false, warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
|
||||
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
|
||||
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
|
||||
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
|
||||
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
|
||||
})
|
||||
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
|
||||
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = [{ dispatch: vi.mocked(createDispatch).mock.calls[0]![0] }]
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
|
||||
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
|
||||
})
|
||||
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
|
||||
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
|
||||
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
|
||||
})
|
||||
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
|
||||
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
|
||||
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
})
|
||||
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
|
||||
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(createDispatch).not.toHaveBeenCalled(); cross.unmount()
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
|
||||
})
|
||||
|
||||
|
||||
it('shows the confirmed dispatch link even when its detail read fails', async () => {
|
||||
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
|
||||
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
|
||||
expect(await guard()).toBe(false); wrapper.unmount()
|
||||
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
|
||||
})
|
||||
it('retries the exact container command after a lost response instead of generating another container key', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatch).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); expect(await guard()).toBe(true); wrapper.unmount()
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
|
||||
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
|
||||
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
|
||||
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
|
||||
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
|
||||
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
|
||||
})
|
||||
|
||||
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
|
||||
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
|
||||
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(createDispatch).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
|
||||
})
|
||||
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
|
||||
@@ -140,7 +158,7 @@ it('does not read scoped cargo without original permissions or create while only
|
||||
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
|
||||
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(createDispatch).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
})
|
||||
|
||||
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
|
||||
@@ -158,8 +176,8 @@ it('keeps original batch allocations and selected cargo while paging through ass
|
||||
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...first.allocations, ...second.allocations])
|
||||
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
|
||||
@@ -177,13 +195,3 @@ it('rejects a selected batch source whose group version changed on another candi
|
||||
expect(createDispatchDraft).not.toHaveBeenCalled()
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('failure preserves selection, releases navigation and creates a fresh request on the next click', async () => {
|
||||
vi.mocked(createDispatch).mockRejectedValueOnce(new Error('timeout'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(createDispatch).mock.calls[0]![0]
|
||||
expect(wrapper.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(await guard()).toBe(true); expect(sessionStorage.length).toBe(0)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createDispatch).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey); expect(getReceivingContext).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, dispatchVehicleSpecs, DispatchInputError, requireBatchVersions, expandSelectionGroups, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchDetail, type Candidate } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -17,8 +17,11 @@ import { useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
||||
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
|
||||
type ReadyRow = BatchRow & { native: Candidate }
|
||||
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
|
||||
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort())
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
||||
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||||
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
|
||||
@@ -27,24 +30,26 @@ const loading = computed(() => working.value || candidatePage.loading.value)
|
||||
const selectedRows = ref<Record<string, BatchRow>>({})
|
||||
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
|
||||
let nativeGeneration = 0
|
||||
const createdNo = ref('')
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null)
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
|
||||
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
||||
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && (!existing.value.length || existingReady.value))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
||||
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
|
||||
const unresolved = computed(() => saving.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !createdNo.value), unresolved)
|
||||
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
let generation = 0
|
||||
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
|
||||
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||
function reasons(row: BatchRow) { return row.blockReasons }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
async function load() {
|
||||
if (!readable.value) return
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
||||
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
|
||||
try {
|
||||
@@ -117,24 +122,105 @@ function select(row: BatchRow, event: Event) {
|
||||
}
|
||||
input.checked = selected.value.includes(row.key)
|
||||
}
|
||||
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
|
||||
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
|
||||
async function executeStep() {
|
||||
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
|
||||
command.attempted = true; persist()
|
||||
try {
|
||||
let value: DispatchDraft | ConvertedDraft
|
||||
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
|
||||
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
|
||||
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
|
||||
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
|
||||
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
|
||||
ensureOwner()
|
||||
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
|
||||
if (command.action === 'CONVERT') {
|
||||
const converted = value as ConvertedDraft
|
||||
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
|
||||
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null })
|
||||
request.converted = converted
|
||||
} else {
|
||||
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
||||
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
|
||||
if (command.action === 'CONTAINER') {
|
||||
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
|
||||
request.containerId = draft.containers[0]!.draftContainerId
|
||||
}
|
||||
if (command.action === 'LOCK') {
|
||||
const selectedSource = command.body.source as DraftSelection['source']
|
||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||
const group = requireSelectionGroup(source)
|
||||
assertSelectionGroupVersions([expected!.native], [source])
|
||||
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
||||
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
||||
}
|
||||
request.draft = draft
|
||||
}
|
||||
request.step = null; persist()
|
||||
} catch (cause) {
|
||||
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
|
||||
persist(); throw cause
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || loading.value || !canCreate.value) return
|
||||
saveError.value = null; saving.value = true
|
||||
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
|
||||
saveError.value = null
|
||||
try {
|
||||
ensureOwner()
|
||||
if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
|
||||
const body = { publishLooseboardDemand: false, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
|
||||
const proof = await createDispatch(body); ensureOwner()
|
||||
validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } })
|
||||
createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated')
|
||||
const latest = await getDispatch(proof.dispatchNo); ensureOwner(); validateCurrentDispatch(latest, proof)
|
||||
result.value = latest; selected.value = []; baseline.value = '[]'
|
||||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`)
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车创建失败,请刷新核对当前货物后重新操作') }
|
||||
if (!pending.value) {
|
||||
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
|
||||
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
|
||||
}
|
||||
saving.value = true
|
||||
const request = pending.value
|
||||
while (!request.converted) {
|
||||
if (!request.step) {
|
||||
if (request.releasing) { step('RELEASE', common(request.draft!)) }
|
||||
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
|
||||
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
|
||||
else {
|
||||
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
|
||||
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
|
||||
else {
|
||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
await executeStep()
|
||||
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
|
||||
}
|
||||
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
|
||||
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
|
||||
ensureOwner(); validateCurrentDispatch(latest, proof)
|
||||
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
|
||||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { createdNo.value = ''; result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
async function releaseRejected() {
|
||||
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
ensureOwner(); const request = pending.value
|
||||
if (!request.draft) { pending.value = null; persist(); return }
|
||||
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
|
||||
ensureOwner()
|
||||
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
|
||||
request.draft = owned; request.releasing = true; request.step = null; persist()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
|
||||
finally { saving.value = false }
|
||||
if (pending.value?.releasing) await save()
|
||||
else if (!pending.value) await load()
|
||||
}
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
|
||||
</script>
|
||||
@@ -144,12 +230,12 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||||
<template v-if="existing.length && !loading && !error && !createdNo">
|
||||
<template v-if="existing.length && !loading && !error && !result && !pending">
|
||||
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
|
||||
</template>
|
||||
<template v-if="createdNo"><p>已创建整车排车单:{{ createdNo }}</p><el-button @click="emit('external', detailPath(createdNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result?.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="createdNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||||
<template v-else-if="readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||||
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
||||
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
||||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved" class="workbench-input"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
@@ -158,6 +244,7 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
|
||||
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
|
||||
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||||
</template>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -55,7 +55,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<WorkSharedTripForm v-if="addingNo" :key="addingNo" :dispatch-no="addingNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="updated" @external="value => emit('external', value)" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -24,16 +24,16 @@ it('opens a real plan in the exact customer scope while leaving missing plans un
|
||||
it('does not read the container source without its original permission', async () => {
|
||||
state.canRead = false; const wrapper = render(); await flushPromises(); expect(getContainerPlanProgress).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('submits the actual preview snapshot and permits a fresh click after an unknown result', async () => {
|
||||
it('submits the actual preview snapshot and replays the same request after an unknown result', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview())
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new Error('lost response')).mockResolvedValueOnce([])
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0]!
|
||||
expect(original).toEqual([{ sourceContainerBookingId: '9007199254740993', snapshotVersion: 4, snapshotHash: 'snapshot-4' }, expect.any(String)])
|
||||
expect(await guard()).toBe(true)
|
||||
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![0]).toEqual(original[0]); expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![1]).not.toBe(original[1])
|
||||
expect(await guard()).toBe(false)
|
||||
await button(wrapper, '重试本次计划生成').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]).toEqual(original)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('uses the original resource recovery snapshot and key instead of creating new plans', async () => {
|
||||
|
||||
@@ -35,9 +35,9 @@ async function createPlans() {
|
||||
await convertLastMileContainerToInboundPlans(pending.value.body, pending.value.key)
|
||||
pending.value = null; preview.value = null; ElMessage.success('本柜入库计划已确认,请查看原接仓记录'); emit('updated'); await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '入库计划生成失败,请重新操作')
|
||||
saveError.value = getApiErrorDetails(cause, '入库计划生成结果待确认,请重试原请求')
|
||||
if (rejectedCommand(cause)) { pending.value = null; preview.value = null }
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function load() { if (!readable.value) return; loading.value = true; error.value = null; try { detail.value = await getContainerPlanProgress(props.containerBookingId) } catch (cause) { error.value = getApiErrorDetails(cause, '本柜入仓计划读取失败') } finally { loading.value = false } }
|
||||
onMounted(load)
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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">{{ pending ? '重试本次计划生成' : preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
|
||||
<LoadErrorState v-if="saveError" title="入库计划尚未确认" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -236,7 +236,7 @@ function mixedPlans(): api.ReceivingContext {
|
||||
c.agreements = [{ ...agreement(), members: [members()[0]!] }]
|
||||
c.batch.items.push({ ...c.batch.items[1]!, id: 'item-d', customerId: 'customer-d', sourceKey: 'ORDER:D', inboundCode: 'IN-D' })
|
||||
c.executionScope.members.push({ member: { batchKey: c.batch.batchKey!, customerId: 'customer-d' }, completed: false, canRebook: true, reason: null })
|
||||
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING', plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
|
||||
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
|
||||
return c
|
||||
}
|
||||
async function enterBPlan(wrapper: ReturnType<typeof render>) {
|
||||
@@ -373,7 +373,7 @@ it('inherits unconfirmed legacy header plans only without active independent cov
|
||||
it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => {
|
||||
for (const ownPlan of [false, true]) {
|
||||
const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' })
|
||||
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING' }) }]
|
||||
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload(createAppointmentForm()) }]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
|
||||
@@ -452,7 +452,7 @@ it('shows authoritative subset blockers and ignores a late response for a previo
|
||||
})
|
||||
|
||||
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, effectiveShipmentReferences: ['FBA-NEW-B'], 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, availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
|
||||
}
|
||||
async function enterRefresh(wrapper: ReturnType<typeof render>) {
|
||||
await flushPromises(); await wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')[1]!.trigger('click'); await flushPromises()
|
||||
|
||||
@@ -143,7 +143,7 @@ async function openPlan(id: string) {
|
||||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
|
||||
const saved = customerPlan(id)
|
||||
const header = context.value!.batch
|
||||
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : { ...createAppointmentForm(), appointmentStatus: 'PENDING' as const }
|
||||
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : createAppointmentForm()
|
||||
Object.assign(planForm, createAppointmentForm(), saved ? Object.fromEntries(Object.entries(saved.details).map(([key, value]) => [key, value ?? ''])) : { ...inherited, confirmationNo: '', appointmentAt: '', appointmentEndAt: '', startOffset: '', endOffset: '' })
|
||||
baseline.value = state()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
@@ -25,14 +25,11 @@ it('honors native stage/gate and permissions and rejects a different returned bu
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount()
|
||||
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
|
||||
})
|
||||
it('ignores an old add command without blocking the calendar or another action',async()=>{
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,body:{idempotencyKey:'old'}}))
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
|
||||
expect(await guard()).toBe(true)
|
||||
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
wrapper.unmount()
|
||||
it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(await guard()).toBe(false)
|
||||
await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
expect(recovery.pending.value?.body.idempotencyKey).toBe('original-key'); wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('update:modelValue', false); await flushPromises(); expect(await guard()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('retries lookup as a read and keeps the original detail navigation', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
@@ -53,12 +50,22 @@ it.each([false, true])('refreshes the original add-cargo gate after a rejection
|
||||
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not offer a stored command replay when opening the form again',async()=>{
|
||||
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,result:{recordVersion:1}}))
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('刷新加货资料')
|
||||
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
|
||||
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id)
|
||||
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
|
||||
recovery.pending.value!.attempted = true
|
||||
const completed: CommandResult = {
|
||||
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
|
||||
occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 },
|
||||
replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] },
|
||||
}
|
||||
recovery.pending.value!.result = completed
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
await button(wrapper, '刷新加货资料').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
||||
|
||||
@@ -31,6 +31,9 @@ async function load() {
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
|
||||
function recover() {
|
||||
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
|
||||
}
|
||||
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
|
||||
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
|
||||
watch(() => props.dispatchNo, load, { immediate: true })
|
||||
@@ -44,6 +47,7 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
|
||||
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
||||
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</el-button></p>
|
||||
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
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 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 CargoVerificationContext { source: PlanningSource; verification: CargoVerification | null; allocatedPackages: number; unknownAllocationCount: number; effectivePackages: number | null; availablePackages: number | null; sourceChanged: boolean; allowedActions: string[]; blockReasons: 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 }
|
||||
const path = (key: string) => `/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(key)}/cargo-verification`
|
||||
|
||||
@@ -3,39 +3,27 @@ import { projectContainerSchedule } from './schedule'
|
||||
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
|
||||
const range = { from: '2026-11-01', to: '2026-12-01' }
|
||||
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true, plannedDate: '2026-11-01', isDefault: true }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
describe('four-type schedule projection', () => {
|
||||
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, planCount: 1, isDefault: false, canEdit: false }], range, true)
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
|
||||
})
|
||||
it('adds two local calendar days across months without creating a time', () => {
|
||||
const events = projectContainerSchedule(sources, [warehouse], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '', title: 'D0001 计划到仓' })
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
|
||||
})
|
||||
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
|
||||
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedDate: '2026-11-04' }], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1, isDefault: false }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
|
||||
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
|
||||
})
|
||||
it('keeps manually moved containers visible when their arrival is outside this month', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.date).toBe('2026-11-08')
|
||||
})
|
||||
it('shows a date-only frozen plan as frozen without manufacturing midnight', () => {
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, planCount: 1, isDefault: false, canEdit: false }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ date: '2026-11-01', time: '', subtitle: '已有入库计划,日期已冻结', canEdit: false })
|
||||
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-01' })
|
||||
})
|
||||
it('keeps cross-month frozen date-only cards and their details readable without a port-arrival row', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedDate: '2026-11-08', planCount: 1, isDefault: false, canEdit: false }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ date: '2026-11-08', time: '', title: 'D0001 计划到仓', subtitle: '已有入库计划,日期已冻结' })
|
||||
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-08' })
|
||||
})
|
||||
it('does not replace the planned date with PDA actual arrival', () => {
|
||||
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
|
||||
expect(event.date).toBe('2026-11-05')
|
||||
@@ -43,7 +31,7 @@ describe('four-type schedule projection', () => {
|
||||
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
|
||||
})
|
||||
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, cabinetNo: null, arrivalAt: null }] }, [{ ...warehouse, plannedDate: null }], range, false)
|
||||
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
|
||||
expect(events.map(row => row.date)).toEqual([null, null])
|
||||
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
|
||||
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
|
||||
|
||||
@@ -22,9 +22,8 @@ const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '
|
||||
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
|
||||
const events: ScheduleEvent[] = []
|
||||
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
|
||||
const containerMap = new Map(sources.containers.map(row => [row.containerBookingId, row]))
|
||||
for (const row of sources.containers) {
|
||||
const label = row.cabinetNo || '—'
|
||||
const label = row.containerNo || row.cabinetNo || '—'
|
||||
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
|
||||
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
|
||||
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
|
||||
@@ -37,32 +36,31 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
|
||||
if (warehouses === null) continue
|
||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||
warehouseMap.delete(row.containerBookingId)
|
||||
const projected = projectWarehouseArrival(warehouse)
|
||||
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
|
||||
const planned = projected.date
|
||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
||||
time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||
canEdit: warehouse?.canEdit === true,
|
||||
subtitle: !planned ? '柜内计划时间不一致,待核实' : warehouse && warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : projected.isDefault ? '到港日期+2个自然日(默认)' : '人工维护',
|
||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
||||
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
||||
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
||||
}
|
||||
// 人工改期可能跨月,与当前月份的到港记录无关。
|
||||
for (const row of warehouseMap.values()) {
|
||||
const projected = projectWarehouseArrival(row)
|
||||
const date = projected.date
|
||||
const date = row.plannedAt?.slice(0, 10) || null
|
||||
if (!date || !inRange(date, range)) continue
|
||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: projected.time,
|
||||
title: `${row.cabinetNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
|
||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
|
||||
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
|
||||
{ label: '计划到仓', value: row.plannedAt ? timestamp(row.plannedAt) : date }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
|
||||
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
|
||||
}
|
||||
for (const row of sources.emptyReturns) {
|
||||
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
|
||||
if (!at || !inRange(at.slice(0, 10), range)) continue
|
||||
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
|
||||
title: `${containerMap.get(row.containerBookingId)?.cabinetNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
|
||||
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
|
||||
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
|
||||
|
||||
@@ -35,7 +35,7 @@ async function readSchedule(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe('warehouse calendar response to schedule', () => {
|
||||
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
|
||||
const { warehouses, event } = await readSchedule({ planCount, plannedDate: '2026-10-22', isDefault: true })
|
||||
const { warehouses, event } = await readSchedule({ planCount })
|
||||
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
|
||||
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
|
||||
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface CalendarReturn {
|
||||
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
|
||||
export interface WarehouseArrival {
|
||||
sourceContainerBookingId: string
|
||||
cabinetNo?: string | null
|
||||
containerNo: string | null
|
||||
warehouseName: string | null
|
||||
warehouseCode: string | null
|
||||
@@ -30,8 +29,6 @@ export interface WarehouseArrival {
|
||||
version: number
|
||||
planCount: number
|
||||
canEdit: boolean
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
export interface ScheduleRange { from: string; to: string }
|
||||
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
|
||||
|
||||
@@ -4,16 +4,15 @@ import { http } from '@/shared/api/http'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => {
|
||||
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'] }
|
||||
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] }
|
||||
const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range)
|
||||
expect(projected.events).toHaveLength(1)
|
||||
expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜次号', value: 'D1821、D1822' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' })
|
||||
const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface TripCalendarEntry {
|
||||
sourceType: 'DISPATCH'
|
||||
dispatchNo: string
|
||||
containerNumbers: string[]
|
||||
cabinetNumbers: string[]
|
||||
status: string
|
||||
originWarehouseId: string | null
|
||||
originWarehouseCode: string | null
|
||||
@@ -82,7 +81,6 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
fields: [
|
||||
{ label: '运输状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '关联柜号', value: row.containerNumbers.join('、') },
|
||||
{ label: '关联柜次号', value: row.cabinetNumbers.join('、') },
|
||||
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
|
||||
{ label: '地点时区', value: row.originTimezone },
|
||||
{ label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
|
||||
|
||||
@@ -7,8 +7,6 @@ export interface ContainerArrivalTime {
|
||||
actualWarehouseArrivalAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
|
||||
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, downloadContainerCustomsFinal, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
|
||||
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
|
||||
|
||||
const customs = {
|
||||
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
|
||||
@@ -99,42 +99,7 @@ describe('platform container customs API', () => {
|
||||
|
||||
expect(get.mock.calls).toEqual([
|
||||
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
|
||||
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
|
||||
[`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
|
||||
])
|
||||
})
|
||||
|
||||
it.each([downloadContainerCustomsDocument, downloadContainerCustomsReceipt, downloadContainerCustomsFinal])('拒绝 HTTP 200 中的真实 JSON Blob 业务错误 %#', async (download) => {
|
||||
const body = new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })], { type: 'application/json;charset=UTF-8' })
|
||||
vi.spyOn(http, 'get').mockResolvedValue(body)
|
||||
|
||||
const error: unknown = await download('2092438843630391407').catch((reason: unknown) => reason)
|
||||
expect(error).toBeInstanceOf(ApiResponseError)
|
||||
expect(error).toMatchObject({ code: 'A0404', message: '预录单不存在', status: 200, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })
|
||||
})
|
||||
|
||||
it('非 2xx 的 JSON Blob 也保留业务错误和诊断信息', async () => {
|
||||
const body = new Blob([JSON.stringify({ code: 'A0403', message: '无权下载预录单', data: null, requestId: 'request-forbidden', traceId: 'trace-forbidden' })], { type: 'application/json' })
|
||||
vi.spyOn(http, 'get').mockRejectedValue({ isAxiosError: true, response: { status: 403, data: body } })
|
||||
|
||||
await expect(downloadContainerCustomsReceipt('7')).rejects.toMatchObject({ code: 'A0403', message: '无权下载预录单', status: 403, requestId: 'request-forbidden', traceId: 'trace-forbidden' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['application/pdf', '%PDF-1.7 原文件'],
|
||||
['application/octet-stream', '原始二进制'],
|
||||
['application/json', '{"product":"报关资料","code":"ITEM-1"}'],
|
||||
['application/json', '{invalid-json'],
|
||||
])('保留原文件内容与对象:%s', async (type, contents) => {
|
||||
const body = new Blob([contents], { type })
|
||||
vi.spyOn(http, 'get').mockResolvedValue(body)
|
||||
|
||||
await expect(downloadContainerCustomsFinal('7')).resolves.toBe(body)
|
||||
})
|
||||
|
||||
it('非 JSON 的传输错误沿用原始错误', async () => {
|
||||
const error = { isAxiosError: true, response: { status: 502, data: new Blob(['bad gateway'], { type: 'text/html' }) } }
|
||||
vi.spyOn(http, 'get').mockRejectedValue(error)
|
||||
|
||||
await expect(downloadContainerCustomsReceipt('7')).rejects.toBe(error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
|
||||
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
|
||||
@@ -211,45 +210,16 @@ export async function getContainerCustoms(loadingPlanId: string) {
|
||||
return value ? normalize(value) : null
|
||||
}
|
||||
|
||||
// 报关资料允许下载原始 JSON 文件,仅把统一业务错误信封识别为失败。
|
||||
async function customsDownloadError(blob: Blob, status: number): Promise<ApiResponseError | null> {
|
||||
if (!blob.type.toLowerCase().includes('json')) return null
|
||||
let payload: unknown
|
||||
try { payload = JSON.parse(await blob.text()) } catch { return null }
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
|
||||
const envelope = payload as Record<string, unknown>
|
||||
if (typeof envelope.code !== 'string' || envelope.code === '200'
|
||||
|| typeof envelope.message !== 'string' || !('data' in envelope)) return null
|
||||
return new ApiResponseError(envelope.message, envelope.code, status, envelope.data,
|
||||
typeof envelope.requestId === 'string' ? envelope.requestId : undefined,
|
||||
typeof envelope.traceId === 'string' ? envelope.traceId : undefined)
|
||||
}
|
||||
|
||||
async function downloadCustomsFile(path: string) {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(path, { responseType: 'blob' })
|
||||
const error = await customsDownloadError(blob, 200)
|
||||
if (error) throw error
|
||||
return blob
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const downloadError = await customsDownloadError(error.response.data, error.response.status)
|
||||
if (downloadError) throw downloadError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadContainerCustomsDocument(documentId: string) {
|
||||
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/download`)
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export function downloadContainerCustomsReceipt(documentId: string) {
|
||||
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`)
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export function downloadContainerCustomsFinal(documentId: string) {
|
||||
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`)
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
@@ -94,8 +94,6 @@ describe('ContainerCustomsPanel', () => {
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('ENTRY-001')
|
||||
expect(wrapper.text()).not.toContain('版本')
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
|
||||
@@ -116,12 +114,12 @@ describe('ContainerCustomsPanel', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => {
|
||||
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||
const wrapper = await render('READY_TO_START')
|
||||
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
|
||||
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(api.start).not.toHaveBeenCalled()
|
||||
confirm.mockRestore()
|
||||
wrapper.unmount()
|
||||
@@ -166,14 +164,15 @@ describe('ContainerCustomsPanel', () => {
|
||||
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
|
||||
const wrapper = await render('READY_TO_START')
|
||||
expect(wrapper.text()).toContain('开始报关')
|
||||
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
|
||||
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
expect(wrapper.text()).toContain('客户退税资料')
|
||||
expect(wrapper.text()).toContain('预录单')
|
||||
expect(wrapper.text()).toContain('报关行回执')
|
||||
expect(wrapper.text()).not.toContain('客户报关资料')
|
||||
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||
})
|
||||
|
||||
@@ -206,22 +205,22 @@ describe('ContainerCustomsPanel', () => {
|
||||
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
|
||||
let wrapper = await render('DECLARING', true, false)
|
||||
expect(wrapper.text()).toContain('首次发送报关行')
|
||||
expect(wrapper.text()).toContain('首次发送后上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
expect(wrapper.text()).toContain('首次发送后上传回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
wrapper.unmount()
|
||||
|
||||
wrapper = await render('DECLARING', true, true)
|
||||
expect(wrapper.text()).toContain('发送报关行执行')
|
||||
expect(wrapper.text()).toContain('覆盖预录单')
|
||||
expect(wrapper.text()).toContain('覆盖报关行回执')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
||||
expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
wrapper.unmount()
|
||||
|
||||
wrapper = await render('DECLARING', false, true)
|
||||
expect(wrapper.text()).toContain('只读')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||
})
|
||||
|
||||
@@ -237,45 +236,14 @@ describe('ContainerCustomsPanel', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('隐藏预录单历史和资料版本,保留当前确认状态、原文件名和上传并发版本', async () => {
|
||||
const value = customs('DECLARING', true)
|
||||
const document = value.documents[0]!
|
||||
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' }]
|
||||
api.get.mockResolvedValue(value)
|
||||
const wrapper = mount(ContainerCustomsPanel, {
|
||||
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '关联入仓码', '报关行状态', '客户退税资料', '预录单', '真实报关完税文件', '客户', '预录单情况'])
|
||||
expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/)
|
||||
expect(wrapper.findAll('.confirmation-history-note, .receipt-history-note')).toHaveLength(0)
|
||||
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)
|
||||
const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' })
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
onChange({ raw: selected })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
|
||||
const wrapper = await render('COMPLETED', true, true)
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
||||
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||
})
|
||||
|
||||
@@ -286,7 +254,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
|
||||
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
|
||||
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
|
||||
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
|
||||
@@ -310,46 +278,4 @@ describe('ContainerCustomsPanel', () => {
|
||||
expect(wrapper.text()).toContain('trace-customs-download')
|
||||
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('下载预录单失败时透传正式名称与诊断信息,保留原文件名', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-pre-entry', traceId: 'trace-pre-entry' })], { type: 'application/json' }))
|
||||
api.downloadReceipt.mockImplementationOnce(async (documentId: string) => {
|
||||
const actual = await vi.importActual<typeof import('../api/platformContainerCustoms')>('../api/platformContainerCustoms')
|
||||
return actual.downloadContainerCustomsReceipt(documentId)
|
||||
})
|
||||
const wrapper = await render('COMPLETED', false, true)
|
||||
|
||||
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('预录单不存在'))
|
||||
expect(wrapper.text()).toContain('request-pre-entry')
|
||||
expect(wrapper.text()).toContain('trace-pre-entry')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).not.toContain('报关行回执不存在')
|
||||
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled()
|
||||
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/download', { responseType: 'blob' })
|
||||
get.mockRestore()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('上传冲突时展示服务端预录单提示并保留待上传文件', async () => {
|
||||
const message = '客户确认的预录单已发送给报关行,不可再修改预录单'
|
||||
api.upload.mockRejectedValueOnce(new ApiResponseError(message, 'A0409', 200, undefined, 'request-pre-entry-upload', 'trace-pre-entry-upload'))
|
||||
const wrapper = await render('DECLARING', true, true)
|
||||
const selected = new File(['receipt'], '预录单.pdf', { type: 'application/pdf' })
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
onChange({ raw: selected })
|
||||
await flushPromises()
|
||||
const submit = wrapper.findAll('button').find(button => button.text() === '提交')!
|
||||
await submit.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(message)
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('request-pre-entry-upload')
|
||||
expect(wrapper.text()).toContain('trace-pre-entry-upload')
|
||||
expect(submit.attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,6 +67,7 @@ function clearanceResultLabel(value: ClearanceResultType) {
|
||||
}
|
||||
|
||||
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
|
||||
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
|
||||
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
|
||||
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
|
||||
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
|
||||
@@ -79,6 +80,12 @@ const statusText: Record<ContainerCustomsStatus, string> = {
|
||||
function documentCustomers(document: ContainerCustomsDocument) {
|
||||
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
|
||||
}
|
||||
function receiptHistoryLabel(document: ContainerCustomsDocument) {
|
||||
return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
|
||||
}
|
||||
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
|
||||
return action === 'CONFIRM' ? '确认' : '打回'
|
||||
}
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
@@ -120,7 +127,7 @@ function declarationStatusLabel(value: DeclarationBatch['status']) {
|
||||
|
||||
async function createFormalDeclaration() {
|
||||
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
|
||||
try { await ElMessageBox.confirm('当前柜的资料、预录单已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
|
||||
@@ -177,7 +184,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
|
||||
async function start() {
|
||||
if (!customs.value || customs.value.status !== 'READY_TO_START') return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认开始整柜报送?', '整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认' })
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
||||
} catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
|
||||
@@ -188,15 +195,15 @@ async function start() {
|
||||
async function uploadReceipt(document: ContainerCustomsDocument) {
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
|
||||
const selected = receiptFiles[document.documentId]
|
||||
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
|
||||
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
|
||||
receiptFiles[document.documentId] = null
|
||||
await load()
|
||||
emit('refreshed')
|
||||
ElMessage.success('预录单已上传,等待客户确认')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') }
|
||||
ElMessage.success('报关行回执已上传,等待客户确认')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -233,9 +240,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
? await downloadContainerCustomsDocument(document.documentId)
|
||||
: kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
|
||||
saveBlob(blob, target.fileName)
|
||||
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载已开始`)
|
||||
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载失败`)
|
||||
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
|
||||
} finally {
|
||||
downloadingKey.value = ''
|
||||
}
|
||||
@@ -243,7 +250,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
|
||||
async function submitToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
|
||||
try { await ElMessageBox.confirm('确认已核对全部预录单并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
|
||||
@@ -252,9 +259,9 @@ async function submitToBroker() {
|
||||
|
||||
async function sendToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回预录单,关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待预录单') }
|
||||
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
@@ -308,23 +315,26 @@ onMounted(load)
|
||||
<template v-else-if="customs">
|
||||
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
|
||||
<template v-else>
|
||||
<dl v-if="brokerSubmission" class="density-fields customs-summary" aria-label="报关概况">
|
||||
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div>
|
||||
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
|
||||
<dl class="density-fields customs-summary" aria-label="报关概况">
|
||||
<div><dt>实际箱数</dt><dd>{{ customs.actualBoxCount }}</dd></div><div><dt>退税总票</dt><dd>{{ customs.taxRefundCount }}</dd></div><div><dt>已覆盖</dt><dd>{{ customs.coveredTaxRefundCount }}</dd></div><div><dt>缺失</dt><dd :class="{ 'coverage-missing': missingCoverageCount }">{{ missingCoverageCount }}</dd></div>
|
||||
<template v-if="brokerSubmission">
|
||||
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
|
||||
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||
<h3>缺退税资料清单</h3>
|
||||
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div v-if="customs.documents.length" class="compact-table-wrap">
|
||||
<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>
|
||||
<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><th>回执情况</th></tr></thead><tbody>
|
||||
<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>{{ document.inboundCodes.join('、') || '—' }}</td>
|
||||
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</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><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.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.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>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><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></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
|
||||
</tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
@@ -333,7 +343,7 @@ onMounted(load)
|
||||
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
|
||||
<div v-else class="declaration-batch-list">
|
||||
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
|
||||
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }}</span></div>
|
||||
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
|
||||
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
|
||||
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="openFormalResultDialog(batch)">标记海关异常/结果</el-button></div>
|
||||
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ clearanceResultLabel(result.resultType) }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
|
||||
@@ -360,5 +370,5 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<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}.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}.coverage-missing{color:var(--el-color-danger)!important}.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)}
|
||||
</style>
|
||||
|
||||
@@ -176,17 +176,17 @@ 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 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="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="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 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="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 prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
<section class="density-dialog-section">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form" label-position="left">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
|
||||
|
||||
@@ -58,10 +58,8 @@ describe('ContainerOverviewPanel', () => {
|
||||
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 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').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.find('.document-table-wrap').exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('业务资料')
|
||||
|
||||
@@ -130,21 +130,21 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
|
||||
<div class="document-table-wrap">
|
||||
<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">
|
||||
<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><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>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</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">
|
||||
<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><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>{{ overview.container.pickupCodeUpdatedByName || '—' }}</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>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -153,7 +153,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
|
||||
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
|
||||
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
|
||||
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div><div><dt>任务明细</dt><dd>{{ overview.pda.itemCount }}</dd></div><div><dt>已处理</dt><dd>{{ overview.pda.completedItemCount }}</dd></div><div><dt>未解决</dt><dd>{{ overview.pda.unresolvedItemCount }}</dd></div><div><dt>有效照片</dt><dd>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -170,9 +170,9 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
.overview-section p { margin: 5px 0; color: var(--workbench-muted); font-size: var(--admin-font-body); }
|
||||
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--workbench-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--workbench-border)}.exception-history li time,.exception-history li span{color:var(--workbench-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)!important}
|
||||
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--workbench-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--workbench-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--workbench-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--workbench-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
|
||||
.overview-panel .pda-summary { grid-template-columns: minmax(0, 1fr); margin-bottom: var(--admin-space-4); }
|
||||
.overview-panel .pda-summary { grid-template-columns: minmax(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
|
||||
.pda-progress dd { width: 100%; }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--workbench-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;height:112px;object-fit:contain;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:grid;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--workbench-muted);text-overflow:ellipsis;white-space:nowrap}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr 1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
|
||||
</style>
|
||||
|
||||
@@ -47,9 +47,6 @@ describe('OceanBookingLinkDialog', () => {
|
||||
|
||||
it.each([
|
||||
[' mscu1234567 ', 'MSCU1234567'],
|
||||
['local-1', 'LOCAL-1'],
|
||||
['MSCU6639871', 'MSCU6639871'],
|
||||
['ABCU123456789012', 'ABCU123456789012'],
|
||||
['', null],
|
||||
])('normalizes and submits the optional real container number %#', async (input, expected) => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
|
||||
@@ -101,9 +101,9 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="modelValue" :title="containerDialogTitle('关联已放舱订舱', container)" fullscreenable width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<section class="density-dialog-section">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form" label-position="left" label-width="160px">
|
||||
<div class="workbench-evidence"><table class="density-table" aria-label="关联订舱资料"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
|
||||
<tr><td><el-form-item label="柜次号"><span>{{ container?.cabinetNo || '—' }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './api/platformContainerLoading'
|
||||
export { listContainerArrivalTimes, type ContainerArrivalTime } from './api/containerArrivalTimes'
|
||||
export * from './types'
|
||||
export { projectWarehouseArrival } from './warehouseArrival'
|
||||
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
||||
|
||||
+8
-64
@@ -1,10 +1,10 @@
|
||||
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect, ElUpload } from 'element-plus'
|
||||
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
import { getContainer, getContainerOverview, linkContainerOceanBooking, getContainerClearance, uploadContainerClearance, uploadContainerDocument, completeContainerClearance } from '../api/platformContainerLoading'
|
||||
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
@@ -59,7 +59,7 @@ function accessibleText(wrapper: { element: Element } | undefined) {
|
||||
describe('ContainerDetailView empty return', () => {
|
||||
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
|
||||
const wrapper = await mountDetail()
|
||||
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
|
||||
expect(value).toBe('2026-10-23(默认推算)')
|
||||
@@ -100,8 +100,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' })
|
||||
const wrapper = await mountDetail()
|
||||
expect(getDirectDeliveries).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
|
||||
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
@@ -124,8 +123,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm')
|
||||
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
const wrapper = await mountDetail()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
|
||||
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
|
||||
@@ -208,20 +206,17 @@ describe('ContainerDetailView empty return', () => {
|
||||
granted.value = true
|
||||
await flushPromises()
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
|
||||
expect((wrapper.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
|
||||
expect(wrapper.text()).toContain('已确认离仓')
|
||||
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
|
||||
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
|
||||
})
|
||||
|
||||
it('reloads authoritative departure state once on each newly opened domestic detail', async () => {
|
||||
const first = await mountDetail('DOMESTIC')
|
||||
expect(first.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
|
||||
expect((first.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
|
||||
expect(first.text()).toContain('已确认离仓')
|
||||
first.unmount()
|
||||
const reopened = await mountDetail('DOMESTIC')
|
||||
expect(reopened.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
|
||||
expect((reopened.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
|
||||
expect(reopened.text()).toContain('已确认离仓')
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -442,54 +437,3 @@ describe('domestic container loading list and booking link', () => {
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('destination document automatic upload', () => {
|
||||
const uploaded = { id: 'doc-1', fileId: 'file-1', fileName: '资料.pdf', version: 1, completedAt: null }
|
||||
beforeEach(() => {
|
||||
canAccess.mockReset().mockReturnValue(true)
|
||||
routeState.query = {}
|
||||
vi.mocked(getContainer).mockReset().mockResolvedValue({ ...base, outboundConfirmedAt: '2026-10-20T08:00:00', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS'] } as never)
|
||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
||||
vi.mocked(getContainerClearance).mockReset().mockResolvedValue(null as never)
|
||||
vi.mocked(uploadContainerDocument).mockReset().mockResolvedValue(uploaded as never)
|
||||
vi.mocked(uploadContainerClearance).mockReset().mockResolvedValue(uploaded as never)
|
||||
vi.mocked(completeContainerClearance).mockReset()
|
||||
})
|
||||
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('saves %s on selection without completing clearance', async type => {
|
||||
const wrapper = await mountDetail()
|
||||
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
|
||||
expect(dialog.props('showConfirm')).toBe(false)
|
||||
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
|
||||
const select = dialog.findComponent(ElUpload).props('onChange')!
|
||||
select({ raw: file } as never, [])
|
||||
select({ raw: file } as never, [])
|
||||
await flushPromises()
|
||||
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
|
||||
expect(upload).toHaveBeenCalledTimes(1)
|
||||
expect(upload).toHaveBeenCalledWith(...(type === 'DELIVERY_ORDER' ? ['9001', type, file, 0, expect.any(String)] : ['9001', file, 0, expect.any(String)]))
|
||||
expect(completeContainerClearance).not.toHaveBeenCalled()
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('retains a failed %s upload and retries the same file', async type => {
|
||||
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
|
||||
vi.mocked(upload).mockRejectedValueOnce(new Error('网络中断'))
|
||||
const wrapper = await mountDetail()
|
||||
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
|
||||
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
|
||||
dialog.findComponent(ElUpload).props('onChange')!({ raw: file } as never, [])
|
||||
await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.text()).toContain('上传失败')
|
||||
await dialog.findAll('button').find(button => button.text() === '重试上传')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(upload).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(upload).mock.calls[1]).toEqual(vi.mocked(upload).mock.calls[0])
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
expect(completeContainerClearance).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,8 +19,10 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
|
||||
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
|
||||
import {
|
||||
confirmContainerOutboundDispatch,
|
||||
getContainerOutboundDispatch,
|
||||
type ContainerOutboundDispatchDetail,
|
||||
type ContainerOutboundGateItem,
|
||||
} from '@/modules/outbound-dispatch'
|
||||
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||||
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
||||
@@ -91,10 +93,12 @@ async function rememberWorkbenchTab(tabName: string | number) {
|
||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||
const arrivalTime = ref<ContainerArrivalTime | null>(null)
|
||||
const expectedArrivalText = computed(() => {
|
||||
const row = workbenchDetail.value
|
||||
const arrival = arrivalTime.value
|
||||
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
|
||||
: projected.date ? `${projected.date}${projected.isDefault ? '(默认推算)' : ''}` : '—'
|
||||
: projected.date ? `${projected.date}(默认推算)` : '—'
|
||||
})
|
||||
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
||||
@@ -242,6 +246,7 @@ async function saveDirect() {
|
||||
}
|
||||
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
|
||||
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||||
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const canViewCurrentLoadingList = computed(() => {
|
||||
@@ -271,7 +276,6 @@ const pickupCodeSaving = ref(false)
|
||||
const clearanceOpen = ref(false)
|
||||
const clearanceDocument = ref<BusinessDocument | null>(null)
|
||||
const clearanceFile = ref<File | null>(null)
|
||||
const clearanceUploadKey = ref(0)
|
||||
const clearanceLoading = ref(false)
|
||||
const clearanceSaving = ref(false)
|
||||
const clearanceError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -280,8 +284,10 @@ const exceptionReason = ref('')
|
||||
const exceptionSaving = ref(false)
|
||||
const exceptionError = ref<ApiErrorDetails | null>(null)
|
||||
const outboundLoading = ref(false)
|
||||
const outboundConfirming = ref(false)
|
||||
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
|
||||
const outboundError = ref<ApiErrorDetails | null>(null)
|
||||
const showPassedGates = ref(false)
|
||||
const carrierRefreshKey = ref(0)
|
||||
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
|
||||
const emptyReturnOpen = ref(false)
|
||||
@@ -316,6 +322,9 @@ const overseasStageText = computed(() => {
|
||||
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
|
||||
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
|
||||
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
|
||||
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
|
||||
? '核对作业证据、报关资料与装柜货物。'
|
||||
: '核对预装货物与 PDA 作业进度。')
|
||||
const overseasDestinationText = computed(() => {
|
||||
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
|
||||
.filter(Boolean).join(' / ')
|
||||
@@ -333,6 +342,32 @@ const emptyReturnWarningText = computed(() => {
|
||||
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
|
||||
})
|
||||
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
|
||||
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
|
||||
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
|
||||
const grouped = new Map<string, AggregatedGateRow>()
|
||||
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
|
||||
for (const item of inbound.gate.items || []) {
|
||||
if (referenceOnlyGateCodes.has(item.code)) continue
|
||||
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
|
||||
const existing = grouped.get(key)
|
||||
if (existing) {
|
||||
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
|
||||
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
|
||||
}
|
||||
}
|
||||
return [...grouped.values()]
|
||||
})
|
||||
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
|
||||
const outboundGateSummary = computed(() => ({
|
||||
total: outboundGateRows.value.length,
|
||||
passed: outboundGateRows.value.filter((row) => row.passed).length,
|
||||
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
|
||||
}))
|
||||
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
|
||||
&& hasAction('CONFIRM_OUTBOUND')
|
||||
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
|
||||
&& outboundGateSummary.value.blocked === 0)
|
||||
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
|
||||
!outboundDetail.value ? 'UNKNOWN'
|
||||
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
|
||||
@@ -439,7 +474,7 @@ watch(
|
||||
|
||||
async function loadOutboundDispatch() {
|
||||
if (!containerId.value || !canViewOutboundDispatch.value) return
|
||||
outboundLoading.value = true; outboundError.value = null
|
||||
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
|
||||
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
|
||||
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
|
||||
finally { outboundLoading.value = false }
|
||||
@@ -458,6 +493,37 @@ async function refreshAfterCustoms() {
|
||||
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
|
||||
}
|
||||
|
||||
async function confirmDeparture() {
|
||||
const aggregate = outboundDetail.value?.aggregate
|
||||
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
||||
outboundConfirming.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
|
||||
} catch { outboundConfirming.value = false; return }
|
||||
outboundError.value = null
|
||||
try {
|
||||
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
|
||||
expectedVersion: aggregate.version,
|
||||
remark: '货柜详情确认国内出仓',
|
||||
})
|
||||
carrierRefreshKey.value += 1
|
||||
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
|
||||
try { overview.value = await getContainerOverview(containerId.value) }
|
||||
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '确认国内出仓失败')
|
||||
if (details.code === 'A0409') await loadOutboundDispatch()
|
||||
outboundError.value = details
|
||||
} finally { outboundConfirming.value = false }
|
||||
}
|
||||
|
||||
function outboundStatusText(status?: string | null) {
|
||||
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
|
||||
}
|
||||
function truckReferenceText(status?: string | null) {
|
||||
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
|
||||
}
|
||||
|
||||
function openDocument(type: string) {
|
||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
|
||||
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
|
||||
@@ -465,14 +531,9 @@ function openDocument(type: string) {
|
||||
documentType.value = type as UploadDocumentType
|
||||
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
|
||||
}
|
||||
function selectDocumentFile(file: UploadFile) {
|
||||
if (documentSaving.value) return
|
||||
documentFile.value = file.raw || null; documentError.value = null
|
||||
if (documentFile.value) void saveDocument()
|
||||
}
|
||||
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
|
||||
function removeDocumentFile() { documentFile.value = null }
|
||||
async function saveDocument() {
|
||||
if (documentSaving.value || !canManageDestinationDocuments.value) return
|
||||
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
|
||||
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
|
||||
try {
|
||||
@@ -545,20 +606,14 @@ async function saveException() {
|
||||
}
|
||||
|
||||
async function openClearance() {
|
||||
clearanceUploadKey.value += 1
|
||||
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
|
||||
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
|
||||
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
|
||||
finally { clearanceLoading.value = false }
|
||||
}
|
||||
function selectClearanceFile(file: UploadFile) {
|
||||
if (clearanceSaving.value || clearanceLoading.value) return
|
||||
clearanceFile.value = file.raw || null; clearanceError.value = null
|
||||
if (clearanceFile.value) void uploadClearance()
|
||||
}
|
||||
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
|
||||
function removeClearanceFile() { clearanceFile.value = null }
|
||||
async function uploadClearance() {
|
||||
if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return
|
||||
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
|
||||
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
|
||||
try {
|
||||
@@ -647,7 +702,7 @@ onMounted(async () => {
|
||||
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-customs-heading">
|
||||
@@ -660,15 +715,31 @@ onMounted(async () => {
|
||||
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-receiving-heading">
|
||||
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
|
||||
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
||||
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
||||
<template v-else-if="outboundDetail">
|
||||
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
|
||||
|
||||
|
||||
<div class="gate-list">
|
||||
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
|
||||
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
|
||||
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="canReadDirect" @click="openDirect">直送卸货清单</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>
|
||||
</section>
|
||||
|
||||
<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><p>{{ loadingListDescription }}</p></div></header>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
|
||||
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||||
@@ -686,24 +757,24 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
|
||||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
|
||||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><div class="workbench-evidence"><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></div></section>
|
||||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||
<section v-if="directSelected" class="density-dialog-section"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<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="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>
|
||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
|
||||
<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" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table density-table--editable 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" />
|
||||
|
||||
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
@@ -713,7 +784,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-detail-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||||
.container-workflow-table { table-layout: fixed; }
|
||||
.direct-remark { white-space: pre-wrap; }
|
||||
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
||||
|
||||
@@ -589,7 +589,7 @@ describe('ContainersView', () => {
|
||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row,
|
||||
estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' },
|
||||
})) })
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
|
||||
const wrapper = await mountOverseasRoute()
|
||||
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
|
||||
expect(picker).toBeDefined()
|
||||
@@ -849,8 +849,8 @@ describe('ContainersView', () => {
|
||||
describe('ContainersView trucking supplier contract', () => {
|
||||
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 fields = ['containerNo', 'planStatus', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'exceptionActive', 'planNo', 'cabinetNo', 'pickupCode']
|
||||
let previous = source.indexOf('const allColumns')
|
||||
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode']
|
||||
let previous = source.indexOf("key: 'actions'")
|
||||
for (const field of fields) {
|
||||
const current = source.indexOf(`key: '${field}'`, previous + 1)
|
||||
expect(current, field).toBeGreaterThan(previous)
|
||||
|
||||
@@ -109,9 +109,9 @@ let emailSequence = 0
|
||||
const emailColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'emailActions' },
|
||||
{ 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: '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: 'failureReason', label: '失败说明', minWidth: 200 },
|
||||
{ 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: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' },
|
||||
{ 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: 'planStatus', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
|
||||
{ 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: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
||||
@@ -286,11 +286,12 @@ function optional(value: string | null) { return value?.trim() || null }
|
||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
|
||||
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
function warehouseArrivalProjection(source: object) {
|
||||
function defaultArrivalDate(source: object) {
|
||||
const row = source as ContainerWorkbenchRow
|
||||
const arrival = arrivalTimes.value[row.id]
|
||||
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected
|
||||
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected.isDefault ? projected.date : null
|
||||
}
|
||||
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
|
||||
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
|
||||
@@ -763,11 +764,11 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
|
||||
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
|
||||
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="warehouseArrivalProjection(row as ContainerWorkbenchRow).date ? `${warehouseArrivalProjection(row as ContainerWorkbenchRow).date}${warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '(默认)' : ''}` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="defaultArrivalDate(row) ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="defaultArrivalDate(row) ? `${defaultArrivalDate(row)}(默认)` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
|
||||
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
|
||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import SmartLoadingDialog from '../components/SmartLoadingDialog.vue'
|
||||
import { loadSmartLoadingPlanCoLoadGroups } from '../smartLoadingPlanGroups'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import {
|
||||
assignPendingReviewSmartLoadingOrder,
|
||||
@@ -211,9 +210,9 @@ describe('GlobalSmartLoadingView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses one-line shared text clamps for block reasons and assigned-count summaries', () => {
|
||||
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />')
|
||||
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
|
||||
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
|
||||
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
|
||||
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
|
||||
@@ -609,16 +608,14 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const candidatePane = wrapper.get('.candidate-pane')
|
||||
expect(candidatePane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆')
|
||||
expect(candidatePane.text()).toContain('NYEXX2608290051、NYEXX2608290051-B、NYEXX2608290052')
|
||||
expect(candidatePane.text()).not.toContain('CLG-2093539779795795969')
|
||||
expect(candidatePane.text()).not.toContain('ORD-201')
|
||||
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
|
||||
expect(wrapper.text()).toContain('3 个入仓片段')
|
||||
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
|
||||
const buttons = wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).not.toContain('不能部分输入、单独分配或单独移除')
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
||||
const contextRows = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||
@@ -626,8 +623,6 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
|
||||
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
||||
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
|
||||
expect(assignmentDialog().text()).toContain('3 个入仓片段')
|
||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -689,7 +684,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('合柜报关 · 3票')
|
||||
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
|
||||
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
@@ -754,36 +749,16 @@ describe('GlobalSmartLoadingView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses one loading-rule column in both workbenches and keeps global list summaries compact', () => {
|
||||
it('uses one loading-rule column and expandable group details in both workbenches', () => {
|
||||
for (const page of ['GlobalSmartLoadingView.vue', 'SmartLoadingWorkbenchView.vue']) {
|
||||
const source = readFileSync(resolve(process.cwd(), `src/modules/container-loading/pages/${page}`), 'utf8')
|
||||
expect(source.match(/label: '装柜规则'/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("label: '分配单位'")
|
||||
expect(source).not.toContain('<template #unitType=')
|
||||
expect(source.match(/<summary>整组加入 · 不可拆<\/summary>/g)).toHaveLength(2)
|
||||
expect(source.match(/'整托加入 · 不可拆' : '按箱加入'/g)).toHaveLength(2)
|
||||
expect(source).toContain("row.memberOrderSummaries.join(';')")
|
||||
}
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source.match(/slotName: 'coLoadGroup', showOverflowTooltip: false/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("row.memberOrderSummaries.join(';')")
|
||||
})
|
||||
|
||||
it('shows compact locked plan groups with all inbound codes and fixed package totals', async () => {
|
||||
const group = { groupId: 'G-1', groupNo: 'CLG-1', lockedPlanId: plan.id, memberOrderCount: 2, inboundFragmentCount: 3, fullPackageCount: 18 }
|
||||
vi.mocked(loadSmartLoadingPlanCoLoadGroups).mockReturnValueOnce(new Map(['2', '3', '4'].map(id => [id, group])))
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
items: ['2', '3', '4'].map((inboundId, index) => ({ ...plan.items[0]!, id: `item-${inboundId}`, inboundId, inboundCode: `IN-${inboundId}`, orderId: index < 2 ? 'order-1' : 'order-2' })),
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const pane = wrapper.get('.plan-pane')
|
||||
expect(pane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆 · 已锁定')
|
||||
expect(pane.text()).toContain('IN-2、IN-3、IN-4')
|
||||
expect(pane.text()).toContain('18 箱(整组固定)')
|
||||
expect(pane.text()).not.toContain('CLG-1')
|
||||
expect(pane.text()).not.toContain('ORD-1')
|
||||
expect(pane.findAll('.el-table__body-wrapper tr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('searches any shared-pallet inbound, retains its complete composition across pages, and resets the search', async () => {
|
||||
|
||||
@@ -157,7 +157,7 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
const planPalletMembersByPalletId = computed(() => new Map(
|
||||
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
|
||||
))
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[] }
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
|
||||
const result: SmartLoadingPlanRow[] = []
|
||||
@@ -165,7 +165,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
for (const row of inboundRows) {
|
||||
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||
if (!group) {
|
||||
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'] })
|
||||
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
continue
|
||||
}
|
||||
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
|
||||
@@ -191,6 +191,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
coLoadGroup: group,
|
||||
palletMembers: [],
|
||||
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
|
||||
memberOrderSummaries: memberOrderSummaries(rows),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -240,13 +241,13 @@ function preferredContainerId() {
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ 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: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 },
|
||||
{ key: 'totalGrossWeightKg', label: '重量', width: 100, slotName: 'candidateTotalWeight' },
|
||||
{ key: 'totalVolumeCbm', label: '体积', width: 100, slotName: 'candidateTotalVolume' },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
|
||||
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
|
||||
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
|
||||
@@ -259,7 +260,7 @@ const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
|
||||
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
]
|
||||
|
||||
function formatNumber(value: number, digits = 3) {
|
||||
@@ -752,16 +753,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
@click="openAssign(row)"
|
||||
>加入</el-button>
|
||||
</template>
|
||||
<template #candidateInbounds="{ row }"><span v-if="row.coLoadGroupId">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }">
|
||||
<div v-if="row.coLoadGroupId" class="candidate-group">
|
||||
<el-tag size="small" type="warning">合柜报关 · {{ row.memberOrderCount }}票</el-tag>
|
||||
<span>整组不可拆{{ row.locked ? ' · 已锁定' : '' }}</span>
|
||||
<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />
|
||||
</div>
|
||||
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
|
||||
</template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroupId" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
|
||||
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
|
||||
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
|
||||
@@ -834,15 +828,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</div>
|
||||
<div class="workbench-table"><DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #planInbounds="{ row }"><span v-if="row.coLoadGroup">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }">
|
||||
<div v-if="row.coLoadGroup" class="candidate-group">
|
||||
<el-tag size="small" type="warning">合柜报关 · {{ row.coLoadGroup.memberOrderCount }}票</el-tag>
|
||||
<span>整组不可拆 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span>
|
||||
</div>
|
||||
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
|
||||
</template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
@@ -864,7 +852,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
|
||||
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
|
||||
@@ -879,11 +867,13 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<section class="density-dialog-section" data-tone="source"><div class="workbench-evidence"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></div></section>
|
||||
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
||||
</template>
|
||||
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -892,7 +882,6 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.smart-loading-assignment-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||||
.assignment-context-table { table-layout: fixed; }
|
||||
.assignment-context-table th { width: 120px; }
|
||||
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).toContain('DP-260908-0001')
|
||||
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).not.toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
||||
|
||||
@@ -157,12 +157,12 @@ const itemColumns: DataTableColumn[] = [
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ 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: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
|
||||
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 90 },
|
||||
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
|
||||
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 120, slotName: 'averageUnitVolume' },
|
||||
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 120, slotName: 'unitGrossWeight' },
|
||||
]
|
||||
@@ -180,6 +180,14 @@ function startQuantityEdit(source: object) {
|
||||
quantityDrafts[item.id] = item.packageCount
|
||||
editingQuantityId.value = item.id
|
||||
}
|
||||
function explainCandidate(source: object) {
|
||||
const candidate = candidateRow(source)
|
||||
const volume = Number(candidate.averageUnitVolumeCbm || 0)
|
||||
const weight = Number(candidate.unitGrossWeightKg || 0)
|
||||
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
|
||||
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
|
||||
return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
|
||||
}
|
||||
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
|
||||
if (!value) return '待生成'
|
||||
const cabinetNo = String(value.cabinetNo || '').trim()
|
||||
@@ -411,7 +419,7 @@ onMounted(load)
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign">
|
||||
<div v-if="selectedCandidate" class="density-dialog-sections">
|
||||
<section class="density-dialog-section">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
|
||||
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
|
||||
@@ -427,10 +435,15 @@ onMounted(load)
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<section class="density-dialog-section" data-tone="source"><div class="workbench-evidence"><table class="density-table" aria-label="同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></div></section>
|
||||
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" />
|
||||
<p class="density-dialog-note">只记录数量,具体上柜箱号未核验。</p>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -7,22 +7,18 @@ describe('shared warehouse arrival projection', () => {
|
||||
['2026-10-30T23:30:00-07:00', '2026-11-01'],
|
||||
['2026-12-31T23:30:00', '2027-01-02'],
|
||||
['2026-03-07T23:30:00-08:00', '2026-03-09'],
|
||||
])('displays the authoritative local date for %s without manufacturing a time', (arrivalAt, date) => {
|
||||
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 0, plannedDate: date, isDefault: true })).toEqual({ date, time: '', isDefault: true })
|
||||
])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => {
|
||||
expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true })
|
||||
})
|
||||
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
|
||||
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
|
||||
expect(projectWarehouseArrival(plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
}
|
||||
})
|
||||
it('does not replace inconsistent or frozen missing dates with defaults', () => {
|
||||
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
|
||||
expect(projectWarehouseArrival(plan).date).toBeNull()
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
|
||||
}
|
||||
expect(projectWarehouseArrival(undefined).date).toBeNull()
|
||||
})
|
||||
it('keeps the frozen server date without recalculating after a port date changes', () => {
|
||||
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02', isDefault: false }))
|
||||
.toEqual({ date: '2026-11-02', time: '', isDefault: false })
|
||||
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,19 @@ export interface WarehouseArrivalPlan {
|
||||
plannedAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/** 仅展示服务端统一解析的日期,不在清关列表或日历重复推算。 */
|
||||
export function projectWarehouseArrival(plan: WarehouseArrivalPlan | undefined) {
|
||||
const date = plan && plan.version >= 0 ? plan.plannedAt?.slice(0, 10) || plan.plannedDate || null : null
|
||||
return { date, time: date ? plan?.plannedAt?.slice(11, 16) || '' : '', isDefault: !!date && plan?.isDefault === true }
|
||||
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
|
||||
export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) {
|
||||
const canDefault = !plan || plan.version === 0 && plan.planCount === 0
|
||||
let date = plan?.plannedAt?.slice(0, 10) || null
|
||||
if (!date && canDefault && arrivalAt) {
|
||||
const day = arrivalAt.slice(0, 10)
|
||||
const calendarDate = new Date(`${day}T12:00:00Z`)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) {
|
||||
calendarDate.setUTCDate(calendarDate.getUTCDate() + 2)
|
||||
date = calendarDate.toISOString().slice(0, 10)
|
||||
}
|
||||
}
|
||||
return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault }
|
||||
}
|
||||
|
||||
@@ -89,18 +89,6 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
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 () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = []
|
||||
|
||||
@@ -399,15 +399,15 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
<div v-else-if="initializationReady" v-loading="loading" class="loading-state" />
|
||||
|
||||
<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><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></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="暂无系数配置">
|
||||
<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="权重系数" 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="145"><template #default="{ row }">{{ row.dimension ? (Number(row.dimension.volumeCbm) * row.coefficient ** 3).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="重量(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 ** 3 * 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 * 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></div>
|
||||
</section>
|
||||
|
||||
@@ -59,8 +59,6 @@
|
||||
|
||||
|
||||
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
|
||||
.customer-editor-dialog .density-form--outlined { --el-border-radius-base: var(--cc-radius-lg); }
|
||||
.customer-editor-dialog .density-form--outlined .el-input-number .el-input__inner { text-align: left; }
|
||||
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
|
||||
.customer-editor-dialog .customer-editor-table td:not([colspan]) { width: calc(100% / 3); }
|
||||
.customer-review-table th { width: 120px; }
|
||||
|
||||
@@ -289,14 +289,14 @@ onBeforeUnmount(() => {
|
||||
<section class="detail-panel user-panel">
|
||||
<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">
|
||||
<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)">
|
||||
<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>{{ displayText(member.phone) }}</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><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light" size="small">{{ member.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('CustomersView', () => {
|
||||
it('searches the customer list by customer name only', () => {
|
||||
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
|
||||
expect(customersSource).toContain("label: '客户昵称'")
|
||||
expect(customersSource).toContain('placeholder="选填,默认自动生成"')
|
||||
expect(customersSource).toContain('中文拼音大写首字母,如 SZWL')
|
||||
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
|
||||
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
|
||||
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
|
||||
@@ -210,16 +210,12 @@ describe('CustomersView', () => {
|
||||
expect(document.querySelector<HTMLElement>('.customer-editor-dialog .qualification-credit-code .el-form-item__label')?.style.width).toBe('144px')
|
||||
const company = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入工商登记公司名称"]')!
|
||||
expect(company.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('120px')
|
||||
const legalName = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入营业执照上的企业名称"]')!
|
||||
expect(legalName.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('144px')
|
||||
expect(company.closest('form')?.classList.contains('density-form--outlined')).toBe(true)
|
||||
company.value = ''
|
||||
company.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
company.dispatchEvent(new FocusEvent('blur'))
|
||||
await vi.waitFor(() => expect(company.closest('.el-form-item')?.querySelector('.el-form-item__error')?.textContent).toContain('请输入公司名称'))
|
||||
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).toContain('保存资料和资质')
|
||||
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).not.toContain('保存资料时会一并上传已选资质')
|
||||
expect(updatePlatformTenant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -214,7 +214,6 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
||||
const customerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 330, slotName: 'actions' },
|
||||
{ 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: 'company', label: '公司名称', width: 250, slotName: 'company' },
|
||||
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
|
||||
@@ -222,6 +221,7 @@ const customerColumns: DataTableColumn[] = [
|
||||
{ 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: '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: 'createdAt', prop: 'createdAt', label: '入驻时间', minWidth: 165, formatter: (row: PlatformTenantResource) => formatDate(row.createdAt) },
|
||||
]
|
||||
@@ -1085,17 +1085,17 @@ onMounted(() => { void loadCustomers() })
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-form--outlined density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="选填,默认自动生成" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" label-width="144px" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
||||
<tr><td colspan="3"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
|
||||
<tr><td colspan="3"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
||||
@@ -1103,8 +1103,8 @@ onMounted(() => { void loadCustomers() })
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section qualification-section workbench-evidence"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></h3>
|
||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small v-if="!creditCodeEstablished">必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。</small></el-form-item></td></tr></tbody></table>
|
||||
<section class="density-dialog-section qualification-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
|
||||
@@ -1124,7 +1124,7 @@ onMounted(() => { void loadCustomers() })
|
||||
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><div class="customer-editor-footer"><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview admin-workbench is-dialog" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||
@@ -1183,12 +1183,13 @@ onMounted(() => { void loadCustomers() })
|
||||
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="passwordDialogOpen" title="重置客户密码" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<AppDialog v-model="passwordDialogOpen" :title="customerDialogTitle('重置客户密码', passwordTenant)" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top" class="density-form">
|
||||
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
|
||||
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
|
||||
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
|
||||
<p class="density-dialog-note">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
|
||||
</el-form>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
|
||||
</AppDialog>
|
||||
@@ -1218,7 +1219,7 @@ onMounted(() => { void loadCustomers() })
|
||||
.customer-company-name { display:block; overflow:hidden; color:var(--workbench-text); font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
|
||||
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); line-height:1.5; }
|
||||
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--workbench-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--workbench-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:var(--cc-list-table-header-bg); color:var(--cc-list-table-header-text); font-size:var(--admin-font-caption, 12px); font-weight:600; }
|
||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||
@@ -1239,7 +1240,7 @@ onMounted(() => { void loadCustomers() })
|
||||
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
|
||||
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--workbench-surface); }
|
||||
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:flex-end; gap:var(--admin-space-6, 16px); }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); text-align:left; }
|
||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--workbench-surface-soft); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); }
|
||||
.review-document-state { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-error { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
|
||||
@@ -93,9 +93,9 @@ onMounted(() => { void load() })
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
||||
<section class="density-dialog-section workbench-evidence"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
<div class="customer-fee-tier-groups">
|
||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
|
||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
|
||||
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
||||
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
||||
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
|
||||
|
||||
@@ -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: '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 }, { 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 }]
|
||||
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 }]
|
||||
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 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>
|
||||
<section ref="dashboardRef" class="dashboard-view admin-workbench">
|
||||
<AdminPage as="div" spacing="compact" class="admin-density admin-workbench">
|
||||
<AdminPage as="div" spacing="normal" class="admin-density admin-workbench">
|
||||
<section class="dashboard-context" aria-label="数据看板操作">
|
||||
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
@@ -128,7 +128,6 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
|
||||
<style scoped>
|
||||
.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-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; }
|
||||
|
||||
@@ -2,11 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createRequire } from 'node:module'
|
||||
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 type { InventoryPage, InventoryRecord } from '../types'
|
||||
import type { InventoryRecord } from '../types'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
// Use Node's supported require export so the validator retains its constructor interop.
|
||||
@@ -34,16 +31,11 @@ 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('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 and units in shared compact metric cards', () => {
|
||||
for (const fact of ['summary?.inboundBoxes', 'summary?.outboundBoxes', 'summary?.availableBoxes', 'summary?.inboundVolumeCbm']) expect(source).toContain(fact)
|
||||
it('keeps all four inventory facts in the approved compact summary', () => {
|
||||
for (const fact of ['summary.inboundBoxes', 'summary.outboundBoxes', 'summary.availableBoxes', 'summary.inboundVolumeCbm']) expect(source).toContain(fact)
|
||||
expect(source).toContain("import './domesticContainersSummary.css'")
|
||||
const summary = source.match(/<section\b[^>]*class="inventory-summary"[\s\S]*?<\/section>/)?.[0] || ''
|
||||
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).toContain('display: flex; flex-wrap: wrap')
|
||||
expect(summaryStyles).toContain('font-size: var(--admin-font-section, 14px)')
|
||||
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'") })
|
||||
@@ -57,14 +49,10 @@ 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() }))
|
||||
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
|
||||
async function mountView() {
|
||||
async function openCreate(label: string) {
|
||||
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
mounted = view
|
||||
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')
|
||||
await flushPromises()
|
||||
await view.findAll('button').find(button => button.text() === label)!.trigger('click')
|
||||
@@ -76,7 +64,7 @@ async function submitCreate() {
|
||||
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
describe('DomesticContainersView workflows', () => {
|
||||
describe('DomesticContainersView create workflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
@@ -87,60 +75,6 @@ describe('DomesticContainersView workflows', () => {
|
||||
})
|
||||
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 () => {
|
||||
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
|
||||
const { form } = await openCreate('新建入库单')
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Box, Boxes, PackageCheck, PackagePlus, Plus } from '@lucide/vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -28,10 +28,7 @@ const businessTabs: Array<{ value: TabName; label: string }> = [
|
||||
{ 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 summary = ref<InventorySummary | null>(null)
|
||||
const summaryLoading = ref(false)
|
||||
const summaryError = ref<ApiErrorDetails | null>(null)
|
||||
let loadSequence = 0
|
||||
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 })
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' })
|
||||
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||
@@ -44,49 +41,15 @@ const queryFields = computed<QueryField[]>(() => {
|
||||
return base
|
||||
})
|
||||
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: '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: '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: '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) },
|
||||
{ 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' },
|
||||
])
|
||||
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 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 } }
|
||||
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 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 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 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() }
|
||||
@@ -110,31 +73,13 @@ async function submit() { if (!await formRef.value?.validate().catch(() => false
|
||||
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench">
|
||||
<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' ? '暂无整柜出库单' : '暂无整柜库存'">
|
||||
<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 #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>
|
||||
<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>
|
||||
@@ -177,7 +122,4 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
||||
</el-form>
|
||||
</AppDialog></AdminPage></template>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
.domestic-containers-page .inventory-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--workbench-grid-gap);
|
||||
.domestic-containers-page .table-panel .inventory-summary {
|
||||
display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px) 28px; margin: 0;
|
||||
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);
|
||||
}
|
||||
|
||||
@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 > div {
|
||||
display: flex; align-items: baseline; min-width: 0; gap: var(--admin-space-3, 8px); padding: 0; border: 0;
|
||||
}
|
||||
.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[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ 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: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'progress', label: '数据量', width: 120, slotName: 'progress' },
|
||||
{ key: 'format', prop: 'format', label: '格式', width: 80 },
|
||||
{ key: 'requester', label: '申请人', minWidth: 130, slotName: 'requester' },
|
||||
|
||||
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowLeft, CircleCheck, Download, RefreshCw } from '@lucide/vue'
|
||||
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -448,6 +448,12 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
@@ -456,11 +462,9 @@ onMounted(() => {
|
||||
<template v-else-if="detail">
|
||||
<section class="version-panel">
|
||||
<header class="section-heading">
|
||||
<div><h2>版本信息</h2></div>
|
||||
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div>
|
||||
</header>
|
||||
<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.rateCount }}</dd></div>
|
||||
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
|
||||
@@ -471,7 +475,7 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section v-if="isPending && canEdit" class="mapping-section">
|
||||
<header class="section-heading"><div><h2>仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
|
||||
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
|
||||
<el-alert
|
||||
v-if="nonMappingBlockingIssues.length"
|
||||
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
|
||||
@@ -510,39 +514,37 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<header class="section-heading"><div><h2>解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
|
||||
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
|
||||
<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-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="编码" width="270" />
|
||||
<el-table-column prop="message" label="说明" min-width="320" />
|
||||
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
|
||||
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="detail-section channel-preview">
|
||||
<header class="section-heading"><div><h2>按渠道预览</h2></div></header>
|
||||
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2></div></header>
|
||||
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
|
||||
<template v-else>
|
||||
<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>{{ channel.channelName }}</template></el-tab-pane>
|
||||
<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-tabs>
|
||||
<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>
|
||||
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '—' }}</span></div>
|
||||
<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">
|
||||
<el-table-column prop="originWarehouseName" label="起运仓" min-width="180" />
|
||||
<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="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="包税方式" 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 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>
|
||||
</template>
|
||||
@@ -553,24 +555,23 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.price-book-detail { min-width: 0; }
|
||||
.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); }
|
||||
.price-book-detail { min-width: 0; max-width: 1880px; }
|
||||
.version-panel, .mapping-section, .detail-section { padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
|
||||
.detail-section:last-child { border-bottom: 0; }
|
||||
.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; }
|
||||
.section-heading > div { min-width: 0; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin: 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 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; }
|
||||
.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-group { display: grid; gap: var(--admin-space-3); }
|
||||
.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); 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) 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 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); }
|
||||
@@ -579,8 +580,8 @@ onMounted(() => {
|
||||
.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); }
|
||||
.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-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) {
|
||||
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
|
||||
.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', () => {
|
||||
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注']) {
|
||||
expect(detailSource).toContain(`<dt>${label}</dt>`)
|
||||
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) {
|
||||
expect(detailSource).toContain(`label="${label}"`)
|
||||
}
|
||||
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
|
||||
expect(detailSource).toContain(`selectedChannel?.${field}`)
|
||||
|
||||
@@ -82,15 +82,15 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'scope', label: '报价范围', minWidth: 160, slotName: 'scope' },
|
||||
{ key: 'version', label: '价格表版本', width: 110, slotName: 'version' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'version' },
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
|
||||
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
|
||||
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
|
||||
{ key: 'contents', label: '解析结果', minWidth: 170, slotName: 'contents' },
|
||||
{ key: 'issues', label: '校验结果', width: 140, slotName: 'issues' },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'contents', label: '解析结果', minWidth: 155, slotName: 'contents' },
|
||||
{ key: 'issues', label: '校验结果', minWidth: 150, slotName: 'issues' },
|
||||
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) },
|
||||
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 260, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
|
||||
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 290, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
|
||||
]
|
||||
|
||||
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 #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 #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 #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 #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 #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
|
||||
@@ -423,7 +423,6 @@ onMounted(() => {
|
||||
.version-cell strong { color: var(--workbench-text); }
|
||||
.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 > span, .issue-counts > span { white-space: nowrap; }
|
||||
.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 .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| 页面 | 操作 | 原型承载/布局 | 当前实现及本轮结论 |
|
||||
|---|---|---|---|
|
||||
| 费用项目 | 列表 | 页面内表格,原型无价格列 | 沿用列表,增加默认价格列,空值显示“—” |
|
||||
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 2026-10-27 用户确认改为居中弹框(760px,受视口宽度限制),三个分区使用白底和中性边框;保留可空默认价格、字段分组及保存行为 |
|
||||
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 沿用现有 620px 抽屉与表单,增加可空默认价格及说明 |
|
||||
| 入库费用 | 新增、编辑、调整时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入单价和金额,支持人工覆盖,回显保留原值 |
|
||||
| 柜次费用 | 新增时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入金额,支持人工覆盖,调整回显保留原值 |
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FeeItemsView from './FeeItemsView.vue'
|
||||
import * as api from '../api/platformFeeItems'
|
||||
|
||||
// Preserve the validator constructor interop through Element Plus's supported Node export.
|
||||
const { default: ElementPlus, ElCheckbox, ElDialog, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const { default: ElementPlus, ElCheckbox, ElDrawer, 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() }))
|
||||
let record: api.PlatformFeeItemResource
|
||||
@@ -27,7 +28,7 @@ async function openEditor(create = false) {
|
||||
const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper
|
||||
}
|
||||
async function setText(placeholder: string, value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
|
||||
const input = document.querySelector<HTMLInputElement>(`.el-drawer [placeholder="${placeholder}"]`)!
|
||||
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) {
|
||||
@@ -50,14 +51,12 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('fee item editing and confirmations', () => {
|
||||
it('creates from a centered white dialog with a zero default and all direction flags', async () => {
|
||||
it('creates from the original compact drawer with a zero default and all direction flags', async () => {
|
||||
const wrapper = await openEditor(true)
|
||||
expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '新增费用项目', width: 'min(760px, 94vw)' })
|
||||
expect(document.querySelector('.el-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
|
||||
expect(document.querySelectorAll('.el-dialog .density-dialog-section')).toHaveLength(3)
|
||||
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()
|
||||
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '新增费用项目', size: 'min(620px, 94vw)' })
|
||||
expect(document.querySelector('.el-drawer')?.classList.contains('admin-density-drawer--editor')).toBe(true)
|
||||
expect(document.querySelectorAll('.el-drawer .density-dialog-section')).toHaveLength(3)
|
||||
expect(document.querySelector('.el-dialog')).toBeNull()
|
||||
await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ')
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0)
|
||||
await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true)
|
||||
@@ -65,33 +64,33 @@ describe('fee item editing and confirmations', () => {
|
||||
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING')
|
||||
await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true)
|
||||
await setText('请输入适用场景或核算说明', ' 新说明 ')
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-drawer__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(useUnsavedWorkStore().entries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('validates required fields and payable supplier category without losing entered values', async () => {
|
||||
const wrapper = await openEditor(true)
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('请输入费用名称'))
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-drawer')?.textContent).toContain('请输入费用名称'))
|
||||
await setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费')
|
||||
await check(wrapper, '提货环节应付', true)
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('新费用')
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('新费用')
|
||||
})
|
||||
|
||||
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 }
|
||||
const wrapper = await openEditor()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑费用项目 · 测试操作费')
|
||||
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(privateId)
|
||||
expect(wrapper.findComponent(AppDrawer).props('title')).toBe('编辑费用项目 · 测试操作费')
|
||||
expect(document.querySelector('.el-drawer')?.textContent).not.toContain(privateId)
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null)
|
||||
await check(wrapper, '提货环节应付', false)
|
||||
expect(document.querySelector('.el-dialog [placeholder="请选择供应商类别"]')).toBeNull()
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(document.querySelector('.el-drawer [placeholder="请选择供应商类别"]')).toBeNull()
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false }))
|
||||
})
|
||||
|
||||
@@ -100,17 +99,17 @@ describe('fee item editing and confirmations', () => {
|
||||
vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
|
||||
const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用')
|
||||
expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' })
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDrawer).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-drawer__footer button').disabled).toBe(true)
|
||||
rejectSave(new Error('保存失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
|
||||
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
|
||||
vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record)
|
||||
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformFeeItem).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unsaved registration and the existing permission-change closure', async () => {
|
||||
@@ -119,7 +118,7 @@ describe('fee item editing and confirmations', () => {
|
||||
await setText('请输入费用名称', '权限变化前的修改')
|
||||
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
|
||||
useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(useUnsavedWorkStore().entries).toHaveLength(0)
|
||||
expect(api.updatePlatformFeeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.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 InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
@@ -119,7 +120,6 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'feeTypeName', label: '费用项目', minWidth: 210, slotName: 'feeItem' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'feeTypeName' },
|
||||
{ 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: 'tradeScope', label: '贸易范围', width: 110, slotName: 'tradeScope' },
|
||||
@@ -132,6 +132,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', label: '创建日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.createdAt) },
|
||||
{ key: 'updatedByName', label: '更新人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.updatedByName || '—' },
|
||||
{ key: 'updatedAt', label: '更新日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.updatedAt) },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
]
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
@@ -429,22 +430,22 @@ onMounted(() => {
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog fee-item-editor"
|
||||
<AppDrawer class="admin-density admin-density-drawer admin-density-drawer--editor admin-workbench is-drawer"
|
||||
v-model="editorOpen"
|
||||
:title="editorTitle"
|
||||
width="min(760px, 94vw)" align-center
|
||||
size="min(620px, 94vw)"
|
||||
:loading="editorLoading"
|
||||
:confirm-loading="saving"
|
||||
>
|
||||
<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" aria-label="费用基本信息">
|
||||
<el-form class="density-form 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="费用基本信息">
|
||||
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
|
||||
<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="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>
|
||||
<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>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="费用方向与供应商">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用方向与供应商">
|
||||
<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>
|
||||
<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>
|
||||
@@ -453,7 +454,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>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="费用适用说明">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用适用说明">
|
||||
<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>
|
||||
</section>
|
||||
@@ -464,7 +465,7 @@ onMounted(() => {
|
||||
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog"
|
||||
v-model="statusOpen"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
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'
|
||||
|
||||
describe('finance api', () => {
|
||||
@@ -89,7 +88,7 @@ describe('finance api', () => {
|
||||
})
|
||||
|
||||
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: '人工收款', 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 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 result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
|
||||
|
||||
@@ -97,22 +96,6 @@ 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' })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
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')
|
||||
@@ -328,9 +311,3 @@ 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' } })
|
||||
})
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
|
||||
documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo),
|
||||
receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange),
|
||||
outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark),
|
||||
businessNo: nullableStringValue(item.walletBusinessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.walletAmount),
|
||||
businessNo: nullableStringValue(item.businessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.amount),
|
||||
actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter),
|
||||
availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType),
|
||||
creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter),
|
||||
@@ -208,11 +208,6 @@ export async function updateFinanceCreditLimit(customerId: string, payload: { cr
|
||||
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: {
|
||||
page?: number
|
||||
size?: number
|
||||
|
||||
@@ -25,12 +25,12 @@ let requestSerial = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'billNo', prop: 'billNo', label: '对账单号', minWidth: 180 },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'billNo' },
|
||||
{ key: 'billDate', prop: 'billDate', label: '账单日期', width: 120 },
|
||||
{ key: 'dueDate', prop: 'dueDate', label: '到期日', width: 120 },
|
||||
{ key: 'totalAmount', label: '应收(CNY)', width: 130, align: 'right', slotName: 'totalAmount' },
|
||||
{ key: 'receivedAmount', label: '已收(CNY)', width: 130, align: 'right', slotName: 'receivedAmount' },
|
||||
{ key: 'outstandingAmount', label: '未收(CNY)', width: 130, align: 'right', slotName: 'outstandingAmount' },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
.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 .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 .account-metrics .is-negative,
|
||||
.admin-density .density-detail-overview .is-danger,
|
||||
@@ -14,32 +15,3 @@
|
||||
.admin-density .density-detail-overview .is-success,
|
||||
.admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); }
|
||||
.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 { listPlatformTenants } from '@/modules/iam'
|
||||
import * as api from '../api/finance'
|
||||
import type { FinanceAccountSummary, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
|
||||
import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types'
|
||||
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
|
||||
|
||||
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('../api/finance', () => ({
|
||||
listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(),
|
||||
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(),
|
||||
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(),
|
||||
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
|
||||
}))
|
||||
const account: FinanceAccountSummary = {
|
||||
@@ -287,105 +287,3 @@ describe('customer wallet native dialogs', () => {
|
||||
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(),
|
||||
listFinanceRechargeRequests: vi.fn(),
|
||||
reviewFinanceRechargeRequest: vi.fn(),
|
||||
adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(),
|
||||
adjustFinanceAccount: vi.fn(),
|
||||
updateFinanceCreditLimit: vi.fn(),
|
||||
downloadFinanceRechargeVoucher: vi.fn(),
|
||||
getFinanceCustomerTerms: vi.fn(),
|
||||
@@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(page).toContain('v-else-if="!detailOpen"')
|
||||
expect(page).toContain('返回客户账户')
|
||||
expect(page).toContain('<DetailPageLayout')
|
||||
expect(page).toContain('class="account-metrics"')
|
||||
expect(page).toContain('class="density-detail-overview account-metrics"')
|
||||
for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric)
|
||||
expect(page).toContain('class="ledger-query workbench-query"')
|
||||
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', () => {
|
||||
for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
|
||||
expect(page).toContain('account-metrics')
|
||||
expect(page).toContain('density-detail-overview account-metrics')
|
||||
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('justify-content: start')
|
||||
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('padding: var(--admin-space-4) 0')
|
||||
expect(page).toContain('class="detail-table-scroll workbench-table is-page-surface"')
|
||||
expect(page).toContain('class="detail-table-scroll workbench-table"')
|
||||
expect(page).toContain('class="ledger-identifier"')
|
||||
expect(page).toContain('class="ledger-document"')
|
||||
expect(page).toContain('overflow-wrap: anywhere')
|
||||
expect(page).not.toContain('text-overflow: ellipsis')
|
||||
expect(page).toContain('text-overflow: ellipsis')
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('keeps account detail and term editing readable across the full workspace width', () => {
|
||||
expect(page).not.toContain('width: min(100%, 1920px)')
|
||||
it('keeps account detail and term editing readable across the 1920 workspace', () => {
|
||||
expect(page).toContain('width: min(100%, 1920px)')
|
||||
expect(page).toContain('@media (min-width: 1920px)')
|
||||
expect(page).toContain('finance-density-detail')
|
||||
expect(page).toContain('../financeDensity.css')
|
||||
@@ -186,10 +186,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('客户A')
|
||||
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('C-001')
|
||||
expect(wrapper.text()).toContain('张三')
|
||||
expect(wrapper.text()).toContain('13800000000')
|
||||
const metrics = wrapper.find('.account-metrics')
|
||||
@@ -197,7 +194,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(metrics.text()).toContain('实际余额¥126580.50 CNY')
|
||||
expect(metrics.text()).toContain('冻结金额¥8500.00 CNY')
|
||||
expect(metrics.text()).toContain('总可用余额¥138080.50 CNY')
|
||||
expect(metrics.text()).toContain('授信额度¥20000.00 CNY2028-09-10 到期')
|
||||
expect(metrics.text()).toContain('授信额度¥20000.00 2028-09-10 到期')
|
||||
expect(metrics.text()).toContain('已用授信¥0.00 CNY')
|
||||
expect(metrics.text()).toContain('可用授信¥20000.00 CNY')
|
||||
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
|
||||
|
||||
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, CircleCheck, CreditCard, Download, LockKeyhole, MinusCircle, PlusCircle, Save, Wallet } from '@lucide/vue'
|
||||
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
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 RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { refundFinanceConsumption, adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
|
||||
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
|
||||
import { financeBusinessTypeLabel } from '../status'
|
||||
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
|
||||
|
||||
@@ -45,7 +45,6 @@ const saving = ref(false)
|
||||
const selected = ref<FinanceAccountSummary | null>(null)
|
||||
const ledger = ref<FinanceLedgerEntry[]>([])
|
||||
const ledgerLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const ledgerError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
|
||||
const creditDays = ref(30)
|
||||
@@ -58,12 +57,6 @@ const rechargeLoading = ref(false)
|
||||
const rechargeError = ref<ApiErrorDetails | null>(null)
|
||||
const accountExportBusy = 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 adjustmentSaving = ref(false)
|
||||
const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
|
||||
@@ -98,16 +91,16 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
|
||||
{ key: 'status', label: '客户状态', width: 110, slotName: 'status', afterColumn: 'customerCode' },
|
||||
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
|
||||
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
|
||||
{ key: 'actualBalance', label: '实际余额(CNY)', align: 'right', width: 155, slotName: 'actualBalance' },
|
||||
{ key: 'frozenBalance', label: '冻结金额(CNY)', align: 'right', width: 150, slotName: 'frozenBalance' },
|
||||
{ key: 'availableBalance', label: '总可用余额(CNY)', align: 'right', width: 155, slotName: 'availableBalance' },
|
||||
{ key: 'creditLimit', label: '授信额度(CNY)', align: 'right', width: 155, slotName: 'creditLimit' },
|
||||
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' },
|
||||
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' },
|
||||
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' },
|
||||
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' },
|
||||
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
|
||||
{ key: 'creditUsed', label: '已用授信(CNY)', align: 'right', width: 150, slotName: 'creditUsed' },
|
||||
{ key: 'pendingRechargeCount', label: '待审核充值', align: 'right', width: 120, slotName: 'pendingRechargeCount' },
|
||||
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' },
|
||||
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' },
|
||||
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
||||
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
|
||||
]
|
||||
const ledgerQueryFields: QueryField[] = [
|
||||
@@ -121,22 +114,21 @@ const ledgerQueryFields: QueryField[] = [
|
||||
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
|
||||
]
|
||||
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: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, align: 'right', slotName: 'amount' },
|
||||
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
|
||||
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
|
||||
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, align: 'right', slotName: 'actualBalanceAfter' },
|
||||
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, align: 'right', slotName: 'frozenBalanceAfter' },
|
||||
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, align: 'right', slotName: 'availableBalanceAfter' },
|
||||
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, align: 'right', slotName: 'creditUsedAfter' },
|
||||
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' },
|
||||
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' },
|
||||
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' },
|
||||
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, slotName: 'creditUsedAfter' },
|
||||
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const rechargeColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'requestNo', label: '申请单号', minWidth: 190 },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status', afterColumn: 'requestNo' }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'amount', label: '充值金额(CNY)', width: 150, align: 'right', slotName: 'amount' },
|
||||
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 }, { key: 'reviewedByName', label: '审核人', width: 130 },
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' },
|
||||
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
|
||||
{ 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')))
|
||||
@@ -212,15 +204,7 @@ async function refreshWalletDetail() {
|
||||
if (!selected.value) return
|
||||
await Promise.all([loadRechargeRequests(), loadLedger()])
|
||||
}
|
||||
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 retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
|
||||
async function saveTerms() {
|
||||
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
|
||||
saving.value = true
|
||||
@@ -253,41 +237,7 @@ function paymentMethodLabel(value: unknown) {
|
||||
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
|
||||
}
|
||||
function walletBusinessType(value: unknown) {
|
||||
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 }
|
||||
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
|
||||
}
|
||||
function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') {
|
||||
adjustmentAccount.value = account
|
||||
@@ -475,15 +425,12 @@ async function closeDetail() {
|
||||
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
|
||||
}
|
||||
async function restoreAccountDetail(customerId: string) {
|
||||
if (!canView.value || detailLoading.value) return
|
||||
detailLoading.value = true
|
||||
detailOpen.value = true; editing.value = false; ledgerError.value = null
|
||||
try {
|
||||
selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null
|
||||
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
|
||||
await Promise.all([loadLedger(), loadRechargeRequests()])
|
||||
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
const customerId = String(route.params.customerId || '')
|
||||
@@ -516,43 +463,18 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-account-detail admin-density admin-workbench is-embedded" spacing="normal">
|
||||
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal">
|
||||
<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 ? '维护客户账期' : '客户账户详情'">
|
||||
<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><section class="account-metrics" aria-label="客户资金账户余额">
|
||||
<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 #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 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>
|
||||
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
||||
<template v-else-if="selected">
|
||||
<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 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 v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
|
||||
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
|
||||
<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 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>
|
||||
<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">
|
||||
<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 #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 #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>
|
||||
@@ -560,7 +482,8 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
</DataTable></div>
|
||||
</FormSection>
|
||||
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
|
||||
<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>
|
||||
<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"><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 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>
|
||||
@@ -568,21 +491,6 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</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">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料">
|
||||
@@ -646,7 +554,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
}
|
||||
|
||||
.finance-account-detail__surface {
|
||||
width: 100%;
|
||||
width: min(100%, 1920px);
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -694,26 +602,27 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.account-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: var(--admin-space-5);
|
||||
padding-block: var(--admin-space-3) 0;
|
||||
.ledger-section {
|
||||
padding: var(--admin-space-4) 0 0;
|
||||
}
|
||||
|
||||
.account-metrics .workbench-metric-value > .is-negative,
|
||||
.account-metrics .credit-expired {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.account-profile :deep(.el-descriptions__table) {
|
||||
table-layout: fixed;
|
||||
.recharge-section {
|
||||
padding: var(--admin-space-4) 0 0;
|
||||
border-bottom: 1px solid var(--workbench-border);
|
||||
}
|
||||
|
||||
.recharge-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ledger-query {
|
||||
margin-bottom: var(--admin-space-3);
|
||||
}
|
||||
|
||||
:deep(.ledger-query.query-toolbar) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -725,7 +634,9 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
.ledger-identifier,
|
||||
.ledger-document {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -782,6 +693,12 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
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 {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -810,7 +727,16 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
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) {
|
||||
|
||||
@@ -127,26 +127,6 @@ 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 () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list'])
|
||||
await loadRows(wrapper)
|
||||
@@ -273,19 +253,17 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(wrapper.find('.arap-metrics').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.arap-metric')).toHaveLength(4)
|
||||
expect(wrapper.find('.arap-information').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('.arap-settlement').text()).toContain('审核与结算')
|
||||
expect(wrapper.find('.operation-log').text()).toContain('操作记录')
|
||||
expect(wrapper.find('.arap-information').text()).toContain('业务日期')
|
||||
expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00')
|
||||
})
|
||||
|
||||
it('keeps the fee-source detail readable across the full workspace width', () => {
|
||||
it('keeps the fee-source detail readable across the 1920 workspace', () => {
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
|
||||
expect(page).not.toContain('width: min(100%, 1920px)')
|
||||
expect(page).toContain('<el-descriptions border :column="3" class="arap-detail density-descriptions">')
|
||||
expect(page).not.toContain('arap-detail-grid')
|
||||
expect(page).toContain('width: min(100%, 1920px)')
|
||||
expect(page).toContain('@media (min-width: 1920px)')
|
||||
expect(page).toContain('grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr)')
|
||||
expect(page).toContain('finance-density-detail')
|
||||
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) })),
|
||||
},
|
||||
{ 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: 'PAYABLE_BILLED' }, { label: '部分付款', value: 'PARTIALLY_PAID' }, { label: '已付款', value: 'PAID' }, { 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: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
|
||||
{ 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: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
|
||||
@@ -141,19 +141,12 @@ function closeCustomerSelectOnScroll() {
|
||||
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
|
||||
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[] = [
|
||||
{ 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: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus', afterColumn: 'businessReference' },
|
||||
{ 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: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||
{ 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' },
|
||||
{ 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: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
|
||||
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
|
||||
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
|
||||
]
|
||||
function customerName(customerId: string, snapshot?: string) {
|
||||
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
|
||||
@@ -198,7 +191,7 @@ function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 's
|
||||
}
|
||||
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
|
||||
function sourceStatusDisplay(value: unknown) {
|
||||
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', PAYABLE_BILLED: '已生成应付单', PARTIALLY_PAID: '部分付款', PAID: '已付款', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
|
||||
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
|
||||
return labels[String(value || '').toUpperCase()] || '状态待确认'
|
||||
}
|
||||
function formatDateTime(value: unknown) {
|
||||
@@ -421,17 +414,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 #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 #generated="{ row }">{{ row.direction === 'PAYABLE' ? '不适用' : row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
|
||||
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
|
||||
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-arap-detail admin-density admin-workbench is-embedded" spacing="normal">
|
||||
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
|
||||
<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="费用来源详情">
|
||||
<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>
|
||||
<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"><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>
|
||||
<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>
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</AdminPage>
|
||||
@@ -509,7 +502,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
}
|
||||
|
||||
.finance-arap-detail__surface {
|
||||
width: 100%;
|
||||
width: min(100%, 1920px);
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -529,12 +522,23 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
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-settlement,
|
||||
.operation-log {
|
||||
padding: var(--admin-space-4) 0;
|
||||
}
|
||||
|
||||
.arap-settlement {
|
||||
padding-left: var(--admin-space-6);
|
||||
border-left: 1px solid var(--workbench-border);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -562,6 +566,16 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
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) {
|
||||
.finance-arap-detail__surface {
|
||||
@@ -577,7 +591,16 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
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) {
|
||||
|
||||
@@ -108,37 +108,13 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
||||
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(22)
|
||||
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20)
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
|
||||
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('[aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
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', () => {
|
||||
@@ -187,8 +163,9 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
|
||||
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
|
||||
await vm.openDetail(statement)
|
||||
vm.activeDetailTab = 'payments'
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
|
||||
@@ -302,7 +279,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
{ label: '已结清', value: 'PAID' },
|
||||
{ label: '已作废', value: 'VOIDED' },
|
||||
])
|
||||
expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status', afterColumn: 'billNo' })
|
||||
expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status' })
|
||||
})
|
||||
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
|
||||
import { ArrowLeft, Download, Plus } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -15,7 +15,6 @@ import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -66,7 +65,7 @@ const selectedId = ref('')
|
||||
const detail = ref<FinanceReconciliationDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const showAdvancedFilters = ref(false)
|
||||
const activeDetailTab = ref<'lines' | 'payments'>('lines')
|
||||
const detailCache = new Map<string, FinanceReconciliationDetail>()
|
||||
const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
@@ -119,32 +118,25 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ 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: '请选择结束日期' } },
|
||||
])
|
||||
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[] = [
|
||||
{ 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: '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: 'periodStart', label: '结算起始日', width: 130 },
|
||||
{ key: 'periodEnd', label: '结算结束日', width: 130 },
|
||||
{ key: 'creditDays', label: '账期(天)', align: 'right', width: 100 },
|
||||
{ key: 'creditDays', label: '账期(天)', width: 100 },
|
||||
{ key: 'lineCount', label: '费用明细数', width: 110 },
|
||||
{ key: 'sourceCount', label: '来源数量', width: 100 },
|
||||
{ key: 'currency', label: '货币', width: 86 },
|
||||
{ key: 'sourceAmount', label: '调整前金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'adjustmentAmount', label: '调整金额(CNY)', align: 'right', width: 145 },
|
||||
{ key: 'totalAmount', label: '总应收金额(CNY)', align: 'right', width: 155 },
|
||||
{ key: 'receivedAmount', label: '已收金额(CNY)', align: 'right', width: 145 },
|
||||
{ key: 'outstandingAmount', label: '未收金额(CNY)', align: 'right', width: 145 },
|
||||
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
|
||||
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
|
||||
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
|
||||
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
|
||||
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
@@ -165,10 +157,10 @@ const detailLineColumns: DataTableColumn[] = [
|
||||
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
|
||||
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
|
||||
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
|
||||
{ key: 'quantity', label: '计费量', align: 'right', width: 110, slotName: 'quantity' },
|
||||
{ key: 'unitPrice', label: '单价(CNY)', align: 'right', width: 120, slotName: 'unitPrice' },
|
||||
{ key: 'sourceAmount', label: '来源金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'amount', label: '账单金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
|
||||
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
|
||||
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
|
||||
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
|
||||
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
|
||||
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
|
||||
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
|
||||
@@ -177,7 +169,7 @@ const detailLineColumns: DataTableColumn[] = [
|
||||
const paymentColumns: DataTableColumn[] = [
|
||||
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
|
||||
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
|
||||
{ key: 'amount', label: '支付金额(CNY)', align: 'right', width: 150 },
|
||||
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
|
||||
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
|
||||
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
|
||||
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
|
||||
@@ -620,10 +612,9 @@ 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="客户对账单详情">
|
||||
<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>
|
||||
<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="对账单摘要">
|
||||
<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.paidAmount.toFixed(2) }} CNY</dd></div>
|
||||
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
|
||||
@@ -647,11 +638,12 @@ watch(detailRouteId, (statementId) => {
|
||||
</dl>
|
||||
</FormSection>
|
||||
</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>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<template v-else-if="detail">
|
||||
<FormSection class="density-form-section" :bordered="false" title="账单明细表" aria-label="账单明细">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<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 #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
|
||||
@@ -666,9 +658,12 @@ watch(detailRouteId, (statementId) => {
|
||||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||||
</DataTable>
|
||||
</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 class="density-form-section" :bordered="false" title="账单支付历史表" aria-label="支付历史">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template>
|
||||
<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="客户对账单支付历史">
|
||||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||||
@@ -679,27 +674,15 @@ watch(detailRouteId, (statementId) => {
|
||||
</DataTable>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection v-if="detail.revisions.length" class="density-form-section" :bordered="false" title="调整记录" aria-label="调整记录">
|
||||
<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>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</DetailPageLayout>
|
||||
<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">
|
||||
<template #toolbar-start><h1>客户对账单</h1></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 #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></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>
|
||||
|
||||
|
||||
@@ -765,11 +748,15 @@ watch(detailRouteId, (statementId) => {
|
||||
.statement-create-table td:not([colspan]) { width: 50%; }
|
||||
.statement-dialog-facts th { width: var(--admin-label-width); }
|
||||
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
|
||||
.statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; }
|
||||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); 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-outstanding { color: var(--el-color-warning-dark-2); }
|
||||
.statement-detail-tabs { min-width: 0; }
|
||||
.statement-detail-table { min-width: 0; }
|
||||
.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); }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
@@ -778,6 +765,13 @@ watch(detailRouteId, (statementId) => {
|
||||
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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
describe('Finance formal settlement desktop presentation', () => {
|
||||
it('keeps the title and actions in the toolbar and settlement totals above the list', () => {
|
||||
it('keeps the title and actions in the toolbar and settlement totals before results', () => {
|
||||
expect(source).toContain('<template #toolbar-start>')
|
||||
expect(source).toContain('<h1>{{ title }}</h1>')
|
||||
expect(source.indexOf('<section class="finance-metric-grid"')).toBeLessThan(source.indexOf('<ListPageLayout :aria-label="title">'))
|
||||
expect(source).toContain('<section class="finance-metric-grid" aria-label="结算汇总"')
|
||||
expect(source).toContain('<template #before-results>')
|
||||
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">')
|
||||
expect(source).toContain('<template #toolbar-actions>')
|
||||
expect(source).toContain('<div class="settlement-list-actions">')
|
||||
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', () => {
|
||||
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.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
|
||||
expect(source).toContain('class="detail-table-scroll workbench-table"')
|
||||
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
|
||||
expect(source).not.toContain('class="settlement-detail-card"')
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../financeDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, CircleCheck, Clock3, CreditCard, Download, HandCoins, Plus, RotateCcw, Wallet, XCircle } from '@lucide/vue'
|
||||
import { ArrowLeft, CreditCard, Download, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
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: '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: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'receivableAmount', label: '应收金额', align: 'right', width: 130, slotName: 'money' },
|
||||
{ key: 'receivedAmount', label: '已收', align: 'right', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
|
||||
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
|
||||
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
|
||||
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
|
||||
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
|
||||
{ key: 'paymentStatus', label: '收款状态', width: 120, slotName: 'paymentStatus' },
|
||||
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', width: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
|
||||
]
|
||||
: [
|
||||
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
|
||||
{ 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: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'payableAmount', label: '应付金额', align: 'right', width: 130, slotName: 'money' },
|
||||
{ key: 'paidAmount', label: '已付', align: 'right', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
|
||||
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
|
||||
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
|
||||
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
|
||||
{ key: 'paymentStatus', label: '付款状态', width: 120, slotName: 'paymentStatus' },
|
||||
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
|
||||
{ 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 })}` }
|
||||
@@ -520,7 +520,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" :spacing="detailMode ? 'normal' : 'compact'">
|
||||
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" spacing="normal">
|
||||
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
|
||||
<OperationErrorToast :message="receiptValidationError" />
|
||||
<OperationErrorToast :details="receiptError" />
|
||||
@@ -534,25 +534,17 @@ onMounted(() => {
|
||||
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
</div></div></template>
|
||||
<template v-if="detail" #summary>
|
||||
<section class="finance-metric-grid" aria-label="结算金额汇总">
|
||||
<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>{{ money(detail.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>{{ 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>
|
||||
<dl class="density-detail-overview settlement-detail-summary" aria-label="结算金额汇总">
|
||||
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</dt><dd>{{ money(detail.amount) }}</dd></div>
|
||||
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div>
|
||||
<div class="summary-item"><dt>未结余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div>
|
||||
<div class="summary-item"><dt>到期日</dt><dd class="is-date">{{ displayDate(detail.dueDate) }}</dd></div>
|
||||
</dl>
|
||||
</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>
|
||||
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
|
||||
<section v-else-if="detail" class="settlement-detail-content">
|
||||
<FormSection class="density-form-section" title="基本信息" :bordered="false">
|
||||
<FormSection class="density-form-section" title="基本信息">
|
||||
<el-descriptions border :column="4" class="settlement-detail-info density-descriptions">
|
||||
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
|
||||
@@ -570,8 +562,8 @@ onMounted(() => {
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="费用明细" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<FormSection class="density-form-section" title="费用明细">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
|
||||
<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>
|
||||
@@ -583,8 +575,8 @@ onMounted(() => {
|
||||
</el-table>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<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="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||||
@@ -598,7 +590,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="修订记录" :bordered="false">
|
||||
<div class="detail-table-scroll workbench-table is-page-surface">
|
||||
<div class="detail-table-scroll workbench-table">
|
||||
<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="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||||
@@ -611,22 +603,7 @@ onMounted(() => {
|
||||
</FormSection>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
<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">
|
||||
<ListPageLayout v-else :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="暂无符合条件的结算单">
|
||||
@@ -637,7 +614,13 @@ onMounted(() => {
|
||||
<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>
|
||||
</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>
|
||||
@@ -671,7 +654,6 @@ onMounted(() => {
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable></section>
|
||||
</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">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
|
||||
@@ -718,8 +700,8 @@ onMounted(() => {
|
||||
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section workbench-evidence" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
|
||||
<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>
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" 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>
|
||||
<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-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>
|
||||
@@ -735,10 +717,11 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.finance-settlement-page { min-width: 0; }
|
||||
.settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
|
||||
.settlement-summary, .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 :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.settlement-detail-page { display: flex; width: 100%; margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
|
||||
.settlement-detail-page { display: flex; width: min(100%, 1920px); 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-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; }
|
||||
@@ -771,11 +754,21 @@ onMounted(() => {
|
||||
padding: 0 var(--admin-space-5) var(--admin-space-4);
|
||||
}
|
||||
|
||||
.detail-table-scroll {
|
||||
margin-bottom: var(--admin-space-3);
|
||||
}
|
||||
|
||||
}
|
||||
@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-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; }
|
||||
.summary-item strong { font-size: 21px; }
|
||||
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
||||
.settlement-detail-actions { width: 100%; }
|
||||
.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"')
|
||||
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"')
|
||||
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
|
||||
expect(settlement.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
|
||||
expect(settlement.match(/class="detail-table-scroll workbench-table"/g)).toHaveLength(3)
|
||||
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
|
||||
expect(settlement).not.toContain('class="detail-section surface-panel"')
|
||||
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form density-form(?: density-form--outlined)?"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2)
|
||||
expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
|
||||
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
|
||||
})
|
||||
|
||||
it('keeps settlement totals and list actions in the table toolbar', () => {
|
||||
expect(settlement).toContain('<template #toolbar-start>')
|
||||
expect(settlement).toContain('class="finance-metric-grid" aria-label="结算汇总"')
|
||||
expect(settlement).toContain('class="density-detail-overview settlement-summary" aria-label="结算汇总"')
|
||||
expect(settlement).toContain('<template #toolbar-actions>')
|
||||
expect(settlement).toContain('class="settlement-list-actions"')
|
||||
expect(financeDensity).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
|
||||
expect(financeDensity).toContain('.settlement-summary.density-detail-overview { padding: 0; border: 0; }')
|
||||
expect(settlement).not.toContain('class="settlement-list-toolbar"')
|
||||
})
|
||||
|
||||
@@ -148,13 +148,15 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
|
||||
})
|
||||
|
||||
it('uses shared compact financial cards for customer accounts', () => {
|
||||
expect(accounts).toContain('class="account-metrics"')
|
||||
it('uses a shared compact financial summary row for customer accounts', () => {
|
||||
expect(accounts).toContain('class="density-detail-overview account-metrics"')
|
||||
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
|
||||
expect(density).toContain('.density-detail-overview')
|
||||
expect(accounts).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
|
||||
expect(financeDensity).toContain('padding-block: 0;')
|
||||
expect(accounts.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
|
||||
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)')
|
||||
expect(accounts).toContain('padding: 0;')
|
||||
const summaryStyles = density.match(/\.density-detail-overview \{([^}]*)\}/)?.[1]
|
||||
expect(summaryStyles).toContain('display: flex;')
|
||||
expect(summaryStyles).toContain('flex-wrap: wrap;')
|
||||
const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1]
|
||||
expect(accounts).toContain('class="ledger-query workbench-query"')
|
||||
expect(queryStyles).toContain('border-color: var(--workbench-border);')
|
||||
@@ -166,11 +168,9 @@ describe('finance presentation governance', () => {
|
||||
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="arap-information density-form-section"')
|
||||
expect(arap).toContain('title="单据信息与审核结算"')
|
||||
expect(arap).toContain('label="审核原因" :span="3"')
|
||||
expect(arap).not.toContain('arap-detail-grid')
|
||||
expect(arap).not.toContain('arap-settlement')
|
||||
expect(arap).not.toContain('border-left: 1px solid')
|
||||
expect(arap).toContain('class="arap-settlement density-form-section"')
|
||||
expect(arap).toContain('padding-left: var(--admin-space-6);')
|
||||
expect(arap).toContain('border-left: 1px solid')
|
||||
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
|
||||
for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"')
|
||||
})
|
||||
|
||||
@@ -9,9 +9,6 @@ describe('receivable statistics page', () => {
|
||||
for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
|
||||
expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
|
||||
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('/^客户\\s*\\d+$/.test(snapshot)')
|
||||
expect(page).toContain("return masterName || '—'")
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
|
||||
import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -39,9 +39,9 @@ const customerNameById = computed(() => new Map(customers.value.map((item) => [S
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
|
||||
{ key: 'currency', label: '币种', width: 100 },
|
||||
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
|
||||
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
|
||||
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
|
||||
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' },
|
||||
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' },
|
||||
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
@@ -90,31 +90,22 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
|
||||
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal">
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
|
||||
<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()">
|
||||
<template #toolbar-start><h1>应收款统计表</h1></template>
|
||||
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
|
||||
<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"><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"><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"><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-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>
|
||||
<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 #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>
|
||||
@@ -126,17 +117,20 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
|
||||
<style scoped>
|
||||
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
|
||||
.query-field { min-width: 220px; }
|
||||
.query-field { display: grid; min-width: 220px; gap: var(--admin-space-2); color: var(--workbench-muted); font-size: var(--admin-font-body); }
|
||||
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
|
||||
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
|
||||
.report-actions { margin-left: auto; }
|
||||
.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; }
|
||||
.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 {
|
||||
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
|
||||
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
|
||||
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
|
||||
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
|
||||
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import * as api from '../api/finance'
|
||||
@@ -111,7 +110,6 @@ function errorDetails(wrapper: VueWrapper) {
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.resetAllMocks()
|
||||
can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
@@ -131,19 +129,6 @@ beforeEach(() => {
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
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 () => {
|
||||
const { dialog } = await openCreate('receivable')
|
||||
expect(dialog.props('width')).toContain('600px')
|
||||
|
||||
@@ -19,6 +19,5 @@ describe('Finance settlement route 1920px layout', () => {
|
||||
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('finance-density-detail')
|
||||
expect(shared).not.toContain('width: min(100%, 1920px)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,8 +39,6 @@ describe('finance status presentation', () => {
|
||||
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
|
||||
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('审核通过')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_PUBLISHED')).toBe('费用同步')
|
||||
expect(financeOperationActionLabel('FEE_SOURCE_VERSION_ADJUST')).toBe('费用调整同步')
|
||||
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
|
||||
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
|
||||
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
|
||||
|
||||
@@ -82,7 +82,6 @@ const financeOperationActionLabels: Record<string, string> = {
|
||||
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', UPDATE: '更新',
|
||||
FEE_SOURCE_EDIT: '编辑费用', FEE_SOURCE_SUBMIT: '提交审核', FEE_SOURCE_REVIEW_APPROVE: '审核通过',
|
||||
FEE_SOURCE_REVIEW_REJECT: '审核驳回', FEE_SOURCE_ADJUST: '调整费用', FEE_SOURCE_VOID: '作废费用',
|
||||
FEE_SOURCE_PUBLISHED: '费用同步', FEE_SOURCE_VERSION_ADJUST: '费用调整同步',
|
||||
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
|
||||
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
|
||||
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
|
||||
|
||||
@@ -156,9 +156,9 @@ const formRules = computed<FormRules>(() => ({
|
||||
const userColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'username', prop: 'username', label: '用户名', minWidth: 145 },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'username' },
|
||||
{ key: 'displayName', prop: 'displayName', label: '姓名', minWidth: 130 },
|
||||
{ key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
|
||||
]
|
||||
|
||||
@@ -434,8 +434,8 @@ onMounted(loadInitialData)
|
||||
</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">
|
||||
<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" aria-label="用户基本资料">
|
||||
<el-form class="density-form 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="用户基本资料">
|
||||
<h3 class="density-dialog-section__title">用户资料</h3>
|
||||
<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>
|
||||
@@ -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>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" aria-label="用户作业仓库">
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户作业仓库">
|
||||
<h3 class="density-dialog-section__title">作业仓库</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<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>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" aria-label="用户登录密码">
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户登录密码">
|
||||
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
|
||||
<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>
|
||||
|
||||
@@ -85,15 +85,14 @@ describe('PermissionsView IAM mode', () => {
|
||||
it('keeps the shared status badge prop defined during table slot initialization', () => {
|
||||
expect(permissionsSource).toContain("(row as Permission).status || '—'")
|
||||
})
|
||||
it('uses outlined business action buttons without global tree view helpers', () => {
|
||||
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
|
||||
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: 'edit', label: '编辑', priority: 300")
|
||||
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
|
||||
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).not.toContain('展开全部')
|
||||
expect(permissionsSource).not.toContain('收起操作权限')
|
||||
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
|
||||
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
|
||||
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
|
||||
expect(permissionsSource).not.toContain('canChangeStatusRow(row as Permission)" link')
|
||||
@@ -104,10 +103,7 @@ describe('PermissionsView IAM mode', () => {
|
||||
})
|
||||
|
||||
it('keeps technical permission fields out of the default list and labels the table', () => {
|
||||
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: 'code', label: '权限编码', minWidth: 220, slotName: 'code' }")
|
||||
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('aria-label="平台权限资源列表"')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user