This commit is contained in:
2026-09-02 10:43:45 +08:00
111 changed files with 3021 additions and 636 deletions
+10 -6
View File
@@ -47,27 +47,31 @@
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` | | 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` |
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | | 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` | | 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
| 货柜、排柜、预装/装柜清单与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。整柜报关按 `loadingPlanId` 展示,逐资料完成,全买单柜使用 `complete-all-buy`;Backend `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` | | 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | | 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 | | 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 拖车、历史报关协同、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料改从货柜详情进入,不得在旧模块复制一套;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 订舱拥有的拖车预约 | `modules/trucking/api/platformTrucking.ts`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentsView.vue` | 不选择或提交客户;先选 `RELEASED` 订舱,再选其已分配且有真实 ISO 柜号的柜次,提单号和仓库由订舱冻结事实带入;订舱须有有效 `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 文件,同一真实柜最多一条有效预约。拖车附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` | | 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 | | 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` | | 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
| 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 | | 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/四期/船节点标准化契约.md` | | 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/四期/船节点标准化契约.md` |
| 五期海外排车、出库与派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | API 为 `dispatches.ts`、`outbounds.ts`、`deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发出库从服务端库存位置选择并携带幂等键,排车转派送按稳定 `dispatchNo` 进入服务端命令 |
| 五期海外异常退回与客户回访 | `modules/overseas-warehouse/pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | API 为 `exceptionOrders.ts`、`customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。派送退回先从服务端查询可关联入仓单,回访附件和派送完成/状态均走海外真实 API |
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。 全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
## 四期海外模块内部快速索引 ## 海外模块内部快速索引(四期/五期)
| 关键词 | 页面/路由入口 | API 与联查 | | 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---| |---|---|---|
| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 按 `sourceContainerBookingId` 消费 `/orders/container-summaries` 的 `allPlansReady`、计划统计与冻结 `plannedArrivalAt`,详情由服务端返回全柜订单且不受列表分页截断;不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` | | 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 按 `sourceContainerBookingId` 消费 `/orders/container-summaries` 的 `allPlansReady`、计划统计与冻结 `plannedArrivalAt`,详情由服务端返回全柜订单且不受列表分页截断;不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 | | 四期排车查询与智能预排、五期转派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;四期覆盖详情、候选、草稿柜、托盘锁和 `pre-arrange`,五期继续使用 `transfer-delivery` 转派送。后台暂无排车编辑、取消和发车调用时,不得仅凭后端 Controller 声称页面已支持 |
| 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 | | 五期海外出库与派送 | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;一件代发出库、取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 五期异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` | | 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 | | 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` | | 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
+2 -1
View File
@@ -42,7 +42,8 @@ describe('domestic outbound list responsive contracts', () => {
const listLayout = source('src/shared/components/ListPageLayout.vue') const listLayout = source('src/shared/components/ListPageLayout.vue')
expect(queryToolbar).toContain('@media (max-width: 720px)') expect(queryToolbar).toContain('@media (max-width: 720px)')
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; }') expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))') expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))')
expect(dataTable).toContain('@media (max-width: 720px)') expect(dataTable).toContain('@media (max-width: 720px)')
expect(dataTable).toContain('flex-direction: column') expect(dataTable).toContain('flex-direction: column')
+4
View File
@@ -98,6 +98,10 @@ describe('admin auth session', () => {
await auth.login('platform_admin', 'password') await auth.login('platform_admin', 'password')
expect(mocks.post).toHaveBeenCalledWith('/auth/login', {
loginId: 'platform_admin',
password: 'password',
})
expect(auth.isAuthenticated).toBe(true) expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-domestic') expect(auth.refreshToken).toBe('refresh-domestic')
}) })
+1 -1
View File
@@ -176,7 +176,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
try { try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>( const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login', '/auth/login',
{ username: account, password }, { loginId: account, password },
) )
const result = unwrapApiResponse<LoginResponse>(response) const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result, expectedGeneration) await applySession(result, expectedGeneration)
@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingGroup, removeSmartLoadingItem, listContainers, listSmartLoadingCandidates, removeSmartLoadingGroup, removeSmartLoadingItem,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
getCustomerContainerTransitSummary, getCustomerContainerTransitSummary,
@@ -66,28 +66,23 @@ describe('platform container loading API', () => {
it('normalizes every long id in candidates and plans', async () => { it('normalizes every long id in candidates and plans', async () => {
vi.mocked(http.get) vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: container }) .mockResolvedValueOnce({ code: '200', data: container })
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] }) .mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] })
expect((await getContainer('9')).id).toBe(String(container.id)) expect((await getContainer('9')).id).toBe(String(container.id))
expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }]) expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }])
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } }) expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } })
}) })
it('sends plan and item versions plus operation and idempotency keys', async () => { it('sends plan and item versions plus operation identifiers', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
const normalized = await createSmartLoadingPlan('7', null)
await assignSmartLoadingItem('10', { inboundId: '2', packageCount: 4, expectedVersion: 5 }) await assignSmartLoadingItem('10', { inboundId: '2', packageCount: 4, expectedVersion: 5 })
await adjustSmartLoadingItem('10', '11', 6, 7, 8) await adjustSmartLoadingItem('10', '11', 6, 7, 8)
await removeSmartLoadingItem('10', '11', 9, 10) await removeSmartLoadingItem('10', '11', 9, 10)
await pushSmartLoadingPreload('10', 11) expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/smart-loading/plans/10/inbound-assignments')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null }) expect(vi.mocked(http.post).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-inbound-') })
expect(normalized).toMatchObject({ id: String(plan.id), version: 3, canAssign: true, canEdit: true, pdaTaskId: String(plan.pdaTaskId), pdaTaskVersion: 4, items: [{ version: 1, pickedPackageCount: 2, minPackageCount: 2, canAdjust: true, canRemove: false }] })
expect(vi.mocked(http.post).mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/smart-loading/plans/10/inbound-assignments')
expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-inbound-') })
expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') }) expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') })
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } }) expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
}) })
it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => { it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => {
@@ -252,11 +252,6 @@ export async function getSmartLoadingPlan(planId: string) {
return plan(unwrapApiResponse(response)) return plan(unwrapApiResponse(response))
} }
export async function createSmartLoadingPlan(bookingId: string, remark: string | null) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans`, { bookingId, remark })
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; packageCount: number; expectedVersion: number }) { export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/inbound-assignments`, { ...payload, operationId: operationId('assign-inbound') }) const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/inbound-assignments`, { ...payload, operationId: operationId('assign-inbound') })
return plan(unwrapApiResponse(response)) return plan(unwrapApiResponse(response))
@@ -269,7 +264,6 @@ export async function assignSmartLoadingGroup(planId: string, inboundId: string,
) )
return plan(unwrapApiResponse(response)) return plan(unwrapApiResponse(response))
} }
export async function recommendSmartLoading(planId: string, expectedVersion: number) { export async function recommendSmartLoading(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') }) const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') })
return plan(unwrapApiResponse(response)) return plan(unwrapApiResponse(response))
@@ -293,7 +287,3 @@ export async function removeSmartLoadingGroup(planId: string, groupId: string, e
return plan(unwrapApiResponse(response)) return plan(unwrapApiResponse(response))
} }
export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
return plan(unwrapApiResponse(response))
}
@@ -69,27 +69,30 @@ describe('ContainerCustomsPanel', () => {
const wrapper = await render('READY_TO_START') const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关') expect(wrapper.text()).toContain('开始报关')
expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.find('[aria-label="下载源资料 客户退税资料.pdf"]').exists()).toBe(true) expect(wrapper.text()).toContain('客户退税资料')
expect(buttonTexts(wrapper)).not.toContain('上传完成文件') expect(wrapper.text()).toContain('报关完成文件')
expect(buttonTexts(wrapper)).not.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('提交')
}) })
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => { it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false) let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('上传完成文件') expect(wrapper.text()).toContain('上传报关完成文件')
expect(wrapper.text()).toContain('提交') expect(wrapper.text()).toContain('提交')
wrapper.unmount() wrapper.unmount()
wrapper = await render('DECLARING', true, true) wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('覆盖完成文件') expect(wrapper.text()).toContain('覆盖报关完成文件')
expect(wrapper.text()).toContain('报关完成文件.pdf') expect(wrapper.text()).toContain('报关完成文件.pdf')
wrapper.unmount() wrapper.unmount()
wrapper = await render('DECLARING', false, true) wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读') 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('提交') expect(buttonTexts(wrapper)).not.toContain('提交')
}) })
@@ -98,19 +101,19 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关完成文件.pdf') expect(wrapper.text()).toContain('报关完成文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件') 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('提交') expect(buttonTexts(wrapper)).not.toContain('提交')
}) })
it('routes source and completion downloads independently by the long document id and original names', async () => { it('routes source and completion downloads independently by the long document id and original names', async () => {
const wrapper = await render('COMPLETED', false, true) const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click') await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
await flushPromises() await flushPromises()
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407') expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadCompletion).not.toHaveBeenCalled() expect(api.downloadCompletion).not.toHaveBeenCalled()
await wrapper.get('[aria-label="下载完成文件 报关完成文件.pdf"]').trigger('click') await wrapper.get('[aria-label="下载报关完成文件 报关完成文件.pdf"]').trigger('click')
await flushPromises() await flushPromises()
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407') expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2) expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
@@ -122,7 +125,7 @@ describe('ContainerCustomsPanel', () => {
api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download')) api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download'))
const wrapper = await render('COMPLETED', false, true) const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click') await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('文件不可用') expect(wrapper.text()).toContain('文件不可用')
@@ -40,7 +40,7 @@ const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefun
const allBuy = computed(() => customs.value?.taxRefundCount === 0) const allBuy = computed(() => customs.value?.taxRefundCount === 0)
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0) const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
const statusText: Record<ContainerCustomsStatus, string> = { const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成', WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
} }
function documentCustomers(document: ContainerCustomsDocument) { function documentCustomers(document: ContainerCustomsDocument) {
@@ -64,7 +64,7 @@ async function load() {
async function start() { async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return if (!customs.value || customs.value.status !== 'READY_TO_START') return
try { try {
await ElMessageBox.confirm('开始后将冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' }) await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
} catch { return } } catch { return }
actionLoading.value = true; actionError.value = null actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') } try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
@@ -75,11 +75,11 @@ async function start() {
async function uploadCompletion(document: ContainerCustomsDocument) { async function uploadCompletion(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = completionFiles[document.documentId] const selected = completionFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应完成文件'); return } if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return }
const finalConfirmation = !document.completion && missingCompletionCount.value === 1 const finalConfirmation = !document.completion && missingCompletionCount.value === 1
if (finalConfirmation) { if (finalConfirmation) {
try { try {
await ElMessageBox.confirm('这是最后一份待上传完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' }) await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
} catch { return } } catch { return }
} }
actionLoading.value = true; actionError.value = null actionLoading.value = true; actionError.value = null
@@ -88,8 +88,8 @@ async function uploadCompletion(document: ContainerCustomsDocument) {
completionFiles[document.documentId] = null completionFiles[document.documentId] = null
await load() await load()
emit('refreshed') emit('refreshed')
ElMessage.success(finalConfirmation ? '完成文件已上传,整柜报关已完成' : '完成文件已上传') ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '完成文件上传失败') } } catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
finally { actionLoading.value = false } finally { actionLoading.value = false }
} }
@@ -113,9 +113,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
? await downloadContainerCustomsDocument(document.documentId) ? await downloadContainerCustomsDocument(document.documentId)
: await downloadContainerCustomsCompletion(document.documentId) : await downloadContainerCustomsCompletion(document.documentId)
saveBlob(blob, target.fileName) saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '源资料' : '完成文件'}下载已开始`) ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`)
} catch (error) { } catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '源资料' : '完成文件'}下载失败`) actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
} finally { } finally {
downloadingKey.value = '' downloadingKey.value = ''
} }
@@ -168,17 +168,17 @@ onMounted(load)
<template v-else-if="customs"> <template v-else-if="customs">
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div> <div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div v-if="missingInbounds.length" class="compact-table-wrap"> <div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺资料清单</h3> <h3>缺退税资料清单</h3>
<table class="compact-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> <table class="compact-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"> <div v-if="customs.documents.length" class="compact-table-wrap">
<h3>冻结资料与完成文件</h3> <h3>已冻结退税资料与报关完成文件</h3>
<table class="compact-table document-table"><thead><tr><th>操作</th><th>源资料</th><th>完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody> <table class="compact-table document-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId"> <tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖完成文件' : '上传完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td> <td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(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 link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载源资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td> <td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td>
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td> <td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>资料 v{{ document.source.version }}<span v-if="document.completion"> / 完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传完成文件' : '待上传' }}</td> <td>{{ document.inboundCodes.join('、') || '—' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.completion"> / 报关完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传报关完成文件' : '待上传报关完成文件' }}</td>
</tr> </tr>
</tbody></table> </tbody></table>
</div> </div>
@@ -188,5 +188,5 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:230px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px} .container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:200px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
</style> </style>
@@ -363,7 +363,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
<AdminPage class="container-detail-page"> <AdminPage class="container-detail-page">
<section class="container-overview surface-panel"> <section class="container-overview surface-panel">
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header> <header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待录入' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div> <div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
</section> </section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail"> <template v-else-if="detail">
@@ -233,7 +233,7 @@ describe('ContainersView', () => {
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel') expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)") expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'") expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。') expect(source).toContain('货柜基础资料不提供柜号写入')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)') expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
expect(source).not.toContain('containerNo: optional(form.containerNo)') expect(source).not.toContain('containerNo: optional(form.containerNo)')
expect(source).not.toContain('updateContainerTransport') expect(source).not.toContain('updateContainerTransport')
@@ -251,7 +251,7 @@ describe('ContainersView', () => {
expect(source).toContain("void router.replace({ name: 'container-loading' })") expect(source).toContain("void router.replace({ name: 'container-loading' })")
}) })
it('normalizes the mandatory booking-link container number and keeps it as the only initial-entry write', async () => { it('normalizes an optional booking-link container number', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never) vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
const router = createTestRouter() const router = createTestRouter()
await router.push('/'); await router.isReady() await router.push('/'); await router.isReady()
@@ -278,6 +278,36 @@ describe('ContainersView', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('links a released booking while leaving the real container number pending until pickup', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
linkTarget: { id: string; version: number }
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
linkCandidateKey: string
linkContainerNo: string
saveLink: () => Promise<void>
}
vm.linkTarget = { id: '9001', version: 7 }
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
vm.linkCandidateKey = '101:202'
vm.linkContainerNo = ''
await vm.saveLink()
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: null, expectedVersion: 7,
})
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('真实集装箱号可待司机提柜时录入')
expect(source).toContain(':confirm-disabled="!linkCandidateKey"')
expect(source).toContain("row.containerNo || '待补录'")
wrapper.unmount()
})
it('keeps document uploads on the routed detail page without duplicate list implementations', () => { it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8') const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
@@ -394,16 +394,16 @@ async function openLink(source: object) {
async function saveLink() { async function saveLink() {
const target = linkTarget.value const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value) const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
const containerNo = normalizedContainerNo(linkContainerNo.value) const normalized = normalizedContainerNo(linkContainerNo.value)
linkContainerNo.value = containerNo const containerNo = normalized || null
linkContainerNo.value = normalized
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return } if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
linkSaving.value = true linkSaving.value = true
linkError.value = null linkError.value = null
try { try {
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version }) await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
linkOpen.value = false linkOpen.value = false
ElMessage.success('订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变') ElMessage.success(containerNo ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
await loadRows() await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') } } catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false } finally { linkSaving.value = false }
@@ -538,8 +538,8 @@ onMounted(async () => {
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template> <template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template> <template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></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 #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 #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template> <template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template> <template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template> <template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
@@ -564,9 +564,9 @@ onMounted(async () => {
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState> <LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog> </AppDialog>
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink"> <AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" /> <el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form> <el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号(可选)"><el-input v-model="linkContainerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p> <p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState> <LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog> </AppDialog>
@@ -576,7 +576,7 @@ onMounted(async () => {
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState> <LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert> <el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid"> <el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待录入'" disabled /><small>初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。</small></el-form-item> <el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item> <el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item> <el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item> <el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
@@ -60,6 +60,15 @@ const plan = {
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
} }
const candidate = {
accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2',
orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00',
warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9',
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类',
availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2,
availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null,
locked: null, blockReason: null,
}
describe('GlobalSmartLoadingView', () => { describe('GlobalSmartLoadingView', () => {
beforeEach(() => { beforeEach(() => {
@@ -67,7 +76,7 @@ describe('GlobalSmartLoadingView', () => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 }) vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]) vi.mocked(listSmartLoadingCandidates).mockResolvedValue([candidate])
}) })
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => { it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
@@ -146,7 +155,7 @@ describe('GlobalSmartLoadingView', () => {
const option = wrapper.find('.container-option') const option = wrapper.find('.container-option')
expect(option.text()).toContain('模拟柜次') expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('柜次号:待生成') expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待录入') expect(option.text()).toContain('集装箱号:待补录')
expect(option.text()).not.toContain('开始装柜前录入集装箱号') expect(option.text()).not.toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税') expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓') expect(option.text()).toContain('仓库:义乌国内仓')
@@ -199,6 +208,47 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain(': candidate.availablePackageCount') expect(source).toContain(': candidate.availablePackageCount')
}) })
it('filters candidates by expected arrival range and order status', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' },
{ ...candidate, inboundId: '52', inboundCode: 'IN-WRONG-STATUS', orderStatusSnapshot: 'LOADING', expectedArrivalAt: '2026-09-11T08:00:00' },
{ ...candidate, inboundId: '53', inboundCode: 'IN-WRONG-DATE', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-20T08:00:00' },
])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.getComponent(QueryToolbar).vm.$emit('search', {
customerId: '', inboundCode: '', orderStatus: 'WAIT_SPLIT', expectedArrivalRange: ['2026-09-10', '2026-09-12'],
})
await flushPromises()
const candidates = wrapper.get('.candidate-pane').text()
expect(candidates).toContain('IN-MATCH')
expect(candidates).not.toContain('IN-WRONG-STATUS')
expect(candidates).not.toContain('IN-WRONG-DATE')
})
it('paginates large inbound candidate results with twenty rows by default', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(Array.from({ length: 25 }, (_, index) => ({
...candidate,
inboundId: String(100 + index),
orderId: String(200 + index),
inboundCode: `IN-${String(index + 1).padStart(3, '0')}`,
})))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const pane = wrapper.get('.candidate-pane')
expect(pane.text()).toContain('IN-020')
expect(pane.text()).not.toContain('IN-021')
expect(pane.text()).toContain('共 25 条 · 第 1 / 2 页')
await pane.findAll('button').find((button) => button.attributes('aria-label') === '下一页')!.trigger('click')
await flushPromises()
expect(pane.text()).toContain('IN-021')
expect(pane.text()).not.toContain('IN-001')
})
it('aggregates a two-order candidate group and calls only whole-group assignment', async () => { it('aggregates a two-order candidate group and calls only whole-group assignment', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([ vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null }, { accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
@@ -260,13 +310,13 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0) expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
}) })
it('keeps only customer and inbound code filters', async () => { it('keeps the confirmed candidate filters without unrelated customs fields', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }> const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode']) expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode', 'expectedArrivalRange', 'orderStatus'])
expect(source).not.toContain('目的仓') expect(source).not.toContain('目的仓')
expect(wrapper.text()).not.toContain('税率') expect(wrapper.text()).not.toContain('税率')
@@ -529,6 +579,9 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }') expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
expect(source).toContain('grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr)') expect(source).toContain('grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr)')
expect(source).toContain('min-height: 112px') expect(source).toContain('min-height: 112px')
expect(source).toContain("label: '预计到货时间'")
expect(source).toContain("label: '订单状态'")
expect(source).toContain(':page-sizes="[20, 50, 100]"')
}) })
it('uses overflow-aware channel tabs with a compact right-aligned summary', () => { it('uses overflow-aware channel tabs with a compact right-aligned summary', () => {
@@ -548,10 +601,8 @@ describe('GlobalSmartLoadingView', () => {
expect(source).not.toContain('class="summary-band"') expect(source).not.toContain('class="summary-band"')
expect(source).toContain('class="capacity-grid"') expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐') expect(source).toContain('智能推荐')
expect(source).toContain('推送预装') expect(source).not.toContain('推送预装')
expect(source).toContain('推送预装的流程说明已由操作确认框承载') expect(source).toContain('缺少自动生成的预装计划')
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
expect(source).not.toContain('关联并放舱真实订舱') expect(source).not.toContain('关联并放舱真实订舱')
expect(source).not.toContain('订舱放舱') expect(source).not.toContain('订舱放舱')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo") expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue' import { Plus, RefreshCw, Save, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -16,11 +16,9 @@ import {
adjustSmartLoadingItem, adjustSmartLoadingItem,
assignSmartLoadingGroup, assignSmartLoadingGroup,
assignSmartLoadingItem, assignSmartLoadingItem,
createSmartLoadingPlan,
getSmartLoadingPlanByBooking, getSmartLoadingPlanByBooking,
listContainers, listContainers,
listSmartLoadingCandidates, listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading, recommendSmartLoading,
removeSmartLoadingGroup, removeSmartLoadingGroup,
removeSmartLoadingItem, removeSmartLoadingItem,
@@ -56,14 +54,14 @@ const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 }) const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({}) const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>()) const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const query = ref<QueryModel>({ customerId: '', inboundCode: '' }) const query = ref<QueryModel>({ customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' })
const appliedQuery = ref<QueryModel>({ ...query.value }) const appliedQuery = ref<QueryModel>({ ...query.value })
const candidatePage = ref(1)
const candidatePageSize = ref(20)
let selectionRequest = 0 let selectionRequest = 0
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign')) const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete')) const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null) const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus))) const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length) const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
@@ -175,9 +173,23 @@ const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / vol
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value)) const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value)) const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const candidateOrderStatusLabels: Record<string, string> = {
DRAFT: '草稿', SUBMITTED: '待审核', PENDING_REVIEW: '待审核', APPROVED: '已审核', ACCEPTED: '已受理',
REJECTED: '已驳回', PROCESSING: '处理中', WAIT_MEASURE: '待测量数据', WAIT_SPLIT: '待分柜',
LOADING: '装柜中', LOADED: '装柜完成', COMPLETED: '已完成', DELIVERED: '派送完成', CANCELLED: '已取消',
}
function candidateOrderStatusLabel(status: string) {
return candidateOrderStatusLabels[status] || '未知状态'
}
const candidateOrderStatusOptions = computed(() => [...new Set(candidates.value
.map((candidate) => candidate.orderStatusSnapshot?.trim().toUpperCase() || '')
.filter(Boolean))]
.map((status) => ({ value: status, label: candidateOrderStatusLabel(status) })))
const queryFields = computed<QueryField[]>(() => [ const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 300 },
{ key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 200, options: candidateOrderStatusOptions.value },
]) ])
const filteredContainers = computed(() => containers.value.filter((item) => ( const filteredContainers = computed(() => containers.value.filter((item) => (
@@ -188,13 +200,27 @@ const filteredContainers = computed(() => containers.value.filter((item) => (
const filteredCandidates = computed(() => { const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '') const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase() const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
const orderStatus = String(appliedQuery.value.orderStatus || '').trim().toUpperCase()
const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
: []
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
return displayedCandidates.value.filter((item) => { return displayedCandidates.value.filter((item) => {
if (!channelCode || item.channelCode !== channelCode) return false if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
return !inboundCode || item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode)) if (inboundCode && !item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))) return false
if (orderStatus && !item.sourceCandidates.some((member) => member.orderStatusSnapshot?.trim().toUpperCase() === orderStatus)) return false
if (arrivalRange.length === 2 && !item.sourceCandidates.some((member) => {
const arrivalDate = member.expectedArrivalAt?.slice(0, 10) || ''
return arrivalDate && arrivalDate >= arrivalRange[0] && arrivalDate <= arrivalRange[1]
})) return false
return true
}) })
}) })
const pagedCandidates = computed(() => {
const start = (candidatePage.value - 1) * candidatePageSize.value
return filteredCandidates.value.slice(start, start + candidatePageSize.value)
})
function preferredContainerId() { function preferredContainerId() {
const visible = filteredContainers.value const visible = filteredContainers.value
@@ -245,7 +271,7 @@ function hasRealContainerNo(container: ContainerBookingResource | null | undefin
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value) return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
} }
function displayActualContainerNo(container: ContainerBookingResource | null | undefined) { function displayActualContainerNo(container: ContainerBookingResource | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待录入' return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录'
} }
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key] for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
@@ -277,6 +303,7 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
clearSelectionData() clearSelectionData()
if (!container) return if (!container) return
if (container.planStatus === 'COMPLETED') return if (container.planStatus === 'COMPLETED') return
candidatePage.value = 1
selectionLoading.value = true selectionLoading.value = true
try { try {
const [candidateResult, planResult] = await Promise.all([ const [candidateResult, planResult] = await Promise.all([
@@ -355,17 +382,13 @@ async function selectChannel(channelCode: string) {
} }
async function applyFilters(model: QueryModel) { async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model } appliedQuery.value = { ...model }
candidatePage.value = 1
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) { if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
selectedContainerId.value = preferredContainerId() selectedContainerId.value = preferredContainerId()
await loadSelectedData() await loadSelectedData()
} }
} }
async function createPlan() {
const container = selectedContainer.value
if (!container) return
await mutate(async () => acceptPlan(await createSmartLoadingPlan(container.id, null)), '创建装柜计划失败')
}
function openAssign(source: object) { function openAssign(source: object) {
const candidate = candidateRow(source) const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return if (candidateBlocked(candidate)) return
@@ -488,18 +511,6 @@ async function removeAll() {
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`) ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
}, '全部移除失败', { pickingConflict: true }) }, '全部移除失败', { pickingConflict: true })
} }
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认')
}, '推送预装清单失败')
}
async function mutate( async function mutate(
action: () => Promise<void>, action: () => Promise<void>,
fallback: string, fallback: string,
@@ -569,9 +580,13 @@ onMounted(() => { void load(); void loadCustomers() })
<section class="dispatch-pane candidate-pane"> <section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header> <header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable <DataTable
:data="filteredCandidates" v-model:page="candidatePage"
v-model:page-size="candidatePageSize"
:data="pagedCandidates"
:columns="candidateColumns" :columns="candidateColumns"
:loading="loading || selectionLoading || mutating" :loading="loading || selectionLoading || mutating"
:total="filteredCandidates.length"
:page-sizes="[20, 50, 100]"
stripe stripe
preference-key="global-smart-loading-candidates" preference-key="global-smart-loading-candidates"
column-settings-mode="hide" column-settings-mode="hide"
@@ -628,7 +643,6 @@ onMounted(() => { void load(); void loadCustomers() })
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip> <el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button> <el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canDelete && editable && plan?.items.some((item) => item.canRemove)" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />移除未拣</el-button> <el-button v-if="canDelete && editable && plan?.items.some((item) => item.canRemove)" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />移除未拣</el-button>
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div> </div>
</header> </header>
@@ -642,9 +656,7 @@ onMounted(() => { void load(); void loadCustomers() })
<p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p> <p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p>
<el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button> <el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button>
</section> </section>
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案"> <el-empty v-else-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<template v-else-if="plan"> <template v-else-if="plan">
<!-- <!--
已下发 PDA 的可编辑说明不再常驻展示,避免挤占装柜明细空间。 已下发 PDA 的可编辑说明不再常驻展示,避免挤占装柜明细空间。
@@ -659,7 +671,6 @@ onMounted(() => { void load(); void loadCustomers() })
:title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'"
:description="plan.blockedReason || '请刷新计划获取最新作业状态。'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'"
/> />
<!-- 推送预装的流程说明已由操作确认框承载,不在方案区重复展示。 -->
<div class="capacity-grid"> <div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div> <div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div> <div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
@@ -94,15 +94,12 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).not.toContain('排柜操作失败') expect(wrapper.text()).not.toContain('排柜操作失败')
}) })
it('uses bordered row actions and keeps push independent from containerNo', () => { it('uses bordered row actions and has no manual preload push', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain') expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" type="danger" plain') expect(source).toContain('size="small" type="danger" plain')
expect(source).toContain("canPush && plan?.status === 'EDITING'") expect(source).not.toContain('canPush')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo") expect(source).not.toContain('推送预装')
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
expect(source).not.toContain('关联并放舱真实订舱') expect(source).not.toContain('关联并放舱真实订舱')
expect(source).not.toContain('订舱放舱') expect(source).not.toContain('订舱放舱')
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)') expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue' import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -11,8 +11,8 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload, getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem, recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading' } from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types' import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
@@ -37,10 +37,8 @@ const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({}) const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>()) const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign')) const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete')) const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list')) const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editable = computed(() => plan.value?.canEdit === true) const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true) const assignable = computed(() => plan.value?.canAssign === true)
@@ -180,7 +178,7 @@ function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
} }
function displayContainerNo(value: ContainerBookingResource | null | undefined) { function displayContainerNo(value: ContainerBookingResource | null | undefined) {
const containerNo = String(value?.containerNo || '').trim() const containerNo = String(value?.containerNo || '').trim()
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待录入' return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待补录'
} }
function syncDrafts(preserved: Record<string, number> = {}) { function syncDrafts(preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key] for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
@@ -222,13 +220,6 @@ function openOperationalList() {
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId }, query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
}) })
} }
async function createPlan() {
mutating.value = true
mutationError.value = null
try { await acceptPlanWithGroups(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
catch (error) { await handleMutationError(error, '创建排柜计划失败') }
finally { mutating.value = false }
}
function openAssign(source: object) { function openAssign(source: object) {
const candidate = candidateRow(source) const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return if (candidateBlocked(candidate)) return
@@ -271,7 +262,7 @@ async function recommend() {
if (!plan.value) throw error if (!plan.value) throw error
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version)) await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
} }
ElMessage.success('智能建议已生成,请人工核对后再推送') ElMessage.success('智能建议已生成,请人工核对明细')
} }
catch (error) { await handleMutationError(error, '智能推荐失败') } catch (error) { await handleMutationError(error, '智能推荐失败') }
finally { mutating.value = false } finally { mutating.value = false }
@@ -319,16 +310,6 @@ async function remove(source: object) {
finally { mutating.value = false } finally { mutating.value = false }
}) })
} }
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { await acceptPlanWithGroups(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
async function handleMutationError(error: unknown, fallback: string, pickingConflict = false) { async function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
const details = getApiErrorDetails(error, fallback) const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') { if (details.code === 'A0409') {
@@ -364,7 +345,6 @@ onMounted(load)
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button> <el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip> <el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button> <el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div> </div>
</section> </section>
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
@@ -379,7 +359,7 @@ onMounted(load)
<template v-else> <template v-else>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section> <section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section> <section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty> <el-empty v-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<template v-else-if="plan"> <template v-else-if="plan">
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" /> <el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" /> <el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
+3 -1
View File
@@ -69,7 +69,7 @@ export interface ContainerSimulationCreateRequest {
export interface ContainerOceanBookingLinkRequest { export interface ContainerOceanBookingLinkRequest {
oceanBookingId: string oceanBookingId: string
requirementId: string requirementId: string
containerNo: string containerNo: string | null
expectedVersion: number expectedVersion: number
} }
@@ -235,6 +235,8 @@ export interface SmartLoadingCandidate {
orderId: string orderId: string
orderNo: string | null orderNo: string | null
inboundCode: string | null inboundCode: string | null
orderStatusSnapshot?: string | null
expectedArrivalAt?: string | null
warehouseId: string warehouseId: string
channelCode: string | null channelCode: string | null
channelName: string | null channelName: string | null
@@ -117,6 +117,9 @@ describe('CustomerDetailView', () => {
expect(document.querySelector('.overview-strip')).toBeNull() expect(document.querySelector('.overview-strip')).toBeNull()
expect(document.body.textContent).toContain('merchant_admin') expect(document.body.textContent).toContain('merchant_admin')
expect(document.body.textContent).toContain('企业管理员') expect(document.body.textContent).toContain('企业管理员')
expect(document.body.textContent).toContain('公司联系电话')
expect(document.body.textContent).toContain('登录手机号')
expect(document.body.textContent).toContain('修改会影响登录')
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id) expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
expect(document.body.textContent).toContain('已替换') expect(document.body.textContent).toContain('已替换')
@@ -365,7 +365,7 @@ watch(orderPage, (page, previousPage) => {
<section class="detail-panel contact-panel"> <section class="detail-panel contact-panel">
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div> <div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
<dl class="info-grid"> <dl class="info-grid">
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div> <div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div> <div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
<div><dt>客户套餐</dt><dd>{{ displayText(customer.servicePackage) }}</dd></div><div><dt>账期</dt><dd>{{ customer.paymentTermDays ?? 0 }} 天</dd></div> <div><dt>客户套餐</dt><dd>{{ displayText(customer.servicePackage) }}</dd></div><div><dt>账期</dt><dd>{{ customer.paymentTermDays ?? 0 }} 天</dd></div>
<div><dt>销售人员</dt><dd>{{ displayText(customer.salesOwnerName) }}</dd></div><div><dt>入驻时间</dt><dd>{{ formatDate(customer.createdAt) }}</dd></div> <div><dt>销售人员</dt><dd>{{ displayText(customer.salesOwnerName) }}</dd></div><div><dt>入驻时间</dt><dd>{{ formatDate(customer.createdAt) }}</dd></div>
@@ -373,13 +373,14 @@ watch(orderPage, (page, previousPage) => {
</dl> </dl>
</section> </section>
<section class="detail-panel user-panel"> <section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户的登录账号</p></div><UsersRound :size="19" /></div> <div class="panel-title"><div><h2>企业用户</h2><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></div><UsersRound :size="19" /></div>
<div v-if="enterpriseMembers.length" class="user-table-wrap"> <div v-if="enterpriseMembers.length" class="user-table-wrap">
<table><thead><tr><th>账号</th><th>成员类型</th><th>联系方式</th><th>角色</th><th>状态</th></tr></thead> <table><thead><tr><th>账号</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th><th>状态</th></tr></thead>
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)"> <tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
<td><div class="user-identity"><span>{{ (member.displayName || member.username || '?').slice(0, 1) }}</span><div><strong>{{ displayText(member.username) }}</strong><small>{{ displayText(member.displayName) }}</small></div></div></td> <td><div class="user-identity"><span>{{ (member.displayName || member.username || '?').slice(0, 1) }}</span><div><strong>{{ displayText(member.username) }}</strong><small>{{ displayText(member.displayName) }}</small></div></div></td>
<td>{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}</td> <td>{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}</td>
<td><div class="member-contact"><span>{{ displayText(member.phone) }}</span><small>{{ displayText(member.email) }}</small></div></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><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> <td><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light" size="small">{{ member.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></td>
</tr></tbody> </tr></tbody>
+182 -16
View File
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus' import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
approveEnterpriseReview, approveEnterpriseReview,
archivePlatformTenant, archivePlatformTenant,
createPlatformTenant,
downloadTenantQualification, downloadTenantQualification,
createPlatformTenantImpersonation, createPlatformTenantImpersonation,
getPlatformTenant, getPlatformTenant,
@@ -15,6 +16,7 @@ import {
listTenantQualifications, listTenantQualifications,
rejectEnterpriseReview, rejectEnterpriseReview,
resetPlatformTenantOwnerPassword, resetPlatformTenantOwnerPassword,
submitPlatformTenantReview,
updatePlatformTenant, updatePlatformTenant,
uploadPlatformTenantQualification, uploadPlatformTenantQualification,
uploadPlatformTenantRequiredQualificationPackage, uploadPlatformTenantRequiredQualificationPackage,
@@ -104,6 +106,12 @@ function tableButtonByText(text: string) {
.find((button) => button.textContent?.trim() === text) .find((button) => button.textContent?.trim() === text)
} }
function selectQualificationFile(index: number, file: File) {
const input = document.querySelectorAll<HTMLInputElement>('.qualification-file input[type="file"]')[index]
Object.defineProperty(input, 'files', { configurable: true, value: [file] })
input.dispatchEvent(new Event('change', { bubbles: true }))
}
async function clickMoreAction(text: string) { async function clickMoreAction(text: string) {
document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')?.click() document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')?.click()
await flushPromises() await flushPromises()
@@ -139,6 +147,10 @@ describe('CustomersView', () => {
vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant) vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([]) vi.mocked(listTenantQualifications).mockResolvedValue([])
vi.mocked(listEnterpriseReviews).mockResolvedValue([]) vi.mocked(listEnterpriseReviews).mockResolvedValue([])
vi.mocked(createPlatformTenant).mockResolvedValue({
tenant: fullTenant, administratorUsername: 'merchant_owner', temporaryPassword: 'Temporary#2026',
})
vi.mocked(submitPlatformTenantReview).mockResolvedValue({ ...fullTenant, status: 'PENDING' })
vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant) vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({ vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({
qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png', qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png',
@@ -187,7 +199,7 @@ describe('CustomersView', () => {
await flushPromises() await flushPromises()
expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id) expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id)
const phone = document.querySelector<HTMLInputElement>('input[placeholder="请输入手机号或固定电话"]') const phone = document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')
const email = document.querySelector<HTMLInputElement>('input[placeholder="如:contact@example.com"]') const email = document.querySelector<HTMLInputElement>('input[placeholder="如:contact@example.com"]')
expect(phone?.value).toBe(fullTenant.contactPhone) expect(phone?.value).toBe(fullTenant.contactPhone)
expect(email?.value).toBe(fullTenant.contactEmail) expect(email?.value).toBe(fullTenant.contactEmail)
@@ -212,19 +224,83 @@ describe('CustomersView', () => {
expect(preview?.src).toBe('blob:qualification-preview') expect(preview?.src).toBe('blob:qualification-preview')
}) })
it('marks only the business license and legal-person ID as required qualifications', async () => { it('marks only the business license as a required file when creating a customer', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:create']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises() await flushPromises()
tableButtonByText('编辑')?.click() ;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '新增客户')?.click()
await flushPromises() await flushPromises()
const labels = [...document.querySelectorAll<HTMLElement>('.qualification-label')] const labels = [...document.querySelectorAll<HTMLElement>('.qualification-label')]
expect(labels.map((label) => label.textContent?.replace(/\s/g, ''))).toEqual([ expect(labels.map((label) => label.textContent?.replace(/\s/g, ''))).toEqual([
'*营业执照', '*营业执照',
'*法人身份证', '法人身份证选填',
'货代资质证书选填', '货代资质证书选填',
'银行开户许可证选填', '银行开户许可证选填',
]) ])
expect(document.body.textContent).toContain('营业执照和18位统一社会信用代码为必填业务项')
})
it('creates a customer with a business license and no legal-person ID', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:create']
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '新增客户')?.click()
await flushPromises()
await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName)
await wrapper.get('input[placeholder="请输入营业执照上的企业名称"]').setValue(fullTenant.legalName)
await wrapper.get('input[placeholder="请输入法定代表人姓名"]').setValue(fullTenant.legalRepresentative)
await wrapper.get('input[placeholder="请输入主要联系人姓名"]').setValue(fullTenant.contactName)
await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone)
await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode)
const licenseFile = new File(['license'], 'license.png', { type: 'image/png' })
selectQualificationFile(0, licenseFile)
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({
tenantName: fullTenant.tenantName,
contactPhone: fullTenant.contactPhone,
}))
expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() }))
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, false, false,
)
})
it('keeps the company contact phone editable without changing a member login phone', async () => {
const activeTenant = { ...fullTenant, status: 'ACTIVE' as const }
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant)
vi.mocked(updatePlatformTenant).mockResolvedValue(activeTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null,
}])
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const phone = wrapper.get('input[placeholder="请输入公司联系电话"]')
expect(phone.attributes('disabled')).toBeUndefined()
await phone.setValue('0755-12345678')
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' }))
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
expect(customersSource).not.toContain('联系电话(登录用户名)')
}) })
it('does not count rejected or superseded history as a current qualification when resubmitting', async () => { it('does not count rejected or superseded history as a current qualification when resubmitting', async () => {
@@ -272,11 +348,11 @@ describe('CustomersView', () => {
await flushPromises() await flushPromises()
expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id) expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id)
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入手机号或固定电话"]')?.value).toBe(fullTenant.contactPhone) expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')?.value).toBe(fullTenant.contactPhone)
expect(routerReplace).toHaveBeenCalledWith({ name: 'customers' }) expect(routerReplace).toHaveBeenCalledWith({ name: 'customers' })
}) })
it('saves both required files and the credit code through one package action', async () => { it('saves the business license, credit code and optional legal-person ID through one package action', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{ vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID',
certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null, certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null,
@@ -288,13 +364,8 @@ describe('CustomersView', () => {
const licenseFile = new File(['license'], 'license.png', { type: 'image/png' }) const licenseFile = new File(['license'], 'license.png', { type: 'image/png' })
const legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' }) const legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' })
const qualificationInputs = document.querySelectorAll<HTMLInputElement>('.qualification-file input[type="file"]') selectQualificationFile(0, licenseFile)
const licenseInput = qualificationInputs[0] selectQualificationFile(1, legalPersonFile)
const legalPersonInput = qualificationInputs[1]
Object.defineProperty(licenseInput, 'files', { configurable: true, value: [licenseFile] })
licenseInput?.dispatchEvent(new Event('change', { bubbles: true }))
Object.defineProperty(legalPersonInput, 'files', { configurable: true, value: [legalPersonFile] })
legalPersonInput?.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises() await flushPromises()
const buttons = [...document.querySelectorAll<HTMLButtonElement>('button')] const buttons = [...document.querySelectorAll<HTMLButtonElement>('button')]
@@ -314,6 +385,101 @@ describe('CustomersView', () => {
expect(uploadPlatformTenantQualification).not.toHaveBeenCalled() expect(uploadPlatformTenantQualification).not.toHaveBeenCalled()
}) })
it('replaces only the business license while preserving an existing legal-person ID', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([
{ id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null },
{ id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null },
])
vi.mocked(uploadPlatformTenantRequiredQualificationPackage).mockResolvedValueOnce({
businessLicenseQualificationId: 'qualification-business-new', legalPersonIdQualificationId: null,
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/files/license-new.png', legalPersonIdDocumentUrl: null,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const licenseFile = new File(['license-new'], 'license-new.png', { type: 'image/png' })
selectQualificationFile(0, licenseFile)
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, true, false,
)
expect(uploadPlatformTenantQualification).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('法人身份证可按需补充')
})
it('uploads an optional legal-person ID independently when the required business license already exists', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null,
}])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' })
selectQualificationFile(1, legalPersonFile)
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(uploadPlatformTenantRequiredQualificationPackage).not.toHaveBeenCalled()
expect(uploadPlatformTenantQualification).toHaveBeenCalledWith(
fullTenant.id, 'LEGAL_PERSON_ID', legalPersonFile, undefined, undefined, false,
)
})
it('retains a selected business license after an upload failure so the operator can retry', async () => {
vi.mocked(uploadPlatformTenantRequiredQualificationPackage)
.mockRejectedValueOnce(new Error('upload unavailable'))
.mockResolvedValueOnce({
businessLicenseQualificationId: 'qualification-business', legalPersonIdQualificationId: null,
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/files/license-new.png', legalPersonIdDocumentUrl: null,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const licenseFile = new File(['license-new'], 'retry-license.png', { type: 'image/png' })
selectQualificationFile(0, licenseFile)
await flushPromises()
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))
saveButton?.click()
await flushPromises()
expect(document.body.textContent).toContain('retry-license.png')
saveButton?.click()
await flushPromises()
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledTimes(2)
})
it('submits a customer for review with a business license and no legal-person ID', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null,
}])
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '提交审核')?.click()
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalled()
expect(submitPlatformTenantReview).toHaveBeenCalledWith(fullTenant.id)
})
it('submits the current filters to the backend export service without loading every customer page', async () => { it('submits the current filters to the backend export service without loading every customer page', async () => {
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises() await flushPromises()
@@ -398,7 +564,7 @@ describe('CustomersView', () => {
.find((button) => button.textContent?.trim() === '审核通过')?.click() .find((button) => button.textContent?.trim() === '审核通过')?.click()
await flushPromises() await flushPromises()
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过') expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)')
expect(rejectEnterpriseReview).not.toHaveBeenCalled() expect(rejectEnterpriseReview).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('请立即保存以下一次性凭证') expect(document.body.textContent).toContain('请立即保存以下一次性凭证')
expect(document.body.textContent).toContain('merchant_8_owner') expect(document.body.textContent).toContain('merchant_8_owner')
@@ -459,7 +625,7 @@ describe('CustomersView', () => {
.find((button) => button.textContent?.trim() === '审核通过')?.click() .find((button) => button.textContent?.trim() === '审核通过')?.click()
await flushPromises() await flushPromises()
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过') expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)')
expect(rejectEnterpriseReview).not.toHaveBeenCalled() expect(rejectEnterpriseReview).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('注册账号继续作为企业管理员') expect(document.body.textContent).toContain('注册账号继续作为企业管理员')
expect(document.body.textContent).not.toContain('请立即保存以下一次性凭证') expect(document.body.textContent).not.toContain('请立即保存以下一次性凭证')
+56 -57
View File
@@ -9,7 +9,6 @@ import {
approveEnterpriseReview, approveEnterpriseReview,
archivePlatformTenant, archivePlatformTenant,
changePlatformTenantStatus, changePlatformTenantStatus,
checkPlatformTenantContactPhone,
createPlatformTenant, createPlatformTenant,
createPlatformTenantImpersonation, createPlatformTenantImpersonation,
downloadTenantQualification, downloadTenantQualification,
@@ -148,11 +147,14 @@ const rules: FormRules = {
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }], tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }], legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
unifiedSocialCreditCode: [{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }], unifiedSocialCreditCode: [
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
],
contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }], contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [ contactPhone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' }, { required: true, message: '请输入公司联系电话', trigger: 'blur' },
{ pattern: /^[0-9-]{4,32}$/, message: '请输入 4-32 位数字或连字符,联系电话将作为登录用户名', trigger: 'blur' }, { pattern: /^[0-9-]{4,32}$/, message: '请输入 4-32 位数字或连字符', trigger: 'blur' },
], ],
contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }], contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }],
servicePackage: [{ required: true, message: '请选择客户套餐', trigger: 'change' }], servicePackage: [{ required: true, message: '请选择客户套餐', trigger: 'change' }],
@@ -171,7 +173,7 @@ const decisionRules: FormRules = {
function newQualificationDrafts(): QualificationDraft[] { function newQualificationDrafts(): QualificationDraft[] {
const definitions: Array<Pick<QualificationDraft, 'type' | 'label' | 'required'>> = [ const definitions: Array<Pick<QualificationDraft, 'type' | 'label' | 'required'>> = [
{ type: 'BUSINESS_LICENSE', label: '营业执照', required: true }, { type: 'BUSINESS_LICENSE', label: '营业执照', required: true },
{ type: 'LEGAL_PERSON_ID', label: '法人身份证', required: true }, { type: 'LEGAL_PERSON_ID', label: '法人身份证', required: false },
{ type: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', required: false }, { type: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', required: false },
{ type: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', required: false }, { type: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', required: false },
] ]
@@ -200,7 +202,7 @@ const customerColumns: DataTableColumn[] = [
{ key: 'company', label: '公司名称', width: 250, slotName: 'company' }, { key: 'company', label: '公司名称', width: 250, slotName: 'company' },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) }, { key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
{ key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) }, { key: 'contactPhone', prop: 'contactPhone', label: '公司联系电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
{ key: 'salesOwnerName', prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) }, { key: 'salesOwnerName', prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) },
{ key: 'paymentTermDays', prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 }, { key: 'paymentTermDays', prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'status', label: '状态', width: 110, slotName: 'status' },
@@ -221,23 +223,16 @@ const uploadingQualifications = computed(() => qualifications.value.some((item)
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file)) const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
const selectedBusinessLicense = computed(() => qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.file) const selectedBusinessLicense = computed(() => qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.file)
const selectedLegalPersonId = computed(() => qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')?.file) const selectedLegalPersonId = computed(() => qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')?.file)
const requiredPackageChangeSelected = computed(() => Boolean(selectedBusinessLicense.value || selectedLegalPersonId.value)) const businessLicenseReadyForSave = computed(() => Boolean(selectedBusinessLicense.value
const requiredPackageFilesSelected = computed(() => Boolean(selectedBusinessLicense.value && selectedLegalPersonId.value)) || qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.uploaded))
const creditCodeReadyForSave = computed(() => requiredPackageChangeSelected.value const creditCodeReadyForSave = computed(() => creditCodeEstablished.value
? requiredPackageFilesSelected.value && (creditCodeEstablished.value || unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || ''))
|| unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) const requiredQualificationPackageComplete = computed(() => businessLicenseReadyForSave.value && creditCodeReadyForSave.value)
: creditCodeEstablished.value)
const requiredQualificationPackageComplete = computed(() => creditCodeReadyForSave.value
&& (requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value
: qualifications.value.filter((item) => item.required).every((item) => item.uploaded)))
const missingQualificationPackageItems = computed(() => { const missingQualificationPackageItems = computed(() => {
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE') const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
const missing: string[] = [] const missing: string[] = []
if (requiredPackageChangeSelected.value ? !businessLicense?.file : !businessLicense?.uploaded) missing.push('营业执照') if (!businessLicense?.file && !businessLicense?.uploaded) missing.push('营业执照')
if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随必填资质包保存)') if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随营业执照保存)')
if (requiredPackageChangeSelected.value ? !legalPersonId?.file : !legalPersonId?.uploaded) missing.push('法人身份证')
return missing return missing
}) })
const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value) const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value)
@@ -512,27 +507,38 @@ async function uploadSelectedQualifications(tenantId: string) {
const failures: string[] = [] const failures: string[] = []
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE') const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID') const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
if (requiredPackageChangeSelected.value && businessLicense?.file && legalPersonId?.file) { const businessLicenseFile = businessLicense?.file || null
businessLicense.uploading = true; legalPersonId.uploading = true const legalPersonIdFile = legalPersonId?.file || null
businessLicense.error = ''; legalPersonId.error = '' if (businessLicense && businessLicenseFile) {
businessLicense.uploading = true
if (legalPersonIdFile && legalPersonId) legalPersonId.uploading = true
businessLicense.error = ''
if (legalPersonId) legalPersonId.error = ''
try { try {
const uploaded = await uploadPlatformTenantRequiredQualificationPackage( const uploaded = await uploadPlatformTenantRequiredQualificationPackage(
tenantId, businessLicense.file, legalPersonId.file, form.value.unifiedSocialCreditCode, tenantId, businessLicenseFile, legalPersonIdFile, form.value.unifiedSocialCreditCode,
businessLicense.uploaded, legalPersonId.uploaded, businessLicense.uploaded, Boolean(legalPersonIdFile && legalPersonId?.uploaded),
) )
businessLicense.uploaded = true; legalPersonId.uploaded = true if (legalPersonIdFile && !uploaded.legalPersonIdDocumentUrl) throw new Error('法人身份证上传结果缺少文件地址')
businessLicense.uploaded = true
creditCodeEstablished.value = true creditCodeEstablished.value = true
businessLicense.uploadedName = businessLicense.file.name businessLicense.uploadedName = businessLicenseFile.name
legalPersonId.uploadedName = legalPersonId.file.name
businessLicense.documentUrl = uploaded.businessLicenseDocumentUrl businessLicense.documentUrl = uploaded.businessLicenseDocumentUrl
legalPersonId.documentUrl = uploaded.legalPersonIdDocumentUrl businessLicense.file = null
businessLicense.file = null; legalPersonId.file = null if (legalPersonIdFile && legalPersonId) {
legalPersonId.uploaded = true
legalPersonId.uploadedName = legalPersonIdFile.name
legalPersonId.documentUrl = uploaded.legalPersonIdDocumentUrl || ''
legalPersonId.file = null
}
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, '必填资质包上传失败,请重试') const message = getApiErrorMessage(error, '营业执照及统一社会信用代码保存失败,请重试')
businessLicense.error = message; legalPersonId.error = message businessLicense.error = message
failures.push('必填资质包') if (legalPersonIdFile && legalPersonId) legalPersonId.error = message
failures.push('营业执照资质')
} finally { } finally {
businessLicense.uploading = false; legalPersonId.uploading = false businessLicense.uploading = false
if (legalPersonId) legalPersonId.uploading = false
} }
} }
for (const draft of failures.length ? [] : qualifications.value.filter((item) => !item.required && item.file)) { for (const draft of failures.length ? [] : qualifications.value.filter((item) => !item.required && item.file)) {
@@ -556,26 +562,15 @@ async function uploadSelectedQualifications(tenantId: string) {
async function saveCustomer() { async function saveCustomer() {
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
if (!editingId.value) { if (!requiredQualificationPackageComplete.value) {
try {
const available = await checkPlatformTenantContactPhone(form.value.contactPhone.trim())
if (!available) {
ElMessage.error('联系电话已被其他客户使用,请更换后再提交')
return
}
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '联系电话唯一性校验失败,请稍后重试'))
return
}
}
const qualificationPackageTouched = hasSelectedQualifications.value || Boolean(form.value.unifiedSocialCreditCode)
if (qualificationPackageTouched && !requiredQualificationPackageComplete.value) {
ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`) ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`)
return return
} }
saving.value = true saving.value = true
try { try {
const hadSelectedQualifications = hasSelectedQualifications.value const hadSelectedQualifications = hasSelectedQualifications.value
const hadBusinessLicense = Boolean(selectedBusinessLicense.value)
const hadLegalPersonId = Boolean(selectedLegalPersonId.value)
let saved: PlatformTenantResource let saved: PlatformTenantResource
if (editingId.value) { if (editingId.value) {
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
@@ -599,8 +594,12 @@ async function saveCustomer() {
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : [] const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
if (failures.length) { if (failures.length) {
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`) ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
} else if (hadBusinessLicense && hadLegalPersonId) {
ElMessage.success('营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核')
} else if (hadBusinessLicense) {
ElMessage.success('营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核')
} else if (hadSelectedQualifications) { } else if (hadSelectedQualifications) {
ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核') ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
} else { } else {
ElMessage.success('客户资料已保存,尚未提交审核') ElMessage.success('客户资料已保存,尚未提交审核')
} }
@@ -619,7 +618,7 @@ async function submitReview() {
if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return } if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return }
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return } if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return }
if (!creditCodeEstablished.value || !unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) { if (!creditCodeEstablished.value || !unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) {
ElMessage.warning('请先完整保存营业执照、18位统一社会信用代码和法人身份证') ElMessage.warning('请先完整保存营业执照和18位统一社会信用代码;法人身份证为选填项')
return return
} }
const missing = qualifications.value.filter((item) => item.required && !item.uploaded) const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
@@ -857,7 +856,7 @@ async function openDecision(row: PlatformTenantResource, type: 'approve' | 'reje
} }
selectedReview.value = review selectedReview.value = review
decision.type = type decision.type = type
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : '' decision.comment = type === 'approve' ? '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)' : ''
decisionOpen.value = true decisionOpen.value = true
await loadReviewQualifications() await loadReviewQualifications()
void nextTick(() => decisionFormRef.value?.clearValidate('comment')) void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
@@ -1006,7 +1005,7 @@ watch(
) )
watch(() => decision.type, (type) => { watch(() => decision.type, (type) => {
if (!decisionOpen.value) return if (!decisionOpen.value) return
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : '' decision.comment = type === 'approve' ? '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)' : ''
void nextTick(() => decisionFormRef.value?.clearValidate('comment')) void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -1064,7 +1063,7 @@ onMounted(() => { void loadCustomers() })
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid two-cols"> <FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid two-cols">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item> <el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
<el-form-item label="联系电话(登录用户名)" prop="contactPhone" required><el-input v-model="form.contactPhone" :disabled="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" placeholder="请输入手机号或固定电话" maxlength="32" /><small v-if="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" class="field-hint">客户已开户,联系电话作为登录用户名不可修改</small></el-form-item> <el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /><small class="field-hint">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</small></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item> <el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
<el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item> <el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item>
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> --> <!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
@@ -1072,8 +1071,8 @@ onMounted(() => { void loadCustomers() })
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item> <el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection> </div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection>
<FormSection class="qualification-section" title="企业认证必填业务项" description="营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div> <FormSection class="qualification-section" title="企业认证资料" description="营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照、法人身份证共同保存。' }}</small></el-form-item> <el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item>
<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 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-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> <div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
@@ -1127,13 +1126,13 @@ onMounted(() => { void loadCustomers() })
<div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '未填写' }}</dd></div> <div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '未填写' }}</dd></div>
<div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '未生成' }}</dd></div> <div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '未生成' }}</dd></div>
<div><dt>联系人</dt><dd>{{ selectedReview.contactName || '未填写' }}</dd></div> <div><dt>联系人</dt><dd>{{ selectedReview.contactName || '未填写' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ selectedReview.contactPhone || '未填写' }}</dd></div> <div><dt>公司联系电话</dt><dd>{{ selectedReview.contactPhone || '未填写' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ selectedReview.contactEmail || '未填写' }}</dd></div> <div><dt>联系邮箱</dt><dd>{{ selectedReview.contactEmail || '未填写' }}</dd></div>
<div><dt>提交时间</dt><dd>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</dd></div> <div><dt>提交时间</dt><dd>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</dd></div>
</dl> </dl>
</section> </section>
<section class="review-dialog-section review-dialog-card"> <section class="review-dialog-section review-dialog-card">
<div class="review-section-heading"><div><h3>资质文件</h3><span>共 {{ reviewQualifications.length }} 份,逐项查看后再作决定</span></div><FileCheck2 :size="18" /></div> <div class="review-section-heading"><div><h3>资质文件</h3><span>营业执照为必填;法人身份证及其他资质为选填。共 {{ reviewQualifications.length }} 份</span></div><FileCheck2 :size="18" /></div>
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div> <div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div> <div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
<div v-else class="review-document-table" role="table" aria-label="待审核客户资质"> <div v-else class="review-document-table" role="table" aria-label="待审核客户资质">
@@ -3,6 +3,10 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/CustomsCasesView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/CustomsCasesView.vue'), 'utf8')
describe('CustomsCasesView', () => { describe('CustomsCasesView', () => {
it('uses a wide customer filter and compact status filter', () => {
expect(source).toContain("key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220")
expect(source).toContain("key: 'status', type: 'select', label: '状态', width: 160")
})
it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") }) it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") })
it('supports detail, history and agent submission', () => { expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') }) it('supports detail, history and agent submission', () => { expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') })
it('selects an active customs supplier and submits its stable identity and name snapshot', () => { it('selects an active customs supplier and submits its stable identity and name snapshot', () => {
@@ -23,7 +23,7 @@ const customers = ref<PlatformTenantResource[]>([]); const customersLoading = re
const customsAgents = ref<Supplier[]>([]); const customsAgentsLoading = ref(false); const customsAgentsError = ref('') const customsAgents = ref<Supplier[]>([]); const customsAgentsLoading = ref(false); const customsAgentsError = ref('')
const filters = ref<QueryModel>({ customerId: '', status: '' }); const fields = computed<QueryField[]>(() => [ const filters = ref<QueryModel>({ customerId: '', status: '' }); const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'status', type: 'select', label: '状态', options: customsCaseStatusOptions.map((option) => ({ ...option })) }, { key: 'status', type: 'select', label: '状态', width: 160, options: customsCaseStatusOptions.map((option) => ({ ...option })) },
]) ])
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { key: 'orderLink', label: '订单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.orderId) }, { key: 'inboundLink', label: '入库单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.inboundId) }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }] const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { 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 ? '已关联' : '未关联' } function associationLabel(value: string | null | undefined) { return value ? '已关联' : '未关联' }
@@ -4,6 +4,7 @@ import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/s
export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED' export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
export type KnownExportTaskType = export type KnownExportTaskType =
| 'CUSTOMER_LIST' | 'CUSTOMER_LIST'
| 'DOMESTIC_WAREHOUSE_ORDER_LIST'
| 'FINANCE_CUSTOMER_ACCOUNT_LIST' | 'FINANCE_CUSTOMER_ACCOUNT_LIST'
| 'FINANCE_CUSTOMER_ACCOUNT_LEDGER' | 'FINANCE_CUSTOMER_ACCOUNT_LEDGER'
| 'FINANCE_FEE_SOURCE_LIST' | 'FINANCE_FEE_SOURCE_LIST'
@@ -56,7 +57,7 @@ export interface CreatePlatformExportTaskRequest {
exportType: ExportTaskType exportType: ExportTaskType
format: 'XLSX' format: 'XLSX'
taskName: string taskName: string
parameters: Record<string, string | number | boolean | null | undefined> parameters: Record<string, string | number | boolean | null | undefined | string[]>
} }
const basePath = '/platform/v1/files/export-tasks' const basePath = '/platform/v1/files/export-tasks'
@@ -27,6 +27,7 @@ type DownloadFilters = QueryModel & {
const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = { const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = {
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' }, CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' },
FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' }, FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_CUSTOMER_ACCOUNT_LEDGER: { label: '客户账户流水', retryPermission: 'platform:finance:customer-account:export' }, FINANCE_CUSTOMER_ACCOUNT_LEDGER: { label: '客户账户流水', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_FEE_SOURCE_LIST: { label: '客户应收应付费用来源', retryPermission: 'platform:finance:customer-arap:export' }, FINANCE_FEE_SOURCE_LIST: { label: '客户应收应付费用来源', retryPermission: 'platform:finance:customer-arap:export' },
@@ -64,7 +64,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).not.toContain('class="detail-toolbar"') expect(page).not.toContain('class="detail-toolbar"')
expect(page).not.toContain('<AppDrawer') expect(page).not.toContain('<AppDrawer')
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker) for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
for (const code of ['RECONCILIATION_CREATED', 'SETTLEMENT_ISSUED', 'RECEIPT', 'PAYMENT', 'ADJUSTMENT', 'VOID']) expect(page).toContain(code) for (const code of ['RECONCILIATION_CREATED', 'SETTLEMENT_ISSUED', 'RECEIPT',
'SUPPLIER_DIRECT_PAID', 'SUPPLIER_ON_ACCOUNT_PAYMENT', 'ADJUSTMENT', 'VOID']) expect(page).toContain(code)
for (const removed of ['FEE_INCLUDED', 'FEE_EXCLUDED', '纳入结算', '排除结算']) expect(page).not.toContain(removed) for (const removed of ['FEE_INCLUDED', 'FEE_EXCLUDED', '纳入结算', '排除结算']) expect(page).not.toContain(removed)
for (const obsolete of ["value: 'FEE_SOURCE'", "value: 'RECONCILIATION'", "value: 'SETTLEMENT'"]) expect(page).not.toContain(obsolete) for (const obsolete of ["value: 'FEE_SOURCE'", "value: 'RECONCILIATION'", "value: 'SETTLEMENT'"]) expect(page).not.toContain(obsolete)
}) })
@@ -77,7 +77,9 @@ const columns: DataTableColumn[] = [
const ledgerQueryFields: QueryField[] = [ const ledgerQueryFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [ { key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
{ label: '生成对账单', value: 'RECONCILIATION_CREATED' }, { label: '结算单生效', value: 'SETTLEMENT_ISSUED' }, { label: '生成对账单', value: 'RECONCILIATION_CREATED' }, { label: '结算单生效', value: 'SETTLEMENT_ISSUED' },
{ label: '收款', value: 'RECEIPT' }, { label: '付款', value: 'PAYMENT' }, { label: '收款', value: 'RECEIPT' },
{ label: '供应商线下直付', value: 'SUPPLIER_DIRECT_PAID' },
{ label: '供应商账期付款', value: 'SUPPLIER_ON_ACCOUNT_PAYMENT' },
{ label: '账务调整', value: 'ADJUSTMENT' }, { label: '作废', value: 'VOID' }, { label: '账务调整', value: 'ADJUSTMENT' }, { label: '作废', value: 'VOID' },
] }, ] },
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 }, { key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
+2
View File
@@ -32,6 +32,8 @@ describe('finance status presentation', () => {
expect(financeBusinessTypeLabel('RECONCILIATION_CREATED')).toBe('生成对账单') expect(financeBusinessTypeLabel('RECONCILIATION_CREATED')).toBe('生成对账单')
expect(financeBusinessTypeLabel('SETTLEMENT_ISSUED')).toBe('结算单生效') expect(financeBusinessTypeLabel('SETTLEMENT_ISSUED')).toBe('结算单生效')
expect(financeBusinessTypeLabel('PAYMENT')).toBe('付款') expect(financeBusinessTypeLabel('PAYMENT')).toBe('付款')
expect(financeBusinessTypeLabel('SUPPLIER_DIRECT_PAID')).toBe('供应商线下直付')
expect(financeBusinessTypeLabel('SUPPLIER_ON_ACCOUNT_PAYMENT')).toBe('供应商账期付款')
expect(financeBusinessTypeLabel('VOID')).toBe('作废') expect(financeBusinessTypeLabel('VOID')).toBe('作废')
expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型') expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型')
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单') expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
+2
View File
@@ -66,6 +66,8 @@ const financeBusinessTypeLabels: Record<string, string> = {
SETTLEMENT_ISSUED: '结算单生效', SETTLEMENT_ISSUED: '结算单生效',
RECEIPT: '收款', RECEIPT: '收款',
PAYMENT: '付款', PAYMENT: '付款',
SUPPLIER_DIRECT_PAID: '供应商线下直付',
SUPPLIER_ON_ACCOUNT_PAYMENT: '供应商账期付款',
ADJUSTMENT: '账务调整', ADJUSTMENT: '账务调整',
VOID: '作废', VOID: '作废',
// The following values are returned by current Finance read models. // The following values are returned by current Finance read models.
+21 -1
View File
@@ -377,7 +377,7 @@ describe('platform IAM roles', () => {
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/submit-review', {}) expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/submit-review', {})
}) })
it('uploads both required files and the credit code in one package request', async () => { it('uploads the business license, credit code and optional legal-person ID in one package request', async () => {
const result = { const result = {
businessLicenseQualificationId: '91', legalPersonIdQualificationId: '92', businessLicenseQualificationId: '91', legalPersonIdQualificationId: '92',
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/license.pdf', tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/license.pdf',
@@ -400,6 +400,26 @@ describe('platform IAM roles', () => {
expect(form.get('replaceLegalPersonId')).toBe('true') expect(form.get('replaceLegalPersonId')).toBe('true')
}) })
it('omits the optional legal-person ID parts when only the business license is uploaded', async () => {
const result = {
businessLicenseQualificationId: '91', legalPersonIdQualificationId: null,
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/license.pdf',
legalPersonIdDocumentUrl: null,
}
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
const businessLicense = new File(['%PDF-1.4'], '营业执照.pdf', { type: 'application/pdf' })
await expect(uploadPlatformTenantRequiredQualificationPackage(
'8', businessLicense, null, '91440300MA5ABCDE12', true, false,
)).resolves.toEqual(result)
const form = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
expect(form.get('businessLicense')).toBe(businessLicense)
expect(form.get('legalPersonId')).toBeNull()
expect(form.get('replaceBusinessLicense')).toBe('true')
expect(form.get('replaceLegalPersonId')).toBeNull()
})
it('preserves long review identifiers and returns one-time administrator credentials', async () => { it('preserves long review identifiers and returns one-time administrator credentials', async () => {
const reviewId = '190000000000000001' const reviewId = '190000000000000001'
const approval = { const approval = {
+4 -4
View File
@@ -128,10 +128,10 @@ export interface PlatformQualificationUploadResponse {
export interface PlatformRequiredQualificationPackageUploadResponse { export interface PlatformRequiredQualificationPackageUploadResponse {
businessLicenseQualificationId: number | string businessLicenseQualificationId: number | string
legalPersonIdQualificationId: number | string legalPersonIdQualificationId: number | string | null
tenantStatus: PlatformTenantStatus tenantStatus: PlatformTenantStatus
businessLicenseDocumentUrl: string businessLicenseDocumentUrl: string
legalPersonIdDocumentUrl: string legalPersonIdDocumentUrl: string | null
} }
export interface EnterpriseApprovalResponse { export interface EnterpriseApprovalResponse {
@@ -429,14 +429,14 @@ export async function uploadPlatformTenantQualification(
export async function uploadPlatformTenantRequiredQualificationPackage( export async function uploadPlatformTenantRequiredQualificationPackage(
tenantId: number | string, tenantId: number | string,
businessLicense: File, businessLicense: File,
legalPersonId: File, legalPersonId: File | null,
unifiedSocialCreditCode: string, unifiedSocialCreditCode: string,
replaceBusinessLicense = false, replaceBusinessLicense = false,
replaceLegalPersonId = false, replaceLegalPersonId = false,
) { ) {
const form = new FormData() const form = new FormData()
form.append('businessLicense', businessLicense) form.append('businessLicense', businessLicense)
form.append('legalPersonId', legalPersonId) if (legalPersonId) form.append('legalPersonId', legalPersonId)
form.append('unifiedSocialCreditCode', unifiedSocialCreditCode) form.append('unifiedSocialCreditCode', unifiedSocialCreditCode)
if (replaceBusinessLicense) form.append('replaceBusinessLicense', 'true') if (replaceBusinessLicense) form.append('replaceBusinessLicense', 'true')
if (replaceLegalPersonId) form.append('replaceLegalPersonId', 'true') if (replaceLegalPersonId) form.append('replaceLegalPersonId', 'true')
@@ -108,6 +108,26 @@ describe('InboundTaskList measurement scope', () => {
expect(source).not.toContain("name: 'inbound-order-detail', params: { inboundId: row.id }") expect(source).not.toContain("name: 'inbound-order-detail', params: { inboundId: row.id }")
}) })
it('closes only after navigating an approved review to the measurement tab', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const handler = source.slice(source.indexOf('async function handleTaskChanged'), source.indexOf('function handleReviewSelectionChange'))
expect(handler).toContain("name: 'unified-order-detail-by-inbound-code'")
expect(handler).toContain('params: { inboundCode }')
expect(handler).toContain("query: { tab: 'measurement', from: 'measurement-review' }")
expect(handler).toContain('await router.push')
expect(handler.indexOf('await router.push')).toBeLessThan(handler.lastIndexOf('reviewDialogOpen.value = false'))
expect(handler).toContain("getApiErrorDetails(error, '订单详情打开失败,请重试')")
expect(handler).toContain("name: 'unified-order-detail'")
expect(handler).toContain("query: { tenantId: target.tenantId, tab: 'measurement', from: 'measurement-review' }")
expect(source).toContain(':post-approve-loading="reviewNavigationLoading"')
expect(source).toContain(':approval-completed="reviewApproved"')
expect(source).toContain(':close-on-click-modal="!reviewNavigationLoading"')
expect(source).toContain('reviewNavigationError')
expect(source).toContain('重新打开订单详情')
expect(source).not.toContain('params: { inboundId: target.id }')
})
it('shows authoritative measurement totals with operation first and no internal IDs as primary columns', () => { it('shows authoritative measurement totals with operation first and no internal IDs as primary columns', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8') const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const columnsSource = source.slice(source.indexOf('const columns'), source.indexOf('function text')) const columnsSource = source.slice(source.indexOf('const columns'), source.indexOf('function text'))
@@ -45,6 +45,9 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null) const selectedInbound = ref<InboundOrderSummary | null>(null)
const measurementDialogOpen = ref(false) const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false) const reviewDialogOpen = ref(false)
const reviewNavigationLoading = ref(false)
const reviewApproved = ref(false)
const reviewNavigationError = ref<ApiErrorDetails | null>(null)
const relationshipDialogOpen = ref(false) const relationshipDialogOpen = ref(false)
const relationshipDialogInboundCode = ref('') const relationshipDialogInboundCode = ref('')
const batchReviewLoading = ref(false) const batchReviewLoading = ref(false)
@@ -184,7 +187,11 @@ function resetFilters() {
function openTask(row: InboundOrderSummary) { function openTask(row: InboundOrderSummary) {
selectedInbound.value = row selectedInbound.value = row
if (isReview.value) reviewDialogOpen.value = true if (isReview.value) {
reviewApproved.value = false
reviewNavigationError.value = null
reviewDialogOpen.value = true
}
else measurementDialogOpen.value = true else measurementDialogOpen.value = true
} }
@@ -200,10 +207,47 @@ function openRelationship(row: InboundOrderSummary) {
relationshipDialogOpen.value = true relationshipDialogOpen.value = true
} }
function handleTaskChanged() { async function handleTaskChanged() {
reviewDialogOpen.value = false const target = selectedInbound.value
selectedInbound.value = null if (!isReview.value) {
void loadTasks() reviewDialogOpen.value = false
selectedInbound.value = null
void loadTasks()
return
}
if (!target || reviewNavigationLoading.value) return
reviewApproved.value = true
reviewNavigationError.value = null
const inboundCode = target.inboundCode?.trim()
const orderId = target.orderId?.trim()
if (!inboundCode && !orderId) {
reviewNavigationError.value = { message: '审核已通过,但未找到对应订单,请关闭弹框后刷新列表重试' }
return
}
reviewNavigationLoading.value = true
try {
if (inboundCode) {
await router.push({
name: 'unified-order-detail-by-inbound-code',
params: { inboundCode },
query: { tab: 'measurement', from: 'measurement-review' },
})
} else {
await router.push({
name: 'unified-order-detail',
params: { orderId },
query: { tenantId: target.tenantId, tab: 'measurement', from: 'measurement-review' },
})
}
reviewDialogOpen.value = false
selectedInbound.value = null
} catch (error) {
reviewNavigationError.value = getApiErrorDetails(error, '订单详情打开失败,请重试')
} finally {
reviewNavigationLoading.value = false
}
} }
function handleReviewSelectionChange(rows: InboundOrderSummary[]) { function handleReviewSelectionChange(rows: InboundOrderSummary[]) {
@@ -266,6 +310,9 @@ function handleMeasurementSubmitted() {
function clearSelectedInbound() { function clearSelectedInbound() {
selectedInbound.value = null selectedInbound.value = null
reviewApproved.value = false
reviewNavigationError.value = null
reviewNavigationLoading.value = false
} }
watch([page, pageSize], () => { void loadTasks() }) watch([page, pageSize], () => { void loadTasks() })
@@ -367,9 +414,16 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
top="4vh" top="4vh"
destroy-on-close destroy-on-close
append-to-body append-to-body
:close-on-click-modal="!reviewNavigationLoading"
:close-on-press-escape="!reviewNavigationLoading"
class="inbound-task-dialog" class="inbound-task-dialog"
@closed="clearSelectedInbound" @closed="clearSelectedInbound"
> >
<el-alert v-if="reviewNavigationError" type="error" :closable="false" show-icon class="review-navigation-error">
<template #title>{{ reviewNavigationError.message }}</template>
<ApiErrorMeta :details="reviewNavigationError" />
<el-button v-if="selectedInbound?.inboundCode || selectedInbound?.orderId" link type="danger" :loading="reviewNavigationLoading" @click="handleTaskChanged">重新打开订单详情</el-button>
</el-alert>
<MeasurementReviewPanel <MeasurementReviewPanel
v-if="selectedInbound" v-if="selectedInbound"
:tenant-id="selectedInbound.tenantId" :tenant-id="selectedInbound.tenantId"
@@ -377,6 +431,8 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
:customer-name="selectedInbound.customerName || selectedInbound.companyName" :customer-name="selectedInbound.customerName || selectedInbound.companyName"
:channel-name="selectedInbound.channelName" :channel-name="selectedInbound.channelName"
:channel-code="selectedInbound.channelCode" :channel-code="selectedInbound.channelCode"
:post-approve-loading="reviewNavigationLoading"
:approval-completed="reviewApproved"
@changed="handleTaskChanged" @changed="handleTaskChanged"
/> />
</el-dialog> </el-dialog>
@@ -398,6 +454,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<style scoped> <style scoped>
.inbound-task-page { min-width: 0; } .inbound-task-page { min-width: 0; }
.state-stack { display: grid; gap: 12px; } .state-stack { display: grid; gap: 12px; }
.review-navigation-error { margin-bottom: 14px; }
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; } .row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.row-actions .el-button { margin: 0; } .row-actions .el-button { margin: 0; }
.primary-cell { display: grid; gap: 3px; min-width: 0; } .primary-cell { display: grid; gap: 3px; min-width: 0; }
@@ -152,7 +152,7 @@ describe('InboundFeePanel', () => {
expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([ expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([
'实际重量', '实际重量',
'实际体积', '实际体积',
'折算量', '体积重量',
'取大后的计费量', '取大后的计费量',
]) ])
expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([ expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([
+2 -2
View File
@@ -376,13 +376,13 @@ defineExpose({ reload: loadFee })
<section class="calculation-section" aria-labelledby="calculation-title"> <section class="calculation-section" aria-labelledby="calculation-title">
<div class="section-title"> <div class="section-title">
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际量与折算量取大后计费,金额以服务端快照为准。</p></div> <div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag> <el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
</div> </div>
<div class="calculation-flow"> <div class="calculation-flow">
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div> <div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div> <div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
<div><span>折算量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div> <div><span>体积重量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div> <div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
</div> </div>
<div class="amount-equation" aria-label="费用计算公式"> <div class="amount-equation" aria-label="费用计算公式">
@@ -195,11 +195,73 @@ describe('MeasurementReviewPanel', () => {
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
await flushPromises() await flushPromises()
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(3) expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(2)
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 8 }) expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 8 })
expect(wrapper.emitted('changed')).toHaveLength(1) expect(wrapper.emitted('changed')).toHaveLength(1)
}) })
it('emits only after approval succeeds and blocks duplicate submissions', async () => {
let finishApproval: (() => void) | undefined
vi.mocked(approveMeasurement).mockImplementation(() => new Promise((resolve) => {
finishApproval = () => resolve({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'CALCULATED', initialPricingSucceeded: true, initialPricingMessage: null, initialFee: null })
}))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const approveButton = wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!
await approveButton.trigger('click')
await approveButton.trigger('click')
expect(approveMeasurement).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('changed')).toBeUndefined()
finishApproval?.()
await flushPromises()
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(1)
})
it('keeps the review panel open and does not emit when approval fails', async () => {
vi.mocked(approveMeasurement).mockRejectedValue(new Error('审核写入失败'))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
await flushPromises()
expect(wrapper.emitted('changed')).toBeUndefined()
expect(wrapper.find('.review-panel').exists()).toBe(true)
expect(wrapper.findAll('button').some((button) => button.text().includes('通过并进入待分柜'))).toBe(true)
})
it('blocks approval while the order detail navigation is loading', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, {
props: { customerId: '10001', inboundId: '90001', postApproveLoading: true },
global: { plugins: [ElementPlus] },
})
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
expect(approveMeasurement).not.toHaveBeenCalled()
})
it('does not submit approval again after approval succeeded but navigation needs retrying', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, {
props: { customerId: '10001', inboundId: '90001', approvalCompleted: true },
global: { plugins: [ElementPlus] },
})
await flushPromises()
const approveButton = wrapper.findAll('button').find((button) => button.text().includes('审核已通过'))!
expect(approveButton.attributes('disabled')).toBeDefined()
await approveButton.trigger('click')
expect(approveMeasurement).not.toHaveBeenCalled()
})
it('shows historical rejected data read-only without new reject or release operations', async () => { it('shows historical rejected data read-only without new reject or release operations', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '历史审核说明' }) vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '历史审核说明' })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } }) const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
@@ -21,6 +21,8 @@ const props = defineProps<{
customerName?: string | null customerName?: string | null
channelName?: string | null channelName?: string | null
channelCode?: string | null channelCode?: string | null
postApproveLoading?: boolean
approvalCompleted?: boolean
}>() }>()
const emit = defineEmits<{ changed: [] }>() const emit = defineEmits<{ changed: [] }>()
@@ -129,7 +131,7 @@ async function handleWeightChanged() {
} }
async function confirmApprove() { async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?', '审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
@@ -147,7 +149,6 @@ async function confirmApprove() {
}) })
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算') if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`) else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
await reload()
emit('changed') emit('changed')
} catch (error) { } catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message) ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
@@ -183,7 +184,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
</div> </div>
<div class="review-toolbar__actions"> <div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button> <el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button> <el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : '通过并进入待分柜' }}</el-button>
<el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button> <el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div> </div>
</div> </div>
@@ -93,26 +93,26 @@ describe('inbound weight OCR API', () => {
data: { records, page: '1', size: '20', total: '2', pages: '1' }, data: { records, page: '1', size: '20', total: '2', pages: '1' },
}) })
await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual({ await expect(getWeightPalletOptions('10001', '20001', { page: 1, size: 50 })).resolves.toEqual({
records, page: 1, size: 20, total: 2, pages: 1, records, page: 1, size: 20, total: 2, pages: 1,
}) })
expect(http.get).toHaveBeenCalledWith( expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options', '/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
{ params: { tenantId: '10001' } }, { params: { tenantId: '10001', page: 1, size: 50, keyword: undefined } },
) )
}) })
it('passes an abort signal without changing the frozen pallet endpoint parameters', async () => { it('passes remote page, keyword and abort signal through the frozen pallet endpoint', async () => {
vi.mocked(http.get).mockResolvedValue({ vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' }, code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' },
}) })
const controller = new AbortController() const controller = new AbortController()
await getWeightPalletOptions('10001', '20001', controller.signal) await getWeightPalletOptions('10001', '20001', { page: 2, size: 50, keyword: ' 木托盘 ', signal: controller.signal })
expect(http.get).toHaveBeenCalledWith( expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options', '/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
{ params: { tenantId: '10001' }, signal: controller.signal }, { params: { tenantId: '10001', page: 2, size: 50, keyword: '木托盘' }, signal: controller.signal },
) )
}) })
+11 -2
View File
@@ -74,10 +74,19 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) {
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) { export async function getWeightPalletOptions(tenantId: string, inboundId: string, query: {
page?: number
size?: number
keyword?: string
signal?: AbortSignal
} = {}) {
const keyword = query.keyword?.trim()
const response = await http.get<ApiEnvelope<WeightPalletOptionPageResponse>, ApiEnvelope<WeightPalletOptionPageResponse>>( const response = await http.get<ApiEnvelope<WeightPalletOptionPageResponse>, ApiEnvelope<WeightPalletOptionPageResponse>>(
`${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`, `${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`,
{ params: { tenantId }, ...(signal ? { signal } : {}) }, {
params: { tenantId, page: query.page ?? 1, size: query.size ?? 50, keyword: keyword || undefined },
...(query.signal ? { signal: query.signal } : {}),
},
) )
const page = unwrapApiResponse(response) const page = unwrapApiResponse(response)
return { return {
@@ -1,5 +1,6 @@
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -221,7 +222,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
await flushPromises() await flushPromises()
await openWeightDialog(wrapper) await openWeightDialog(wrapper)
const dialogText = document.body.textContent || '' const dialogText = document.body.querySelector('.el-dialog')?.textContent || ''
expect(dialogText).toContain('当前托盘皮重') expect(dialogText).toContain('当前托盘皮重')
expect(dialogText).toContain('12.000 KG') expect(dialogText).toContain('12.000 KG')
expect(dialogText).toContain('OCR 识别毛重') expect(dialogText).toContain('OCR 识别毛重')
@@ -360,6 +361,24 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.text()).not.toContain('可选托盘加载失败') expect(wrapper.text()).not.toContain('可选托盘加载失败')
}) })
it('remote search keyword uses page 1 and size 50', async () => {
const wrapper = mountPanel()
await flushPromises()
vi.useFakeTimers()
try {
await wrapper.find('.pallet-picker .el-select').trigger('click')
await wrapper.find('.pallet-picker input').setValue(' 木托盘 ')
await vi.advanceTimersByTimeAsync(350)
await flushPromises()
expect(getWeightPalletOptions).toHaveBeenLastCalledWith('10001', '20001', expect.objectContaining({
page: 1, size: 50, keyword: '木托盘', signal: expect.any(AbortSignal),
}))
} finally {
vi.useRealTimers()
}
})
it('shows pallet loading failures without hiding existing weight results', async () => { it('shows pallet loading failures without hiding existing weight results', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503)) vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
const wrapper = mountPanel() const wrapper = mountPanel()
@@ -369,6 +388,17 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.findAll('.weight-card')).toHaveLength(1) expect(wrapper.findAll('.weight-card')).toHaveLength(1)
}) })
it('shows a network error as a pallet candidate load failure without hiding historical snapshots', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new AxiosError('Network Error', 'ERR_NETWORK'))
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain('可选托盘加载失败')
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('12.000 KG')
})
it('does not load weight or pallet data without measurement view permission', async () => { it('does not load weight or pallet data without measurement view permission', async () => {
const wrapper = mountPanel([]) const wrapper = mountPanel([])
await flushPromises() await flushPromises()
@@ -378,13 +408,14 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(getWeightPalletOptions).not.toHaveBeenCalled() expect(getWeightPalletOptions).not.toHaveBeenCalled()
}) })
it('keeps historical pallet snapshots visible without requesting candidates for a read-only operator', async () => { it('keeps historical palletCode snapshots visible for a read-only operator', async () => {
const wrapper = mountPanel(['platform:firstleg:inbound:measure:view']) const wrapper = mountPanel(['platform:firstleg:inbound:measure:view'])
await flushPromises() await flushPromises()
expect(getWeightWorkspace).toHaveBeenCalledOnce() expect(getWeightWorkspace).toHaveBeenCalledOnce()
expect(getWeightPalletOptions).not.toHaveBeenCalled() expect(getWeightPalletOptions).not.toHaveBeenCalled()
expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false) expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false)
expect(wrapper.text()).toContain('TP-001')
expect(wrapper.text()).toContain('木托盘') expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('12.000 KG') expect(wrapper.text()).toContain('12.000 KG')
}) })
@@ -424,7 +455,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
})) }))
const wrapper = mountPanel() const wrapper = mountPanel()
await flushPromises() await flushPromises()
const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2] const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]?.signal
wrapper.unmount() wrapper.unmount()
resolveOptions?.(palletPage([{ resolveOptions?.(palletPage([{
@@ -45,6 +45,7 @@ const fileInput = ref<HTMLInputElement>()
const workspace = ref<WeightWorkspaceResource | null>(null) const workspace = ref<WeightWorkspaceResource | null>(null)
const palletOptions = ref<WeightPalletOption[]>([]) const palletOptions = ref<WeightPalletOption[]>([])
const selectedPalletId = ref('') const selectedPalletId = ref('')
const palletKeyword = ref('')
const palletLoading = ref(false) const palletLoading = ref(false)
const palletError = ref<ApiErrorDetails | null>(null) const palletError = ref<ApiErrorDetails | null>(null)
const loading = ref(false) const loading = ref(false)
@@ -61,6 +62,7 @@ const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB
let loadSequence = 0 let loadSequence = 0
let palletLoadSequence = 0 let palletLoadSequence = 0
let palletAbortController: AbortController | null = null let palletAbortController: AbortController | null = null
const PALLET_OPTION_PAGE_SIZE = 50
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const minimumWeight = { KG: 0.001, LB: 0.002 } as const const minimumWeight = { KG: 0.001, LB: 0.002 } as const
@@ -225,7 +227,7 @@ async function loadWorkspace() {
} }
} }
async function loadPalletOptions() { async function loadPalletOptions(keyword = palletKeyword.value) {
if (!canLoadPalletOptions.value) { if (!canLoadPalletOptions.value) {
cancelPalletOptionsLoad() cancelPalletOptionsLoad()
palletOptions.value = [] palletOptions.value = []
@@ -237,10 +239,16 @@ async function loadPalletOptions() {
const sequence = ++palletLoadSequence const sequence = ++palletLoadSequence
const controller = new AbortController() const controller = new AbortController()
palletAbortController = controller palletAbortController = controller
palletKeyword.value = keyword.trim()
palletLoading.value = true palletLoading.value = true
palletError.value = null palletError.value = null
try { try {
const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal) const result = await getWeightPalletOptions(customerId.value, props.inboundId, {
page: 1,
size: PALLET_OPTION_PAGE_SIZE,
keyword: palletKeyword.value || undefined,
signal: controller.signal,
})
if (sequence !== palletLoadSequence || controller.signal.aborted) return if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = result.records palletOptions.value = result.records
if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = '' if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
@@ -257,6 +265,14 @@ async function loadPalletOptions() {
} }
} }
function searchPalletOptions(keyword: string) {
void loadPalletOptions(keyword)
}
function reloadPalletOptions() {
void loadPalletOptions()
}
function cancelPalletOptionsLoad() { function cancelPalletOptionsLoad() {
++palletLoadSequence ++palletLoadSequence
palletAbortController?.abort() palletAbortController?.abort()
@@ -417,6 +433,7 @@ watch(
workspace.value = null workspace.value = null
palletOptions.value = [] palletOptions.value = []
selectedPalletId.value = '' selectedPalletId.value = ''
palletKeyword.value = ''
palletError.value = null palletError.value = null
weightDrafts.value = {} weightDrafts.value = {}
loadError.value = null loadError.value = null
@@ -433,6 +450,7 @@ watch(
cancelPalletOptionsLoad() cancelPalletOptionsLoad()
palletOptions.value = [] palletOptions.value = []
selectedPalletId.value = '' selectedPalletId.value = ''
palletKeyword.value = ''
palletError.value = null palletError.value = null
if (canLoadPalletOptions.value) void loadPalletOptions() if (canLoadPalletOptions.value) void loadPalletOptions()
}, },
@@ -472,6 +490,8 @@ defineExpose({ reload: loadWorkspace })
aria-label="选择称重托盘" aria-label="选择称重托盘"
placeholder="选择称重托盘" placeholder="选择称重托盘"
filterable filterable
remote
:remote-method="searchPalletOptions"
:loading="palletLoading" :loading="palletLoading"
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)" :disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
/> />
@@ -519,7 +539,7 @@ defineExpose({ reload: loadWorkspace })
:loading="palletLoading" :loading="palletLoading"
compact compact
class="weight-action-error" class="weight-action-error"
@retry="loadPalletOptions" @retry="reloadPalletOptions"
> >
<template #details><ApiErrorMeta :details="palletError" /></template> <template #details><ApiErrorMeta :details="palletError" /></template>
</LoadErrorState> </LoadErrorState>
@@ -531,7 +551,7 @@ defineExpose({ reload: loadWorkspace })
class="weight-action-error" class="weight-action-error"
title="当前仓库暂无可选托盘,暂时不能上传称重图片。" title="当前仓库暂无可选托盘,暂时不能上传称重图片。"
> >
<el-button size="small" @click="loadPalletOptions"><RefreshCw :size="14" />重新加载</el-button> <el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
</el-alert> </el-alert>
<div v-loading="loading" class="weight-list" aria-live="polite"> <div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty"> <div v-if="!loading && rows.length === 0" class="weight-empty">
@@ -566,6 +586,7 @@ defineExpose({ reload: loadWorkspace })
@click="openPreview(row)" @click="openPreview(row)"
><strong>{{ cardWeight(row) }}</strong></button> ><strong>{{ cardWeight(row) }}</strong></button>
<dl class="weight-card__facts"> <dl class="weight-card__facts">
<div><dt>托盘编码</dt><dd :title="row.palletCode">{{ row.palletCode || '未记录编码' }}</dd></div>
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div> <div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div> <div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div> <div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
@@ -30,7 +30,6 @@ const preloadSummary = {
createdAt: '2026-08-10T10:00:00', createdAt: '2026-08-10T10:00:00',
version: 3, version: 3,
readiness: { readiness: {
preloadPushed: true,
containerNoReady: true, containerNoReady: true,
preloadLinkCount: 1, preloadLinkCount: 1,
confirmedPreloadCount: 0, confirmedPreloadCount: 0,
@@ -54,7 +53,7 @@ const detail = {
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null, dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null, internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null,
}], }],
histories: [{ historyId: 21, itemId: 11, actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }], histories: [{ historyId: 21, itemId: 11, operationId: 'transport-21', actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }],
} }
describe('platform loading lists API', () => { describe('platform loading lists API', () => {
@@ -78,7 +77,7 @@ describe('platform loading lists API', () => {
expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' }) expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' })
expect(result.summary.containerBookingId).toBe('1900000000000000004') expect(result.summary.containerBookingId).toBe('1900000000000000004')
expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operatorId: '22' }) expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' })
}) })
it('updates transport with expectedVersion, warehouse and idempotency key', async () => { it('updates transport with expectedVersion, warehouse and idempotency key', async () => {
@@ -9,9 +9,7 @@ export interface LoadingCustomer {
export type PreloadConfirmationStatus = 'PENDING' | 'PARTIALLY_CONFIRMED' | 'CONFIRMED' export type PreloadConfirmationStatus = 'PENDING' | 'PARTIALLY_CONFIRMED' | 'CONFIRMED'
export type PreloadBlockingReason = export type PreloadBlockingReason =
| 'PRELOAD_NOT_PUSHED'
| 'LOADING_PLAN_NOT_EDITING' | 'LOADING_PLAN_NOT_EDITING'
| 'CONTAINER_NO_MISSING'
| 'PRELOAD_LINK_MISSING' | 'PRELOAD_LINK_MISSING'
| 'OCEAN_BOOKING_NOT_LINKED' | 'OCEAN_BOOKING_NOT_LINKED'
| 'OCEAN_BOOKING_NOT_RELEASED' | 'OCEAN_BOOKING_NOT_RELEASED'
@@ -19,7 +17,6 @@ export type PreloadBlockingReason =
| 'PRELOAD_ALREADY_CONFIRMED' | 'PRELOAD_ALREADY_CONFIRMED'
export interface PreloadReadiness { export interface PreloadReadiness {
preloadPushed: boolean
containerNoReady: boolean containerNoReady: boolean
preloadLinkCount: number preloadLinkCount: number
confirmedPreloadCount: number confirmedPreloadCount: number
@@ -115,6 +112,7 @@ export interface LoadingItem {
export interface LoadingHistory { export interface LoadingHistory {
historyId: string historyId: string
itemId: string | null itemId: string | null
operationId: string
actionType: string actionType: string
fromStatus: string | null fromStatus: string | null
toStatus: string | null toStatus: string | null
@@ -251,6 +249,7 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
...raw, ...raw,
historyId: requiredId(raw.historyId), historyId: requiredId(raw.historyId),
itemId: optionalId(raw.itemId), itemId: optionalId(raw.itemId),
operationId: requiredId(raw.operationId),
operatorId: requiredId(raw.operatorId), operatorId: requiredId(raw.operatorId),
} }
} }
@@ -9,7 +9,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import ContainerFeesPanel from './ContainerFeesPanel.vue' import ContainerFeesPanel from './ContainerFeesPanel.vue'
import { loadingListStatusLabel } from '../status' import { loadingListStatusLabel } from '../status'
import type { ApiErrorDetails } from '@/shared/api/http' import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists' import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
const props = defineProps<{ const props = defineProps<{
mode: LoadingListMode mode: LoadingListMode
@@ -29,7 +29,7 @@ const baseItemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 }, { key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true }, { key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '—' }, { key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '待补录' },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
@@ -56,10 +56,46 @@ const summary = computed<LoadingListSummary | null>(() => props.detail?.summary
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null) const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
const canEditItemRemark = computed(() => props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')) const canEditItemRemark = computed(() => props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || ''))
const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns) const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)
const volatileSnapshotFields = new Set(['requestHash', 'version', 'updatedAt', 'operatorId', 'operatorName', 'occurredAt'])
function stableSnapshotValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableSnapshotValue)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.filter(([key]) => !volatileSnapshotFields.has(key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, stableSnapshotValue(nested)]))
}
function normalizedSnapshot(value: string | null) {
if (!value) return null
try { return stableSnapshotValue(JSON.parse(value) as unknown) } catch { return value }
}
function isPureStatusNoop(history: LoadingHistory) {
return history.actionType === 'STATUS_UPDATE'
&& history.fromStatus === history.toStatus
&& JSON.stringify(normalizedSnapshot(history.beforeSnapshot)) === JSON.stringify(normalizedSnapshot(history.afterSnapshot))
}
function historyEventKey(history: LoadingHistory) {
if (history.operationId) return `operation:${history.operationId}`
return history.historyId ? `history:${history.historyId}` : null
}
const visibleHistories = computed(() => {
const seen = new Set<string>()
return (props.detail?.histories || []).filter((history) => {
if (isPureStatusNoop(history)) return false
const eventKey = historyEventKey(history)
if (!eventKey) return true
if (seen.has(eventKey)) return false
seen.add(eventKey)
return true
})
})
const blockingReasonLabels: Record<string, string> = { const blockingReasonLabels: Record<string, string> = {
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态', LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
CONTAINER_NO_MISSING: '尚未录入集装箱号',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据', PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
OCEAN_BOOKING_NOT_LINKED: '尚未关联真实订舱', OCEAN_BOOKING_NOT_LINKED: '尚未关联真实订舱',
OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱', OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱',
@@ -127,14 +163,14 @@ function formatNumber(value?: number | null, digits = 3) {
</div> </div>
<div v-if="detail && summary" class="detail-page-header__actions"> <div v-if="detail && summary" class="detail-page-header__actions">
<template v-if="canEditItemRemark"> <template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />纠正集装箱号 / 航次</el-button> <el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button> <el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
</template> </template>
</div> </div>
</header> </header>
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息"> <section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div> <div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待录入' }}</strong></div> <div><span>集装箱号</span><strong>{{ summary.containerNo || '待补录' }}</strong></div>
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div> <div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div> <div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
<template v-if="preloadSummary"> <template v-if="preloadSummary">
@@ -188,10 +224,10 @@ function formatNumber(value?: number | null, digits = 3) {
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" /> <ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<section class="history-section" aria-label="操作历史"> <section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header> <header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" /> <el-empty v-if="visibleHistories.length === 0" description="暂无操作历史" :image-size="72" />
<el-timeline v-else> <el-timeline v-else>
<el-timeline-item v-for="history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top"> <el-timeline-item v-for="history in visibleHistories" :key="historyEventKey(history) || history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
<div class="history-entry"> <div class="history-entry">
<strong>{{ actionLabel(history.actionType) }}</strong> <strong>{{ actionLabel(history.actionType) }}</strong>
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span> <span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
@@ -96,9 +96,9 @@ describe('LoadingListsView', () => {
it('keeps transport editing as a detail-page action', () => { it('keeps transport editing as a detail-page action', () => {
expect(source).not.toContain('openEditFromRow(row)') expect(source).not.toContain('openEditFromRow(row)')
expect(source).not.toContain('编辑运输信息') expect(source).not.toContain('编辑运输信息')
expect(detailSource).toContain('纠正集装箱号 / 航次') expect(detailSource).toContain("summary.containerNo || '待补录'")
expect(detailSource).toContain('v-if="canEditTransport"') expect(detailSource).toContain('v-if="canEditTransport"')
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')") expect(preloadDetailSource).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
}) })
it('refreshes the detail version on A0409 while preserving transport input', () => { it('refreshes the detail version on A0409 while preserving transport input', () => {
@@ -121,7 +121,6 @@ describe('LoadingListsView', () => {
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => { it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"') expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
expect(detailSource).toContain("PRELOAD_NOT_PUSHED: '尚未推送预装清单'")
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'") expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'") expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm") expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
@@ -240,4 +239,19 @@ describe('LoadingListsView', () => {
expect(detailSource).not.toContain('customsDocument') expect(detailSource).not.toContain('customsDocument')
expect(detailSource).not.toContain('报关资料') expect(detailSource).not.toContain('报关资料')
}) })
it('filters pure status no-ops and counts identity-distinct visible history events', () => {
for (const marker of ['visibleHistories', 'STATUS_UPDATE', 'operationId', 'PUSH_PRELOAD']) expect(detailSource).toContain(marker)
expect(detailSource).toContain('visibleHistories.length }} 条')
expect(detailSource).toContain('v-for="history in visibleHistories"')
expect(detailSource).not.toContain('detail.histories.length }} 条')
expect(detailSource).not.toContain('v-for="history in detail.histories"')
expect(detailSource).toContain('seen.has(eventKey)')
expect(detailSource).toContain('history.operationId')
const sameWording = [
{ historyId: '4', operationId: '', actionType: 'ADJUST_ITEM', operationReason: '相同文案' },
{ historyId: '5', operationId: '', actionType: 'ADJUST_ITEM', operationReason: '相同文案' },
]
expect(new Set(sameWording.map((history) => history.historyId)).size).toBe(2)
})
}) })
@@ -79,7 +79,7 @@ describe('PreloadPlanDetailView lifecycle handoff', () => {
vi.mocked(getPreloadPlan).mockResolvedValue({ vi.mocked(getPreloadPlan).mockResolvedValue({
summary: { summary: {
status: 'LOADING', containerNo: null, status: 'LOADING', containerNo: null,
readiness: { preloadPushed: true, pdaTaskStatus: null }, readiness: { pdaTaskStatus: null },
}, },
items: [], histories: [], items: [], histories: [],
} as never) } as never)
@@ -61,20 +61,22 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain(':confirm-loading="remarkSaving"') expect(source).toContain(':confirm-loading="remarkSaving"')
}) })
it('keeps preload as a correction-only entry before PDA claim and does not imply that preload directly opens PDA', () => { it('supports first entry and reasoned correction before PDA claim without blocking earlier flow', () => {
expect(source).toContain("{ required: true, message: '请输入集装箱号'") expect(source).toContain("{ required: true, message: '请输入集装箱号'")
expect(source).toContain("summary.status === 'EDITING'") expect(source).toContain("summary?.status === 'EDITING'")
expect(source).toContain("(!pdaStatus || pdaStatus === 'PENDING')") expect(source).toContain("(!pdaStatus || pdaStatus === 'PENDING')")
expect(source).not.toContain('summary?.containerNo && summary.status')
expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。') expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。')
expect(source).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')") expect(source).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
expect(source).toContain("{ required: true, message: '请输入操作原因'") expect(source).toContain("isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因')")
expect(source).toContain("String(value || '').trim().toUpperCase()") expect(source).toContain("String(value || '').trim().toUpperCase()")
expect(source).toContain('operationReason: editForm.operationReason.trim()') expect(source).toContain('operationReason: editForm.operationReason.trim() || null')
expect(detailSource).toContain('v-if="canEditTransport"') expect(detailSource).toContain('v-if="canEditTransport"')
expect(detailSource).not.toContain("preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'") expect(detailSource).toContain("summary.containerNo || '待补录'")
expect(source).toContain('由服务端按真实条件创建唯一 PDA 任务') expect(source).toContain('由服务端按真实条件创建唯一 PDA 任务')
expect(source).toContain('真实集装箱号未录入时不能确认') expect(source).toContain('缺号不阻断此前流程')
expect(source).toContain('PDA 领取或作业开始后集装箱号只读') expect(source).toContain('PDA 确认装柜完成前由服务端执行最终门禁')
expect(source).not.toContain('真实集装箱号未录入时不能确认')
}) })
it('uses a flat blocking note instead of repeated colored page alerts', () => { it('uses a flat blocking note instead of repeated colored page alerts', () => {
@@ -51,17 +51,19 @@ const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
const canCorrectTransport = computed(() => { const canCorrectTransport = computed(() => {
const summary = detail.value?.summary const summary = detail.value?.summary
const pdaStatus = summary?.readiness.pdaTaskStatus const pdaStatus = summary?.readiness.pdaTaskStatus
return Boolean(canEdit.value && summary?.containerNo && summary.status === 'EDITING' return Boolean(canEdit.value && summary?.status === 'EDITING'
&& summary.readiness.preloadPushed && (!pdaStatus || pdaStatus === 'PENDING')) && (!pdaStatus || pdaStatus === 'PENDING'))
}) })
const isContainerCorrection = computed(() => Boolean(detail.value?.summary.containerNo))
const transportCorrectionNote = computed(() => { const transportCorrectionNote = computed(() => {
const summary = detail.value?.summary const summary = detail.value?.summary
if (!summary?.containerNo) return '真实集装箱号尚未录入;请返回货柜管理,通过“关联已放舱订舱”完成初次录入。' if (!summary) return ''
if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。' if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。'
if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。' if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。'
return '真实集装箱号仅可在 PDA 领取前纠错,保存时必须填写操作原因。' if (!summary.containerNo) return '真实集装箱号待补录;可在预装详情首次录入,PDA 领取或作业开始后只读。'
return '真实集装箱号仅可在 PDA 领取前纠错,纠错保存时必须填写操作原因。'
}) })
const editDialogTitle = computed(() => '纠正集装箱号 / 航次') const editDialogTitle = computed(() => isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次')
const editRules: FormRules = { const editRules: FormRules = {
containerNo: [ containerNo: [
{ required: true, message: '请输入集装箱号', trigger: 'blur' }, { required: true, message: '请输入集装箱号', trigger: 'blur' },
@@ -69,7 +71,7 @@ const editRules: FormRules = {
], ],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }], voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [ operationReason: [
{ required: true, message: '请输入操作原因', trigger: 'blur' }, { validator: (_rule, value, callback) => callback(isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因') : undefined), trigger: 'blur' },
{ max: 500, message: '操作原因不能超过 500 个字符', trigger: 'blur' }, { max: 500, message: '操作原因不能超过 500 个字符', trigger: 'blur' },
], ],
} }
@@ -135,6 +137,7 @@ function openEdit() {
async function saveTransport() { async function saveTransport() {
if (!detail.value || !canCorrectTransport.value || !(await editFormRef.value?.validate().catch(() => false))) return if (!detail.value || !canCorrectTransport.value || !(await editFormRef.value?.validate().catch(() => false))) return
const correcting = isContainerCorrection.value
editForm.containerNo = normalizedContainerNo(editForm.containerNo) editForm.containerNo = normalizedContainerNo(editForm.containerNo)
editSaving.value = true editSaving.value = true
editError.value = null editError.value = null
@@ -142,11 +145,11 @@ async function saveTransport() {
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, { detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
containerNo: editForm.containerNo, containerNo: editForm.containerNo,
voyageNo: editForm.voyageNo.trim() || null, voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim(), operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version, expectedVersion: detail.value.summary.version,
}) })
editOpen.value = false editOpen.value = false
ElMessage.success('运输信息已更新') ElMessage.success(correcting ? '运输信息已纠正' : '真实集装箱号已补录')
} catch (cause) { } catch (cause) {
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试') const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) { if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
@@ -247,9 +250,9 @@ watch([planId, warehouseId], () => void loadDetail())
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport"> <AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top"> <el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item> <el-form-item label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item> <el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="操作原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" placeholder="必填,将记录在操作历史中" /></el-form-item> <el-form-item :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item>
</el-form> </el-form>
</AppDialog> </AppDialog>
@@ -265,11 +268,11 @@ watch([planId, warehouseId], () => void loadDetail())
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload"> <AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<div class="confirm-content"> <div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="真实集装箱号未录入时不能确认;PDA 领取或作业开始后集装箱号只读。系统会原子确认本计划包含的全部入库预装票据,并由服务端按真实条件创建唯一 PDA 任务;当前页面不会伪造 PDA 成功。" /> <el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="真实集装箱号可在提柜或预装详情补录,缺号不阻断此前流程;PDA 确认装柜完成前由服务端执行最终门禁。系统会原子确认本计划包含的全部入库预装票据,并由服务端按真实条件创建唯一 PDA 任务;当前页面不会伪造 PDA 成功。" />
<el-descriptions v-if="detail" :column="1" border> <el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item> <el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item> <el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待录入' }}</el-descriptions-item> <el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待补录' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>
</AppDialog> </AppDialog>
@@ -82,4 +82,10 @@ describe('OceanBookingsView', () => {
expect(source).not.toContain('customer.tenantCode') expect(source).not.toContain('customer.tenantCode')
expect(source).toContain('@media(max-width:420px)') expect(source).toContain('@media(max-width:420px)')
}) })
it('keeps desktop filter widths proportional instead of stretching short status fields', () => {
expect(source).toContain("key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220")
expect(source).toContain("key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/提单号/船名/航次', width: 260")
expect(source).toContain("key: 'status', type: 'select', label: '状态', width: 160")
})
}) })
@@ -36,7 +36,7 @@ const printBooking = ref<{ id: string; bookingNo?: string | null } | null>(null)
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/提单号/船名/航次', width: 260 }, { key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/提单号/船名/航次', width: 260 },
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions }, { key: 'status', type: 'select', label: '状态', width: 160, options: oceanBookingStatusOptions },
]) ])
const columns = [ const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false },
+1 -1
View File
@@ -44,7 +44,7 @@ export interface OceanBookingContainerRequirement {
export interface OceanBookingAllocatedContainer { export interface OceanBookingAllocatedContainer {
containerBookingId: string containerBookingId: string
cabinetNo: string cabinetNo: string
containerNo: string containerNo: string | null
requirementId: string requirementId: string
containerType: OceanBookingContainerType containerType: OceanBookingContainerType
containerKind: string containerKind: string
@@ -39,6 +39,11 @@ describe('customer visit API', () => {
await uploadCustomerVisitAttachment('9', '100', file) await uploadCustomerVisitAttachment('9', '100', file)
const body = vi.mocked(overseasHttp.post).mock.calls[0]?.[1] as FormData const body = vi.mocked(overseasHttp.post).mock.calls[0]?.[1] as FormData
expect(body.get('file')).toBe(file) expect(body.get('file')).toBe(file)
expect(overseasHttp.post).toHaveBeenCalledWith(
'/platform/v1/last-leg/customer-visits/9/attachments',
body,
{ params: { customerId: '100' }, headers: { 'Content-Type': 'multipart/form-data' } },
)
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } }) vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
await saveCustomerVisit('9', '100', { selectedInboundOrderId: '7', visitResult: 'NORMAL', attachments: [] }) await saveCustomerVisit('9', '100', { selectedInboundOrderId: '7', visitResult: 'NORMAL', attachments: [] })
@@ -1,11 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp' import { overseasHttp } from '@/shared/api/overseasHttp'
import { getDeliveryOrder, listDeliveryOrders } from './deliveryOrders' import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from './deliveryOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } })) vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
describe('delivery order API', () => { describe('delivery order API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset()
})
it('passes paging and expanded filters and normalizes page numbers', async () => { it('passes paging and expanded filters and normalizes page numbers', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } })
@@ -19,4 +22,15 @@ describe('delivery order API', () => {
await getDeliveryOrder('DEL/1', '1001') await getDeliveryOrder('DEL/1', '1001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } }) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
}) })
it('loads return options and marks a return in the explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
await listDeliveryReturnInboundOptions('DEL/1', '1001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', packageCount: 2 }
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
await markDeliveryReturn('DEL/1', '1001', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
})
}) })
@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp' import { overseasHttp } from '@/shared/api/overseasHttp'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet } from './dispatches' import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } })) vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => { describe('dispatch overseas API', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset() vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset() vi.mocked(overseasHttp.post).mockReset()
vi.mocked(overseasHttp.put).mockReset()
vi.mocked(overseasHttp.delete).mockReset() vi.mocked(overseasHttp.delete).mockReset()
}) })
@@ -68,6 +69,23 @@ describe('dispatch overseas API', () => {
expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' }) expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
}) })
it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await updateDispatch('PC/1', { vehiclePlate: 'CA-100', route: 'LAX → ONT8' })
await cancelDispatch('PC/1')
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100', route: 'LAX → ONT8' })
expect(overseasHttp.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('transfers to delivery only by dispatchNo without customerId', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', deliveryNo: 'DEL-1' } })
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10 }] }
await transferDispatchToDelivery('PC/1', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
})
it('loads drafts and preserves long container ids in create and delete paths', async () => { it('loads drafts and preserves long container ids in create and delete paths', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 68, palletIds: [] } }) vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 68, palletIds: [] } })
@@ -87,6 +87,19 @@ export interface DispatchSummaryDto {
items?: DispatchItemDto[] items?: DispatchItemDto[]
} }
export interface UpdateDispatchPayload {
vehiclePlate?: string | null
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
route?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
vehicleAppointmentStatus?: string | null
estimatedArrivalAt?: string | null
}
export interface DispatchItemDto { export interface DispatchItemDto {
id: string id: string
planPalletId: string planPalletId: string
@@ -120,6 +133,20 @@ export async function getDispatch(dispatchNo: string) {
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) {
const response = await overseasHttp.put<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload,
)
return unwrapApiResponse(response)
}
export async function cancelDispatch(dispatchNo: string) {
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
}
export async function listDispatchCandidates(query: DispatchCandidateQuery) { export async function listDispatchCandidates(query: DispatchCandidateQuery) {
const response = await overseasHttp.get<ApiEnvelope<DispatchPalletCandidateDto[]>, ApiEnvelope<DispatchPalletCandidateDto[]>>( const response = await overseasHttp.get<ApiEnvelope<DispatchPalletCandidateDto[]>, ApiEnvelope<DispatchPalletCandidateDto[]>>(
'/platform/v1/last-leg/dispatches/candidates', '/platform/v1/last-leg/dispatches/candidates',
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { cancelOutbound, createOneClickDropshipOutbound, getOutbound, listOutbounds } from './outbounds'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
describe('outbound overseas API', () => {
beforeEach(() => { vi.mocked(overseasHttp.get).mockReset(); vi.mocked(overseasHttp.post).mockReset() })
it('lists outbound orders with supported filters', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listOutbounds({ page: 1, size: 20, documentNo: 'OUT-1', warehouseId: 'WH-1', status: 'PICKED' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders', { params: { page: 1, size: 20, documentNo: 'OUT-1', warehouseId: 'WH-1', status: 'PICKED' } })
})
it('loads detail and cancels only by outboundNo without customerId', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, delivery: null } })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: null })
await getOutbound('OUT/1')
await cancelOutbound('OUT/1', '运营取消')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1/cancel', { reason: '运营取消' })
})
it('keeps customerId explicit only for one-click dropship creation', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '1', outboundNo: 'OUT-1' } })
const payload = { recipient: 'John / 555 / LA', items: [{ inventoryPositionId: '99', packages: 2, sku: 'SKU-1' }], idempotencyKey: 'dropship-1' }
await createOneClickDropshipOutbound('customer-1', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId: 'customer-1' } })
})
})
@@ -1,18 +1,24 @@
import { overseasHttp } from '@/shared/api/overseasHttp' import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import type { DeliveryOrderRow } from './deliveryOrders'
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null } export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null }
export interface OutboundDetail { outbound: OutboundRow; delivery?: DeliveryOrderRow | null }
export interface OutboundQuery { page: number; size: number; documentNo?: string; warehouseId?: string; status?: string } export interface OutboundQuery { page: number; size: number; documentNo?: string; warehouseId?: string; status?: string }
export async function listOutbounds(query: OutboundQuery) { export async function listOutbounds(query: OutboundQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OutboundRow>>, ApiEnvelope<OverseasPagePayload<OutboundRow>>>('/platform/v1/last-leg/outbound-orders', { params: query }) const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OutboundRow>>, ApiEnvelope<OverseasPagePayload<OutboundRow>>>('/platform/v1/last-leg/outbound-orders', { params: query })
return normalizeOverseasPage(unwrapApiResponse(response)) return normalizeOverseasPage(unwrapApiResponse(response))
} }
export async function getOutbound(outboundNo: string) {
const response = await overseasHttp.get<ApiEnvelope<OutboundDetail>, ApiEnvelope<OutboundDetail>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`)
return unwrapApiResponse(response)
}
export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string }>; remark?: string; idempotencyKey: string }) { export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string }>; remark?: string; idempotencyKey: string }) {
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } }) const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function cancelOutbound(outboundNo: string, reason: string, customerId?: string) { export async function cancelOutbound(outboundNo: string, reason: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason }, { params: customerId ? { customerId } : undefined }) const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import OrderReferenceText from './OrderReferenceText.vue' import OrderReferenceText from './OrderReferenceText.vue'
describe('OrderReferenceText', () => { describe('OrderReferenceText', () => {
@@ -23,4 +23,15 @@ describe('OrderReferenceText', () => {
expect(wrapper.text()).toBe('LMO-260827-0001') expect(wrapper.text()).toBe('LMO-260827-0001')
expect(wrapper.attributes('title')).toBe('LMO-260827-0001') expect(wrapper.attributes('title')).toBe('LMO-260827-0001')
}) })
it('copies the exact server short number without frontend formatting', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
const wrapper = mount(OrderReferenceText, { props: { value: 'LMO-9A7', copyable: true } })
await wrapper.get('el-button').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('LMO-9A7')
expect(wrapper.attributes('title')).toBe('LMO-9A7')
vi.unstubAllGlobals()
})
}) })
@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import CopyButton from '@/shared/components/CopyButton.vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
value?: string | null value?: string | null
emptyText?: string emptyText?: string
}>(), { value: null, emptyText: '—' }) copyable?: boolean
copyLabel?: string
}>(), { value: null, emptyText: '—', copyable: false, copyLabel: '尾程单号' })
const displayValue = computed(() => { const displayValue = computed(() => {
const value = String(props.value ?? '') const value = String(props.value ?? '')
@@ -15,16 +18,23 @@ const fullTitle = computed(() => displayValue.value === props.emptyText ? undefi
</script> </script>
<template> <template>
<span class="order-reference-text" :title="fullTitle">{{ displayValue }}</span> <span class="order-reference-text" :title="fullTitle"><span class="order-reference-text__value">{{ displayValue }}</span><CopyButton v-if="copyable && fullTitle" class="order-reference-text__copy" :value="displayValue" :label="copyLabel" button-text="" /></span>
</template> </template>
<style scoped> <style scoped>
.order-reference-text { .order-reference-text {
display: inline-block; display: inline-flex;
min-width: 0;
align-items: center;
gap: 4px;
max-width: 100%; max-width: 100%;
vertical-align: bottom;
}
.order-reference-text__value {
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap; white-space: nowrap;
} }
.order-reference-text__copy { flex: 0 0 auto; margin: 0; padding-inline: 6px; }
</style> </style>
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, toLastMileOrderListItem } from './lastMilePresentation' import { formatOverseasDateTime, lastMileOrderReference, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => { describe('last-mile order presentation adapter', () => {
it('formats overseas timestamps with zero-padded seconds', () => { it('formats overseas timestamps with zero-padded seconds', () => {
@@ -32,6 +32,14 @@ describe('last-mile order presentation adapter', () => {
}).owner).toBe('—') }).owner).toBe('—')
}) })
it('preserves the exact server LMO short number without padding, prefixing or deriving it from id', () => {
const serverOrderNo = 'LMO-9A7'
const row = toLastMileOrderListItem({ id: '2092873463568596994', customerId: '100', orderNo: serverOrderNo })
expect(row.orderNo).toBe(serverOrderNo)
expect(lastMileOrderReference(serverOrderNo)).toBe(serverOrderNo)
expect(row.orderNo).not.toContain(row.id)
})
it('preserves real metrics and distinguishes zero from unknown fields and times', () => { it('preserves real metrics and distinguishes zero from unknown fields and times', () => {
const estimatedArrivalAt = '2026-08-27T12:30:00Z' const estimatedArrivalAt = '2026-08-27T12:30:00Z'
const row = toLastMileOrderListItem({ const row = toLastMileOrderListItem({
@@ -45,6 +45,11 @@ export function lastMileOrderTypeLabel(value: unknown) {
return orderTypeLabels[code] || text(code) return orderTypeLabels[code] || text(code)
} }
export function lastMileOrderReference(value: unknown, emptyLabel = '—') {
const serverValue = value == null ? '' : String(value)
return serverValue || emptyLabel
}
export function formatOverseasDateTime(value: unknown) { export function formatOverseasDateTime(value: unknown) {
const raw = String(value ?? '').trim() const raw = String(value ?? '').trim()
if (!raw) return '—' if (!raw) return '—'
@@ -71,7 +76,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
id: String(dto.id), id: String(dto.id),
customerId: String(dto.customerId), customerId: String(dto.customerId),
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(), sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
orderNo: text(dto.orderNo), orderNo: lastMileOrderReference(dto.orderNo),
warning: dto.abnormal ? '异常' : '—', warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)), orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
rejectedAt: '—', rejectedAt: '—',
@@ -0,0 +1,76 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCustomerVisit, listCustomerVisits, saveCustomerVisit, uploadCustomerVisitAttachment } from '../api/customerVisits'
import CustomerVisitsView from './CustomerVisitsView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/customerVisits', () => ({ listCustomerVisits: vi.fn(), getCustomerVisit: vi.fn(), saveCustomerVisit: vi.fn(), uploadCustomerVisitAttachment: vi.fn() }))
const visit = {
id: '9', customerId: '100', deliveryNo: 'DEL-1', deliveryCompletedAt: '2026-09-01T10:00:00',
status: 'PENDING' as const, visitCount: 0, attachments: [],
}
function mountView() { return mount(CustomerVisitsView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) }
describe('CustomerVisitsView permissions and visit material', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(listCustomerVisits).mockReset().mockResolvedValue({ records: [visit], current: 1, size: 10, total: 1, pages: 1, visitable: 1, pending: 1, completed: 0 })
vi.mocked(getCustomerVisit).mockReset().mockResolvedValue({
visit,
inboundOrders: [{ inboundOrderId: '7', inboundCode: 'IN-1', lastMileOrderId: '6', lastMileOrderNo: 'LM-1', packageCount: 2 }],
customerFeedback: '', remark: '',
})
vi.mocked(saveCustomerVisit).mockReset().mockResolvedValue({ visit, inboundOrders: [] })
vi.mocked(uploadCustomerVisitAttachment).mockReset().mockResolvedValue({ fileId: '8', fileName: 'visit.pdf', sizeBytes: 4 })
})
it('hides the visit action without edit permission', async () => {
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('回访')
wrapper.unmount()
})
it('validates and submits a customer-scoped visit', async () => {
canPermission.mockImplementation(code => code.endsWith(':edit'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '回访')!.trigger('click')
await flushPromises()
expect(getCustomerVisit).toHaveBeenCalledWith('9', '100')
const dialog = wrapper.find('[data-dialog="客户回访"]')
await dialog.find('.dialog-confirm').trigger('click')
expect(saveCustomerVisit).not.toHaveBeenCalled()
await dialog.find('select').setValue('NORMAL')
const textareas = dialog.findAll('textarea')
await textareas[0].setValue(' 服务正常 ')
await textareas[1].setValue(' 后续关注 ')
await dialog.find('.dialog-confirm').trigger('click')
await flushPromises()
expect(saveCustomerVisit).toHaveBeenCalledWith('9', '100', {
selectedInboundOrderId: '7', visitResult: 'NORMAL', customerFeedback: '服务正常', remark: '后续关注', attachments: [],
})
wrapper.unmount()
})
it('uploads the selected attachment in the visit customer scope', async () => {
canPermission.mockImplementation(code => code.endsWith(':edit'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '回访')!.trigger('click')
await flushPromises()
const input = wrapper.find('[data-dialog="客户回访"] input[type="file"]')
const file = new File(['data'], 'visit.pdf', { type: 'application/pdf' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
await flushPromises()
expect(uploadCustomerVisitAttachment).toHaveBeenCalledWith('9', '100', file)
expect(wrapper.text()).toContain('visit.pdf')
wrapper.unmount()
})
})
@@ -0,0 +1,42 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDeliveryOrder } from '../api/deliveryOrders'
import DeliveryOrderDetailView from './DeliveryOrderDetailView.vue'
const routeState = vi.hoisted(() => ({ params: { deliveryNo: 'DEL/1' }, query: { customerId: '100' } as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn() }))
describe('DeliveryOrderDetailView', () => {
beforeEach(() => {
routeState.query = { customerId: '100' }
vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({
order: {
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2,
createdAt: '2026-09-01T10:00:00',
},
skus: [], fees: [], operations: [],
})
})
it('loads and renders detail in the explicit customer scope', async () => {
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDeliveryOrder).toHaveBeenCalledWith('DEL/1', '100')
expect(wrapper.text()).toContain('DEL/1')
expect(wrapper.text()).toContain('派送中')
expect(wrapper.text()).toContain('洛杉矶仓')
wrapper.unmount()
})
it('blocks an unsafe detail request without customer scope', async () => {
routeState.query = {}
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDeliveryOrder).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少派送单客户范围')
wrapper.unmount()
})
})
@@ -0,0 +1,78 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import DeliveryOrdersView from './DeliveryOrdersView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() }))
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: vi.fn() }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
const row = {
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00',
}
function mountView() {
return mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } })
}
describe('DeliveryOrdersView permissions and commands', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(listDeliveryOrders).mockReset().mockResolvedValue({ records: [row], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100' }])
vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
vi.mocked(updateDeliveryOrderStatus).mockReset().mockResolvedValue({ deliveryNo: 'DEL/1', deliveryStatus: 'WAIT_DELIVERY', replayed: false })
})
it('keeps detail read-only while hiding independent write operations', async () => {
const wrapper = mountView()
await flushPromises()
const tableText = wrapper.find('[data-testid="data-table"]').text()
expect(tableText).toContain('详情')
expect(tableText).not.toContain('编辑状态')
expect(tableText).not.toContain('标记退货')
wrapper.unmount()
})
it('submits status and return commands with the row customer scope', async () => {
canPermission.mockImplementation(code => code.endsWith(':status') || code.endsWith(':return'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'WAIT_DELIVERY', remark: undefined })
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100')
await wrapper.find('[data-dialog="标记退货"] textarea').setValue(' 客户拒收 ')
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
await flushPromises()
expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', {
inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
returnLogisticsNo: undefined, packageCount: undefined, skuCode: undefined,
})
wrapper.unmount()
})
it('does not submit an incomplete return form', async () => {
canPermission.mockImplementation(code => code.endsWith(':return'))
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([])
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
expect(markDeliveryReturn).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -0,0 +1,32 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDispatch } from '../api/dispatches'
import DispatchDetailView from './DispatchDetailView.vue'
import { DataTableStub } from './phase5TestStubs'
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' } }), useRouter: () => ({ push: routerPush }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
describe('DispatchDetailView', () => {
beforeEach(() => {
routerPush.mockReset()
vi.mocked(getDispatch).mockReset().mockResolvedValue({
id: '1', dispatchNo: 'PC/1', vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', volumeCbm: 1.25 }],
})
})
it('loads the business dispatch number and renders summary and related orders', async () => {
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
expect(getDispatch).toHaveBeenCalledWith('PC/1')
expect(wrapper.text()).toContain('PC/1')
expect(wrapper.text()).toContain('CA-100')
expect(wrapper.text()).toContain('待发车')
const table = wrapper.findComponent(DataTableStub)
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: '—', palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
wrapper.unmount()
})
})
@@ -4,10 +4,12 @@ import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listDispatches } from '../api/dispatches' import { cancelDispatch, listDispatches, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DispatchesView from './DispatchesView.vue' import DispatchesView from './DispatchesView.vue'
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn() })) const canPermission = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { class ResizeObserverStub implements ResizeObserver {
observe() {} observe() {}
@@ -44,6 +46,10 @@ describe('DispatchesView', () => {
}], }],
page: 1, size: 20, total: 1, pages: 1, page: 1, size: 20, total: 1, pages: 1,
}) })
vi.mocked(updateDispatch).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
canPermission.mockReset().mockReturnValue(true)
}) })
it('loads immediately and sends only supported page filters', async () => { it('loads immediately and sends only supported page filters', async () => {
@@ -81,4 +87,21 @@ describe('DispatchesView', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('请确认海外尾程排车接口已发布') expect(source).toContain('请确认海外尾程排车接口已发布')
}) })
it('gates edit, cancel and transfer with independent permissions and service statuses', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('permission.dispatchEdit')
expect(source).toContain('permission.dispatchCancel')
expect(source).toContain('permission.dispatchTransferDelivery')
expect(source).toContain("function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }")
expect(source).toContain('access.can(permission.dispatchEdit) && isPendingDeparture(row)')
expect(source).toContain('access.can(permission.dispatchTransferDelivery) && isPendingDeparture(row)')
expect(source).toContain('普通编辑不会修改排车状态')
expect(source).not.toContain('departDispatch')
canPermission.mockReturnValue(false)
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('转派送')
})
}) })
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Truck } from '@lucide/vue' import { Eye, MoreHorizontal, Pencil, Truck, XCircle } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -11,12 +11,16 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue' import StatusBadge from '@/shared/components/StatusBadge.vue'
import { listDispatches, transferDispatchToDelivery, type DispatchSummaryDto } from '../api/dispatches' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, listDispatches, transferDispatchToDelivery, updateDispatch, type DispatchSummaryDto } from '../api/dispatches'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types' import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation' import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
const router = useRouter() const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<DispatchListItem[]>([]) const rows = ref<DispatchListItem[]>([])
const loading = ref(false) const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
@@ -25,6 +29,10 @@ const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const filters = ref<QueryModel>({ dispatchNo: '', vehiclePlate: '', status: '', fbaAppointmentNo: '' }) const filters = ref<QueryModel>({ dispatchNo: '', vehiclePlate: '', status: '', fbaAppointmentNo: '' })
const transferOpen = ref(false); const transferRow = ref<DispatchListItem | null>(null); const transferSaving = ref(false) const transferOpen = ref(false); const transferRow = ref<DispatchListItem | null>(null); const transferSaving = ref(false)
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false); const editRow = ref<DispatchListItem | null>(null); const editSaving = ref(false)
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
const editForm = reactive({ vehiclePlate: '', driverName: '', driverPhone: '', carrierName: '', route: '', departAt: '', fbaAppointmentNo: '', fbaAppointmentStatus: '', vehicleAppointmentStatus: '', estimatedArrivalAt: '' })
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, remark: '' }) const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, remark: '' })
const deliveryMethodOptions = [ const deliveryMethodOptions = [
@@ -66,7 +74,7 @@ const fields = computed<QueryField[]>(() => [
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' }, { key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 }, { key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 }, { key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
@@ -123,6 +131,7 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
vehicleAppointment: text(dto.vehicleAppointmentStatus), vehicleAppointment: text(dto.vehicleAppointmentStatus),
estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt), estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt),
status: statusLabel(dto.status), status: statusLabel(dto.status),
statusCode: String(dto.status || ''),
palletCount: number(dto.palletCount), palletCount: number(dto.palletCount),
packageCount: number(dto.packageCount), packageCount: number(dto.packageCount),
createdAt: formatOverseasDateTime(dto.createdAt), createdAt: formatOverseasDateTime(dto.createdAt),
@@ -149,6 +158,8 @@ async function load(model: QueryModel = filters.value) {
status: String(filters.value.status || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined,
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined, fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
}) })
dispatchSnapshots.clear()
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
rows.value = result.records.map(toListItem) rows.value = result.records.map(toListItem)
total.value = result.total total.value = result.total
} catch (error) { } catch (error) {
@@ -163,9 +174,17 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) } function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) } function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(row: DispatchListItem) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) } function openDetail(row: DispatchListItem) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
function openTransfer(row: DispatchListItem) { transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, remark: '' }); transferOpen.value = true } function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }
function canEdit(row: DispatchListItem) { return access.can(permission.dispatchEdit) && isPendingDeparture(row) }
function canCancel(row: DispatchListItem) { return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE' }
function canTransfer(row: DispatchListItem) { return access.can(permission.dispatchTransferDelivery) && isPendingDeparture(row) }
function openEdit(row: DispatchListItem) { if (!canEdit(row)) return; const source = dispatchSnapshots.get(row.dispatchNo); editRow.value = row; Object.assign(editForm, { vehiclePlate: source?.vehiclePlate || '', driverName: source?.driverName || '', driverPhone: source?.driverPhone || '', carrierName: source?.carrierName || '', route: source?.route || '', departAt: source?.departAt || '', fbaAppointmentNo: source?.fbaAppointmentNo || '', fbaAppointmentStatus: source?.fbaAppointmentStatus || '', vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '', estimatedArrivalAt: source?.estimatedArrivalAt || '' }); mutationError.value = null; editOpen.value = true }
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, remark: '' }); mutationError.value = null; transferOpen.value = true }
async function submitEdit() { if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return; editSaving.value = true; mutationError.value = null; try { await updateDispatch(editRow.value.dispatchNo, { vehiclePlate: editForm.vehiclePlate.trim() || null, driverName: editForm.driverName.trim() || null, driverPhone: editForm.driverPhone.trim() || null, carrierName: editForm.carrierName.trim() || null, route: editForm.route.trim() || null, departAt: editForm.departAt || null, fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null, fbaAppointmentStatus: editForm.fbaAppointmentStatus || null, vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null, estimatedArrivalAt: editForm.estimatedArrivalAt || null }); editOpen.value = false; ElMessage.success('排车信息已保存'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试') } finally { editSaving.value = false } }
async function cancel(row: DispatchListItem) { if (!canCancel(row)) return; try { await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }) } catch { return } mutationError.value = null; try { await cancelDispatch(row.dispatchNo); ElMessage.success('排车单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试') } }
function handleMore(command: string, row: DispatchListItem) { if (command === 'edit') openEdit(row); else if (command === 'cancel') void cancel(row) }
async function submitTransfer() { async function submitTransfer() {
if (!transferRow.value) return if (!transferRow.value || !canTransfer(transferRow.value)) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) { ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型'); return } if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) { ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型'); return }
if (transferForm.deliveryFee == null) { ElMessage.warning('请填写派送费用'); return } if (transferForm.deliveryFee == null) { ElMessage.warning('请填写派送费用'); return }
if (needsLoadingFee.value && transferForm.loadingFee == null) { ElMessage.warning('当前装车类型需要填写装车费'); return } if (needsLoadingFee.value && transferForm.loadingFee == null) { ElMessage.warning('当前装车类型需要填写装车费'); return }
@@ -173,7 +192,7 @@ async function submitTransfer() {
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, remark]) => ({ feeType, currency: 'USD', amount: Number(amount), remark })) const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, remark]) => ({ feeType, currency: 'USD', amount: Number(amount), remark }))
transferSaving.value = true transferSaving.value = true
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单') } catch (error) { ElMessage.error(getApiErrorDetails(error, '转派送失败').message) } finally { transferSaving.value = false } try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
} }
onMounted(load) onMounted(load)
@@ -181,13 +200,15 @@ onMounted(load)
<template> <template>
<AdminPage><ListPageLayout aria-label="海外仓排车列表"> <AdminPage><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" @search="search" @reset="reset" /></template> <template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template> <template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()"> <section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button></template> <template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template> <template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template> <template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog> </ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form></AppDialog>
</template> </template>
<style scoped>.transfer-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid{grid-template-columns:1fr}}</style> <style scoped>.transfer-grid,.edit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number),.edit-grid :deep(.el-select),.edit-grid :deep(.el-date-editor){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid,.edit-grid{grid-template-columns:1fr}}</style>
@@ -0,0 +1,59 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from '../api/exceptionOrders'
import ExceptionOrdersView from './ExceptionOrdersView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/exceptionOrders', () => ({ listExceptionOrders: vi.fn(), resolveExceptionRelation: vi.fn(), createExceptionOrder: vi.fn() }))
function mountView() { return mount(ExceptionOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) }
describe('ExceptionOrdersView permissions and creation', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(listExceptionOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(resolveExceptionRelation).mockReset().mockResolvedValue({ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderId: '8', lastMileOrderNo: 'LM-1', customerId: '100' })
vi.mocked(createExceptionOrder).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
})
it('hides creation without the independent permission', async () => {
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('新增异常订单')
wrapper.unmount()
})
it('validates the required relation before submitting', async () => {
canPermission.mockImplementation(code => code.endsWith(':create'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('新增异常订单'))!.trigger('click')
await wrapper.find('[data-dialog="新增异常订单"] .dialog-confirm').trigger('click')
await flushPromises()
expect(resolveExceptionRelation).not.toHaveBeenCalled()
expect(createExceptionOrder).not.toHaveBeenCalled()
wrapper.unmount()
})
it('resolves and submits trimmed business fields', async () => {
canPermission.mockImplementation(code => code.endsWith(':create'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('新增异常订单'))!.trigger('click')
const dialog = wrapper.find('[data-dialog="新增异常订单"]')
await dialog.find('input[placeholder="请输入入仓码"]').setValue(' IN-1 ')
await dialog.find('select').setValue('RETURN')
await dialog.find('textarea').setValue(' 包裹破损 ')
await dialog.find('.dialog-confirm').trigger('click')
await flushPromises()
expect(resolveExceptionRelation).toHaveBeenCalledWith({ customerId: undefined, inboundCode: 'IN-1', lastMileOrderNo: undefined })
expect(createExceptionOrder).toHaveBeenCalledWith({
customerId: undefined, inboundCode: 'IN-1', lastMileOrderNo: 'LM-1',
exceptionType: 'RETURN', reason: '包裹破损', packageCount: undefined, skuCode: undefined,
})
wrapper.unmount()
})
})
@@ -11,7 +11,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders' import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation' import { formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue' import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue' import OrderReferenceText from '../components/OrderReferenceText.vue'
@@ -39,7 +39,7 @@ function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' } function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value } function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
const referenceValue = computed(() => text(detail.value?.order.orderNo || '订单编码待补充')) const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const orderFields = computed<DetailField[]>(() => { const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order const order = detail.value?.order
return [ return [
@@ -151,7 +151,7 @@ onMounted(load)
<template> <template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back"> <OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<template #reference><OrderReferenceText :value="referenceValue" /></template> <template #reference><OrderReferenceText :value="referenceValue" copyable /></template>
<OperationErrorToast :details="mutationError" /> <OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading"> <section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
@@ -410,7 +410,7 @@ onMounted(() => { void load() })
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()"> <section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template> <template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" /></template> <template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template> <template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
@@ -0,0 +1,39 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOutbound } from '../api/outbounds'
import OutboundDetailView from './OutboundDetailView.vue'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { outboundNo: 'OUT/1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/outbounds', () => ({ getOutbound: vi.fn(), cancelOutbound: vi.fn() }))
describe('OutboundDetailView', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(getOutbound).mockReset().mockResolvedValue({
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
})
})
it('loads by encoded business outbound number and remains read-only without cancel permission', async () => {
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getOutbound).toHaveBeenCalledWith('OUT/1')
expect(wrapper.text()).toContain('OUT/1')
expect(wrapper.text()).toContain('PC-1')
expect(wrapper.text()).toContain('DEL-1')
expect(wrapper.text()).not.toContain('取消出库单')
wrapper.unmount()
})
it('shows cancellation only for PICKED with the independent permission', async () => {
canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel')
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('取消出库单')
wrapper.unmount()
})
})
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ArrowLeft, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound, getOutbound, type OutboundDetail } from '../api/outbounds'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const statusLabels: Record<string, string> = { WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED')
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
function back() { void router.push({ name: 'overseas-outbounds' }) }
watch(outboundNo, () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage><DetailPageLayout aria-label="出库单详情">
<template #header><div class="detail-heading"><el-button aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
</DetailPageLayout></AdminPage>
</template>
<style scoped>.detail-heading{display:flex;min-width:0;align-items:center;gap:14px}.detail-title{display:flex;min-width:0;flex:1;align-items:center;justify-content:space-between;gap:12px}.detail-title h1{margin:0;font-size:20px}.detail-title p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.detail-section{padding:16px}.detail-section h2{margin:0 0 14px;font-size:16px}.detail-section+.detail-section{margin-top:16px}@media(max-width:1200px){.detail-section :deep(.el-descriptions__body .el-descriptions__table){table-layout:auto}}@media(max-width:720px){.detail-heading,.detail-title{align-items:flex-start;flex-direction:column}}</style>
@@ -0,0 +1,53 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listOutbounds } from '../api/outbounds'
import OutboundsView from './OutboundsView.vue'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => true))
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn(), cancelOutbound: vi.fn(), createOneClickDropshipOutbound: vi.fn() }))
vi.mock('../api/inventory', () => ({ listInventoryBalances: vi.fn(), listInventoryPositions: vi.fn() }))
class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} }
describe('OutboundsView permissions and navigation', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
vi.mocked(listOutbounds).mockReset().mockResolvedValue({ records: [{ id: '1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 1, packageCount: 2 }], page: 1, size: 20, total: 1, pages: 1 })
canPermission.mockReset().mockReturnValue(true)
routerPush.mockReset()
})
it('uses list access for detail and independent create/cancel permissions', async () => {
canPermission.mockImplementation((code: string) => code.endsWith(':list'))
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('详情')
expect(wrapper.text()).not.toContain('一件代发出库')
expect(wrapper.text()).not.toContain('取消')
expect(OutboundsView.__file).toContain('OutboundsView.vue')
wrapper.unmount()
})
it('shows one-click creation and cancellation only with their exact permissions', async () => {
canPermission.mockImplementation((code: string) => ['platform:overseas-warehouse:outbound:create', 'platform:overseas-warehouse:outbound:cancel'].includes(code))
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('一件代发出库')
expect(wrapper.text()).toContain('取消')
wrapper.unmount()
})
it('requires an explicit customer selection before choosing customer inventory', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
expect(source).toContain('v-model="selectedCustomerId"')
expect(source).toContain('availablePositionOptions')
expect(source).toContain('createOneClickDropshipOutbound(selectedCustomerId.value')
})
})
@@ -1,41 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { Plus, XCircle } from '@lucide/vue' import { Eye, Plus, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue' import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound, createOneClickDropshipOutbound, listOutbounds, type OutboundRow } from '../api/outbounds' import { cancelOutbound, createOneClickDropshipOutbound, listOutbounds, type OutboundRow } from '../api/outbounds'
import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory' import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation' import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null) const router = useRouter(); const access = useAccessPreviewStore()
const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false) const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }] const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
const customerOptions = computed(() => {
const customers = new Map<string, string>()
positionOptions.value.forEach(({ balance }) => customers.set(balance.customerId, balance.customerName?.trim() || '客户名称待补充'))
return [...customers].map(([value, label]) => ({ value, label }))
})
const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value))
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }] const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } } async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
async function openCreate() { createOpen.value = true; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } } async function openCreate() { createOpen.value = true; selectedCustomerId.value = ''; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
async function submit() { const selected = positionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selected.balance.customerId, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } } function selectCustomer() { selectedPositionId.value = ''; quantity.value = undefined }
async function cancel(row: OutboundRow) { let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '取消出库单失败').message) } } async function submit() { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!selectedCustomerId.value || !recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择客户,填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } }
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
function search(model: QueryModel) { page.value = 1; void load(model) } function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) } function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<AdminPage><ListPageLayout aria-label="出库单"> <AdminPage><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" @search="search" @reset="reset" /></template> <template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load" /></template> <template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()"> <section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template>
<template #actions="{ row }"><el-button v-if="row.status === 'PICKED'" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template> <template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as OutboundRow)"><Eye :size="15" />详情</el-button><el-button v-if="canCancel(row as OutboundRow)" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template> <template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog> <AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="客户" required><el-select v-model="selectedCustomerId" :loading="inventoryLoading" filterable placeholder="请选择库存所属客户" @change="selectCustomer"><el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" :disabled="!selectedCustomerId" filterable placeholder="请先选择客户,再选择有可用库存的托盘"><el-option v-for="item in availablePositionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
</template> </template>
@@ -0,0 +1,54 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
function source(name: string) { return readFileSync(resolve(process.cwd(), `src/modules/overseas-warehouse/pages/${name}`), 'utf8') }
function expectOperationColumnFirst(name: string) {
const value = source(name)
if (!value.includes("key: 'actions', label: '操作'")) return
expect(value).toMatch(/const columns:[\s\S]*?= \[\s*\{ key: 'actions', label: '操作'/)
}
describe('phase-five outbound, delivery, exception and visit behavior', () => {
it('keeps outbound detail and cancellation scoped only by outboundNo', () => {
const list = source('OutboundsView.vue')
const detail = source('OutboundDetailView.vue')
expect(list).toContain("params: { outboundNo: row.outboundNo }")
expect(list).toContain('access.can(permission.outboundCreate)')
expect(list).toContain('access.can(permission.outboundCancel)')
expect(detail).toContain('getOutbound(outboundNo.value)')
expect(detail).toContain('cancelOutbound(outboundNo.value, reason)')
expect(detail).not.toContain('customerId')
expect(list).toContain('createOneClickDropshipOutbound(selectedCustomerId.value')
expectOperationColumnFirst('OutboundsView.vue')
})
it('keeps delivery return and status operations on real APIs with independent permissions', () => {
const value = source('DeliveryOrdersView.vue')
expect(value).toContain('permission.deliveryStatus')
expect(value).toContain('permission.deliveryReturn')
expect(value).toContain('updateDeliveryOrderStatus')
expect(value).toContain('markDeliveryReturn')
expect(value).toContain('listDeliveryReturnInboundOptions')
expectOperationColumnFirst('DeliveryOrdersView.vue')
})
it('keeps exception creation and relation resolution on real APIs', () => {
const value = source('ExceptionOrdersView.vue')
expect(value).toContain('permission.exceptionOrderCreate')
expect(value).toContain('resolveExceptionRelation')
expect(value).toContain('createExceptionOrder')
expect(value).toContain('getApiErrorDetails')
expectOperationColumnFirst('ExceptionOrdersView.vue')
})
it('keeps customer visit updates and attachments behind edit permission', () => {
const value = source('CustomerVisitsView.vue')
expect(value).toContain('permission.customerVisitEdit')
expect(value).toContain('saveCustomerVisit')
expect(value).toContain('uploadCustomerVisitAttachment')
expect(value).toContain('getApiErrorDetails')
expectOperationColumnFirst('CustomerVisitsView.vue')
})
})
@@ -0,0 +1,83 @@
import { defineComponent, h, type Component, type PropType } from 'vue'
export const DataTableStub = defineComponent({
name: 'DataTable',
props: { data: { type: Array as PropType<object[]>, default: () => [] } },
setup(props, { slots }) {
return () => h('div', { 'data-testid': 'data-table' }, [
slots['toolbar-actions']?.(),
...props.data.flatMap(row => slots.actions?.({ row }) || []),
])
},
})
export const AppDialogStub = defineComponent({
name: 'AppDialog',
props: { title: { type: String, required: true }, confirmText: { type: String, default: '确定' } },
emits: ['confirm'],
setup(props, { emit, slots }) {
return () => h('section', { 'data-dialog': props.title }, [
slots.default?.(),
h('button', { class: 'dialog-confirm', onClick: () => emit('confirm') }, props.confirmText),
])
},
})
export const ModelInputStub = defineComponent({
name: 'ElInput',
inheritAttrs: false,
props: { modelValue: { type: [String, Number], default: '' }, type: { type: String, default: 'text' } },
emits: ['update:modelValue', 'blur'],
setup(props, { attrs, emit }) {
return () => h(props.type === 'textarea' ? 'textarea' : 'input', {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
onBlur: () => emit('blur'),
})
},
})
export const ModelSelectStub = defineComponent({
name: 'ElSelect',
props: { modelValue: { type: [String, Number], default: '' } },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () => h('select', {
value: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
}, slots.default?.())
},
})
export const OptionStub = defineComponent({
name: 'ElOption',
props: { value: { type: [String, Number], required: true }, label: { type: String, required: true } },
setup(props) { return () => h('option', { value: props.value }, props.label) },
})
export const ModelNumberStub = defineComponent({
name: 'ElInputNumber',
props: { modelValue: { type: Number, default: undefined } },
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('input', {
type: 'number',
value: props.modelValue,
onInput: (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value === '' ? undefined : Number(value))
},
})
},
})
export const commonPhase5Stubs: Record<string, Component> = {
DataTable: DataTableStub,
AppDialog: AppDialogStub,
QueryToolbar: defineComponent({ name: 'QueryToolbar', setup: () => () => h('div') }),
ElInput: ModelInputStub,
ElSelect: ModelSelectStub,
ElOption: OptionStub,
ElInputNumber: ModelNumberStub,
}
@@ -128,6 +128,16 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).not.toContain('海外派送') expect(value).not.toContain('海外派送')
}) })
it('renders and copies the server-provided tail-leg short number in list and detail', () => {
const list = source('LastMileOrdersView.vue')
const detail = source('LastMileOrderDetailView.vue')
expect(list).toContain('<OrderReferenceText :value="String(row.orderNo || \'\')" copyable />')
expect(detail).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(detail).toContain('<OrderReferenceText :value="referenceValue" copyable />')
expect(list).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
expect(detail).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
})
it('connects the tail-leg detail to the real overseas API and keeps missing contracts pending', () => { it('connects the tail-leg detail to the real overseas API and keeps missing contracts pending', () => {
const tailLeg = source('LastMileOrderDetailView.vue') const tailLeg = source('LastMileOrderDetailView.vue')
expect(tailLeg).toContain('getLastMileOrder') expect(tailLeg).toContain('getLastMileOrder')
@@ -137,6 +147,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain('label="费用明细"') expect(tailLeg).toContain('label="费用明细"')
expect(tailLeg).toContain('label="操作记录"') expect(tailLeg).toContain('label="操作记录"')
expect(tailLeg).toContain('title="新增费用"') expect(tailLeg).toContain('title="新增费用"')
expect(tailLeg).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable />')
const inbound = source('InboundOrderDetailView.vue') const inbound = source('InboundOrderDetailView.vue')
expect(inbound).toContain('getInboundPlan') expect(inbound).toContain('getInboundPlan')
expect(inbound).toContain('sourceOrderId') expect(inbound).toContain('sourceOrderId')
@@ -8,8 +8,12 @@ export const overseasWarehousePermissions = {
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment', inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch', inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
dispatchList: 'platform:overseas-warehouse:dispatch:list', dispatchList: 'platform:overseas-warehouse:dispatch:list',
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
dispatchTransferDelivery: 'platform:overseas-warehouse:dispatch:transfer-delivery',
outboundList: 'platform:overseas-warehouse:outbound:list', outboundList: 'platform:overseas-warehouse:outbound:list',
outboundCreate: 'platform:overseas-warehouse:outbound:create', outboundCreate: 'platform:overseas-warehouse:outbound:create',
outboundCancel: 'platform:overseas-warehouse:outbound:cancel',
deliveryList: 'platform:overseas-warehouse:delivery:list', deliveryList: 'platform:overseas-warehouse:delivery:list',
exceptionOrderList: 'platform:overseas-warehouse:exception-order:list', exceptionOrderList: 'platform:overseas-warehouse:exception-order:list',
exceptionOrderCreate: 'platform:overseas-warehouse:exception-order:create', exceptionOrderCreate: 'platform:overseas-warehouse:exception-order:create',
@@ -12,6 +12,7 @@ describe('overseas warehouse routes', () => {
['overseas-warehouse/dispatches', permission.dispatchList], ['overseas-warehouse/dispatches', permission.dispatchList],
['overseas-warehouse/dispatches/:dispatchNo', permission.dispatchList], ['overseas-warehouse/dispatches/:dispatchNo', permission.dispatchList],
['overseas-warehouse/outbounds', permission.outboundList], ['overseas-warehouse/outbounds', permission.outboundList],
['overseas-warehouse/outbounds/:outboundNo', permission.outboundList],
['overseas-warehouse/delivery-orders', permission.deliveryList], ['overseas-warehouse/delivery-orders', permission.deliveryList],
['overseas-warehouse/delivery-orders/:deliveryNo', permission.deliveryList], ['overseas-warehouse/delivery-orders/:deliveryNo', permission.deliveryList],
['overseas-warehouse/exception-orders', permission.exceptionOrderList], ['overseas-warehouse/exception-orders', permission.exceptionOrderList],
@@ -28,7 +29,7 @@ describe('overseas warehouse routes', () => {
it('keeps all details as formal parameterized pages', () => { it('keeps all details as formal parameterized pages', () => {
const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:')) const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:'))
expect(details).toHaveLength(7) expect(details).toHaveLength(8)
expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true) expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true)
}) })
@@ -46,6 +47,8 @@ describe('overseas warehouse routes', () => {
permission.productCreate, permission.productEdit, permission.productCreate, permission.productEdit,
permission.exceptionOrderCreate, permission.deliveryReturn, permission.deliveryStatus, permission.exceptionOrderCreate, permission.deliveryReturn, permission.deliveryStatus,
permission.customerVisitEdit, permission.customerVisitEdit,
]).size).toBe(12) permission.dispatchEdit, permission.dispatchCancel, permission.dispatchTransferDelivery,
permission.outboundCancel, permission.outboundCreate,
]).size).toBe(17)
}) })
}) })
+6
View File
@@ -44,6 +44,12 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/OutboundsView.vue'), component: () => import('./pages/OutboundsView.vue'),
meta: { title: '出库单', description: '查询海外仓出库单并创建一件代发出库', permission: permission.outboundList }, meta: { title: '出库单', description: '查询海外仓出库单并创建一件代发出库', permission: permission.outboundList },
}, },
{
path: 'overseas-warehouse/outbounds/:outboundNo',
name: 'overseas-outbound-detail',
component: () => import('./pages/OutboundDetailView.vue'),
meta: { title: '出库单详情', description: '查看海外仓出库单与关联派送信息', permission: permission.outboundList, showPageHeading: false },
},
{ {
path: 'overseas-warehouse/delivery-orders', path: 'overseas-warehouse/delivery-orders',
name: 'overseas-delivery-orders', name: 'overseas-delivery-orders',
+1
View File
@@ -87,6 +87,7 @@ export interface DispatchListItem {
vehicleAppointment: string vehicleAppointment: string
estimatedArrivalAt: string estimatedArrivalAt: string
status: string status: string
statusCode: string
palletCount: number palletCount: number
packageCount: number packageCount: number
createdAt: string createdAt: string
@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/pages/PortSchedulesView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/pages/PortSchedulesView.vue'), 'utf8')
describe('PortSchedulesView', () => { describe('PortSchedulesView', () => {
it('uses calibrated desktop widths for customer, composite keyword and validity', () => {
expect(source).toContain("key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220")
expect(source).toContain("key: 'keyword', type: 'input', label: '关键词', placeholder: '船期号/船名/航次/港口', width: 260")
expect(source).toContain("key: 'expired', type: 'select', label: '有效性', width: 160")
})
it('uses standard list components and a left action column', () => { it('uses standard list components and a left action column', () => {
expect(source).toContain('QueryToolbar') expect(source).toContain('QueryToolbar')
expect(source).toContain('DataTable') expect(source).toContain('DataTable')
@@ -21,7 +21,7 @@ const access = useAccessPreviewStore()
const rows = ref<PortScheduleView[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null) const rows = ref<PortScheduleView[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0) const page = ref(1); const pageSize = ref(20); const total = ref(0)
const query = reactive<QueryModel>({ customerId: '', keyword: '', expired: '' }) const query = reactive<QueryModel>({ customerId: '', keyword: '', expired: '' })
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customerLabel(customer), value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'keyword', type: 'input', label: '关键词', placeholder: '船期号/船名/航次/港口', width: 230 }, { key: 'expired', type: 'select', label: '有效性', options: [{ label: '有效', value: 'false' }, { label: '已过期', value: 'true' }] }]) const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customerLabel(customer), value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'keyword', type: 'input', label: '关键词', placeholder: '船期号/船名/航次/港口', width: 260 }, { key: 'expired', type: 'select', label: '有效性', width: 160, options: [{ label: '有效', value: 'false' }, { label: '已过期', value: 'true' }] }])
const columns = [ const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 165, slotName: 'actions', hideable: false, movable: false }, { key: 'actions', label: '操作', fixed: 'left', width: 165, slotName: 'actions', hideable: false, movable: false },
{ prop: 'schedule.scheduleNo', label: '船期编号', minWidth: 145 }, { prop: 'schedule.sourceName', label: '信息来源', minWidth: 130 }, { prop: 'schedule.scheduleNo', label: '船期编号', minWidth: 145 }, { prop: 'schedule.sourceName', label: '信息来源', minWidth: 130 },
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking' import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentCandidates, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data })) vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
@@ -8,9 +8,18 @@ describe('truck appointment API', () => {
beforeEach(() => { vi.clearAllMocks() }) beforeEach(() => { vi.clearAllMocks() })
it('normalizes page values and maps filters', async () => { it('normalizes page values and maps filters', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: 1, oceanBookingId: 2, containerBookingId: 3, warehouseId: 4, shippingCompanyId: 5, bookingAgentId: 6, oceanBookingNo: 'OB-1', cabinetNo: 'CAB-1', containerNo: 'MSCU-1', warehouseName: '义乌仓', shippingCompanyName: '船公司', bookingAgentName: '订舱代理', attachmentCount: '2' }], page: '2', size: '20', total: '21', pages: '2' } }) vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: 1, oceanBookingId: 2, warehouseId: 4, shippingCompanyId: 5, bookingAgentId: 6, oceanBookingNo: 'OB-1', warehouseName: '义乌仓', shippingCompanyName: '船公司', bookingAgentName: '订舱代理', attachmentCount: '2' }], page: '2', size: '20', total: '21', pages: '2' } })
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerBookingId: '77' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2, records: [{ id: '1', oceanBookingId: '2', containerBookingId: '3', warehouseId: '4', shippingCompanyId: '5', bookingAgentId: '6', oceanBookingNo: 'OB-1', cabinetNo: 'CAB-1', containerNo: 'MSCU-1', attachmentCount: 2 }] }) await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', oceanBookingId: '77' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2, records: [{ id: '1', oceanBookingId: '2', warehouseId: '4', shippingCompanyId: '5', bookingAgentId: '6', oceanBookingNo: 'OB-1', attachmentCount: 2 }] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerBookingId: '77' } }) expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', oceanBookingId: '77' } })
})
it('uses the server-owned create candidate contract without rebuilding availability', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } })
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
records: [{ oceanBookingId: '11', warehouseId: '12', bookingDocumentReady: true }],
total: 1,
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
}) })
it('uses detail, create, and status contracts', async () => { it('uses detail, create, and status contracts', async () => {
@@ -18,18 +27,36 @@ describe('truck appointment API', () => {
vi.mocked(http.post).mockResolvedValue({ data: '9001' }) vi.mocked(http.post).mockResolvedValue({ data: '9001' })
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } }) vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' }) await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' }) await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 }) expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0 })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' }) await expect(performTruckAppointmentAction('9001', 'DISPATCH', { expectedVersion: 2 })).resolves.toMatchObject({ status: 'DISPATCHED' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } }) expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
}) })
it('keeps pickup booking-scoped without sending container identity', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PICKUP_IN_PROGRESS' } })
await performTruckAppointmentAction('9001', 'PICKUP', { expectedVersion: 3 })
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/pickup', {
expectedVersion: 3, reason: null,
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9001-') } })
})
it.each(['SUBMIT', 'DISPATCH', 'PICKUP'] as const)('sends %s without any container field', async (action) => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PENDING_DISPATCH' } })
await performTruckAppointmentAction('9001', action, { expectedVersion: 4, reason: '演示流转' })
const body = vi.mocked(http.post).mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(body).toEqual({ expectedVersion: 4, reason: '演示流转' })
expect(body).not.toHaveProperty('containerNo')
expect(body).not.toHaveProperty('containerBookingId')
expect(body).not.toHaveProperty('loadingPlanId')
})
it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => { it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => {
vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } }) vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } })
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, customerId: 'legacy-customer', customerName: '旧客户快照' } const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: '500', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, currency: 'EUR', receivableAmount: '999.00', customerId: 'legacy-customer', customerName: '旧客户快照' }
await createTruckAppointment(payload as never) await createTruckAppointment(payload as never)
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', { expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', {
carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0,
}) })
}) })
@@ -69,4 +96,16 @@ describe('truck appointment API', () => {
expect(form.get('file')).toBe(file) expect(form.get('file')).toBe(file)
expect(form.get('attachmentType')).toBe('DISPATCH_ORDER') expect(form.get('attachmentType')).toBe('DISPATCH_ORDER')
}) })
it('never forwards a legacy booking document type into truck attachments', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '1', fileId: '2', fileName: 'legacy.pdf' } })
await referenceTruckAppointmentAttachment('77', { fileId: '2', fileName: 'legacy.pdf', attachmentType: 'BILL_OF_LADING' as never })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/truck-appointments/77/attachments', { fileId: '2', fileName: 'legacy.pdf' })
const file = new File(['legacy'], 'legacy.pdf', { type: 'application/pdf' })
await uploadTruckAppointmentAttachment('77', file, 'BILL_OF_LADING' as never)
const form = vi.mocked(http.post).mock.calls.at(-1)?.[1] as FormData
expect(form.get('file')).toBe(file)
expect(form.has('attachmentType')).toBe(false)
})
}) })
+52 -10
View File
@@ -1,10 +1,14 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { TRUCK_APPOINTMENT_SAVE_FIELDS } from '../types' import { TRUCK_APPOINTMENT_SAVE_FIELDS, isTruckAttachmentType, normalizeTruckAppointmentCurrency } from '../types'
import type { import type {
SaveTruckAppointmentRequest, SaveTruckAppointmentRequest,
TruckAppointmentPage, TruckAppointmentPage,
TruckAppointmentQuery, TruckAppointmentQuery,
TruckAppointmentResource, TruckAppointmentResource,
TruckAppointmentActionRequest,
TruckAppointmentCandidate,
TruckAppointmentCandidatePage,
TruckAppointmentCandidateQuery,
TruckAppointmentStatusHistory, TruckAppointmentStatusHistory,
TruckAttachmentReference, TruckAttachmentReference,
TruckAttachmentType, TruckAttachmentType,
@@ -17,7 +21,7 @@ function operationId(action: string, id: string | number) {
function dtoPayload(payload: SaveTruckAppointmentRequest) { function dtoPayload(payload: SaveTruckAppointmentRequest) {
return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => { return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => {
const value = payload[key] const value = key === 'currency' ? normalizeTruckAppointmentCurrency(payload[key]) : payload[key]
return value === undefined ? [] : [[key, value === '' ? null : value]] return value === undefined ? [] : [[key, value === '' ? null : value]]
})) as unknown as SaveTruckAppointmentRequest })) as unknown as SaveTruckAppointmentRequest
} }
@@ -37,7 +41,7 @@ function normalizePage(payload: PagePayload): TruckAppointmentPage {
} }
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource { function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId'] const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
const normalized = { ...resource } as Record<string, unknown> const normalized = { ...resource } as Record<string, unknown>
for (const key of idKeys) { for (const key of idKeys) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key]) if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
@@ -52,13 +56,46 @@ export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
...query, ...query,
keyword: query.keyword?.trim() || undefined, keyword: query.keyword?.trim() || undefined,
status: query.status || undefined, status: query.status || undefined,
containerBookingId: query.containerBookingId || undefined, oceanBookingId: query.oceanBookingId || undefined,
containerType: query.containerType || undefined,
}, },
}) })
return normalizePage(unwrapApiResponse(response)) return normalizePage(unwrapApiResponse(response))
} }
type CandidatePagePayload = Omit<TruckAppointmentCandidatePage, 'records' | 'page' | 'size' | 'total' | 'pages'> & {
records?: Array<TruckAppointmentCandidate & Record<string, unknown>>
page: number | string
size: number | string
total: number | string
pages?: number | string
}
function normalizeCandidate(candidate: TruckAppointmentCandidate & Record<string, unknown>): TruckAppointmentCandidate {
const normalized = { ...candidate } as Record<string, unknown>
for (const key of ['oceanBookingId', 'warehouseId']) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
}
normalized.bookingDocumentReady = normalized.bookingDocumentReady === true
return normalized as unknown as TruckAppointmentCandidate
}
export async function listTruckAppointmentCandidates(query: TruckAppointmentCandidateQuery = {}) {
const response = await http.get<ApiEnvelope<CandidatePagePayload>, ApiEnvelope<CandidatePagePayload>>(`${basePath}/candidates`, {
params: { keyword: query.keyword?.trim() || undefined, page: query.page || 1, size: query.size || 200 },
})
const payload = unwrapApiResponse(response)
const page = Number(payload.page || 1)
const size = Number(payload.size || 200)
const total = Number(payload.total || 0)
return {
records: (payload.records || []).map(normalizeCandidate),
page,
size,
total,
pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)),
} satisfies TruckAppointmentCandidatePage
}
export async function getTruckAppointment(id: string | number) { export async function getTruckAppointment(id: string | number) {
const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`) const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`)
return normalizeResource(unwrapApiResponse(response)) return normalizeResource(unwrapApiResponse(response))
@@ -77,14 +114,16 @@ export async function listTruckAppointmentAttachments(id: string | number) {
} }
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: TruckAttachmentType | null; fileName?: string | null; fileHash?: string | null }) { export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: TruckAttachmentType | null; fileName?: string | null; fileHash?: string | null }) {
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, payload) const { attachmentType, ...rest } = payload
const body = { ...rest, ...(isTruckAttachmentType(attachmentType) ? { attachmentType } : {}) }
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, body)
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: TruckAttachmentType | null) { export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: TruckAttachmentType | null) {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
if (attachmentType) form.append('attachmentType', attachmentType) if (isTruckAttachmentType(attachmentType)) form.append('attachmentType', attachmentType)
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments/upload`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments/upload`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -108,13 +147,16 @@ export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE'
export async function performTruckAppointmentAction( export async function performTruckAppointmentAction(
id: string | number, id: string | number,
action: TruckAppointmentAction, action: TruckAppointmentAction,
expectedVersion: number, payload: TruckAppointmentActionRequest,
reason?: string,
idempotencyKey?: string, idempotencyKey?: string,
requestHash?: string, requestHash?: string,
) { ) {
const operationKey = idempotencyKey || operationId(action, id) const operationKey = idempotencyKey || operationId(action, id)
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, { const body = {
expectedVersion: payload.expectedVersion,
reason: payload.reason || null,
}
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, body, {
headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) }, headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
}) })
return unwrapApiResponse(response) return unwrapApiResponse(response)
@@ -3,62 +3,61 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http' import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentForm from './TruckAppointmentForm.vue' import TruckAppointmentForm from './TruckAppointmentForm.vue'
import type { SaveTruckAppointmentRequest } from '../types' import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings' import { getOceanBooking } from '@/modules/ocean-bookings'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
vi.mock('@/modules/suppliers', () => ({ vi.mock('@/modules/suppliers', () => ({
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }), listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }),
})) }))
vi.mock('@/modules/ocean-bookings', () => ({ vi.mock('@/modules/ocean-bookings', () => ({
listOceanBookings: vi.fn(),
getOceanBooking: vi.fn(), getOceanBooking: vi.fn(),
})) }))
vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.fn() }))
const model: SaveTruckAppointmentRequest = { const model: SaveTruckAppointmentRequest = {
expectedVersion: 0, expectedVersion: 0,
oceanBookingId: null, oceanBookingId: null,
containerBookingId: null,
loadingPlanId: null,
carrierId: null, carrierId: null,
plannedLoadingAt: null, plannedLoadingAt: null,
} }
const bookings = [ const candidates = [
{ id: 'booking-1', bookingNo: 'OB-1', status: 'RELEASED', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓' }, { oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
{ id: 'booking-2', bookingNo: 'OB-2', status: 'RELEASED', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓' }, { oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
{ id: 'booking-missing-bill', bookingNo: 'OB-3', status: 'RELEASED', billOfLadingNo: null, warehouseId: 'WH-3', warehouseName: '宁波仓' },
] ]
function detail(id: string) { function detail(id: string) {
const booking = bookings.find((item) => item.id === id)! const candidate = candidates.find((item) => item.oceanBookingId === id)!
return { return {
...booking, id, bookingNo: candidate.oceanBookingNo, status: 'RELEASED', billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName,
documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }], documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }],
containers: id === 'booking-1' containers: [],
? [
{ containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' },
{ containerBookingId: 'container-invalid', cabinetNo: 'CAB-X', containerNo: '错误柜号' },
]
: [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }],
} }
} }
function resource(id: string, bookingId: string): TruckAppointmentResource {
const candidate = candidates.find((item) => item.oceanBookingId === bookingId)!
return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName }
}
describe('TruckAppointmentForm booking-only behavior', () => { describe('TruckAppointmentForm booking-only behavior', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(listOceanBookings).mockResolvedValue({ records: bookings } as never) vi.mocked(listTruckAppointmentCandidates).mockResolvedValue({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never) vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
}) })
it('selects a released booking, exposes only valid real containers, and never emits customer fields', async () => { it('selects only server-approved released bookings and never exposes container identity', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }) const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1') selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises() await flushPromises()
expect(getOceanBooking).toHaveBeenCalledWith('booking-1') expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || '')) const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-1') && label.includes('CBHU8888883'))).toBe(true) expect(labels.some((label) => label.includes('OB-1') && label.includes('BL-1'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-X'))).toBe(false) expect(labels.some((label) => label.includes('CAB-'))).toBe(false)
expect(candidates[0]).not.toHaveProperty('containerBookingId')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-1') expect(emitted.oceanBookingId).toBe('booking-1')
expect(emitted.billOfLadingNo).toBe('BL-1') expect(emitted.billOfLadingNo).toBe('BL-1')
@@ -67,31 +66,78 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('clears a selected container when switching to another booking', async () => { it('keeps only the latest candidate response when option reloads overlap', async () => {
let resolveSlow!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
let resolveFast!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
vi.mocked(listTruckAppointmentCandidates)
.mockResolvedValueOnce({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSlow = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveFast = resolve }))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { loadOptions: () => Promise<void> }
const slow = vm.loadOptions()
const fast = vm.loadOptions()
resolveFast({ records: [candidates[1]!], page: 1, size: 200, total: 1, pages: 1 })
await fast
resolveSlow({ records: [candidates[0]!], page: 1, size: 200, total: 1, pages: 1 })
await slow
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
wrapper.unmount()
})
it('replaces frozen booking snapshots when switching the selected booking', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }) const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1') selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises() await flushPromises()
selects[1]!.vm.$emit('update:modelValue', 'container-1')
await flushPromises()
expect((wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest).containerBookingId).toBe('container-1')
selects[0]!.vm.$emit('update:modelValue', 'booking-2') selects[0]!.vm.$emit('update:modelValue', 'booking-2')
await flushPromises() await flushPromises()
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-2') expect(emitted.oceanBookingId).toBe('booking-2')
expect(emitted.containerBookingId).toBeNull() expect(emitted.billOfLadingNo).toBe('BL-2')
expect(emitted.warehouseId).toBe('WH-2')
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || '')) const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-2'))).toBe(true) expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
wrapper.unmount()
})
it('ignores stale current-booking details when a cached edit route changes', async () => {
let resolveFirst!: (value: ReturnType<typeof detail>) => void
let resolveSecond!: (value: ReturnType<typeof detail>) => void
vi.mocked(getOceanBooking).mockImplementation((id) => new Promise((resolve) => {
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
else resolveSecond = resolve as typeof resolveSecond
}) as never)
const firstResource = resource('appointment-1', 'booking-1')
const secondResource = resource('appointment-2', 'booking-2')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2' }, resource: secondResource })
await flushPromises()
resolveSecond(detail('booking-2'))
await flushPromises()
resolveFirst({ ...detail('booking-1'), documents: [] })
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2') && label.includes('不可重选'))).toBe(true)
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
wrapper.unmount() wrapper.unmount()
}) })
it('preserves the business code and request trace when booking readiness fails', async () => { it('preserves the business code and request trace when booking readiness fails', async () => {
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking')) vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) const current = resource('appointment-1', 'booking-1')
await flushPromises() const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('订舱版本冲突') expect(wrapper.text()).toContain('订舱版本冲突')
expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).toContain('A0409')
@@ -100,26 +146,30 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('blocks a released booking that has no bill of lading number', async () => { it('flags an edit binding whose booking has become unreleased without adding it to create candidates', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
const current = resource('appointment-1', 'booking-1')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-missing-bill') expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态')
await flushPromises() expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('所选订舱缺少有效提单号')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.billOfLadingNo).toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
it('rejects a booking that becomes unreleased before its detail is loaded', async () => { it('switches the payable currency between RMB and USD and normalizes an illegal legacy value', async () => {
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never) const wrapper = mount(TruckAppointmentForm, {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) props: { modelValue: { ...model, payableAmount: '88.00', currency: 'EUR' as never } },
global: { plugins: [ElementPlus] },
})
await flushPromises() await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1') const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')!
expect(currencySelect.props('modelValue')).toBe('RMB')
currencySelect.vm.$emit('update:modelValue', 'USD')
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('所选订舱尚未放舱')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.containerBookingId).toBeNull() expect(emitted.currency).toBe('USD')
expect(wrapper.text()).toContain('USD')
expect(emitted).not.toHaveProperty('receivableAmount')
wrapper.unmount() wrapper.unmount()
}) })
}) })
@@ -3,31 +3,31 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8')
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
describe('TruckAppointmentForm interaction contract', () => { describe('TruckAppointmentForm interaction contract', () => {
it('requires a released booking before a real-numbered allocated container', () => { it('requires one server-approved released booking without exposing a container selector', () => {
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })") expect(source).toContain('listTruckAppointmentCandidates({ page: 1, size: 200 })')
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'") expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次'") expect(source).not.toContain('prop="containerBookingId"')
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次(真实集装箱号)"')) expect(source).not.toContain('label="柜次"')
expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
expect(source).not.toContain('关联订舱(可选)') expect(source).not.toContain('关联订舱(可选)')
expect(source).not.toContain('关联柜次(可选)') expect(source).not.toContain('关联柜次(可选)')
}) })
it('loads only containers allocated to the selected booking and filters missing or malformed physical numbers', () => { it('uses only server-owned booking candidates and never rebuilds availability from booking detail', () => {
expect(source).toContain('getOceanBooking(bookingId)') expect(source).toContain('candidates.value = results[1].value.records')
expect(source).toContain('containers.value = booking.containers || []') expect(source).toContain('new Map<string')
expect(source).toContain('bookingDocuments.value = booking.documents || []') expect(source).not.toContain('booking.containers')
expect(source).toContain('isIso6346ContainerNumber(item.containerNo)') expect(source).not.toContain('listOceanBookings')
expect(source).toContain('该订舱暂无已录真实集装箱号的柜次') expect(source).not.toContain('isIso6346ContainerNumber')
expect(source).not.toContain('listContainers') expect(source).not.toContain('listContainers')
}) })
it('clears an incompatible container whenever the booking changes', () => { it('does not write legacy container or loading-plan fields', () => {
expect(source).toContain('draft.containerBookingId = null') expect(source).not.toContain('draft.containerBookingId')
expect(source).toContain('draft.loadingPlanId = null') expect(source).not.toContain('draft.loadingPlanId')
expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))') expect(typesSource).not.toContain("'containerBookingId', 'loadingPlanId'")
}) })
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => { it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
@@ -46,16 +46,33 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('所选订舱缺少有效提单号') expect(source).toContain('所选订舱缺少有效提单号')
expect(source).toContain("booking.status !== 'RELEASED'") expect(source).toContain("booking.status !== 'RELEASED'")
expect(source).toContain('bookingContainersError') expect(source).toContain('bookingContainersError')
expect(source).toContain("getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')") expect(source).toContain("getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')")
expect(source).toContain('<ApiErrorMeta :details="bookingContainersError" />') expect(source).toContain('<ApiErrorMeta :details="bookingContainersError" />')
expect(source).toContain('重新加载选项') expect(source).toContain('重新加载选项')
expect(source).toContain('重新加载') expect(source).toContain('重新加载')
expect(source).toContain('请选择有效的合作中拖车供应商') expect(source).toContain('请选择有效的合作中拖车供应商')
}) })
it('labels the generated cabinet reference and physical container number separately', () => { it('freezes the current edit booking and marks its fallback option as non-selectable', () => {
expect(source).toContain('柜次号:${item.cabinetNo') expect(source).toContain('const relationLocked = computed(() => Boolean(props.resource))')
expect(source).toContain('集装箱号:${item.containerNo}') expect(source).toContain('(当前绑定,不可重选)')
expect(source).toContain(':disabled="relationLocked"')
expect(source).not.toContain('selectedContainerFallback')
})
it('guards current booking detail loads against stale route reuse responses', () => {
expect(source).toContain('const sequence = ++bookingLoadSequence')
expect(source).toContain("sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId")
expect(source).toContain('const sequence = ++optionsLoadSequence')
expect(source).toContain('if (sequence !== optionsLoadSequence) return')
expect(source).toContain("watch(() => props.resource?.id")
})
it('keeps container identity out of the booking-scoped form', () => {
expect(source).not.toContain('柜次')
expect(source).not.toContain('装柜计划')
expect(source).not.toContain('集装箱号')
expect(source).toContain('货柜资料在货柜与预装流程维护')
}) })
it('keeps the form usable at 390px', () => { it('keeps the form usable at 390px', () => {
@@ -68,4 +85,22 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain(':deep(.el-select__selected-item)') expect(source).toContain(':deep(.el-select__selected-item)')
expect(source).toContain('text-overflow:ellipsis') expect(source).toContain('text-overflow:ellipsis')
}) })
it('uses payable-only fees with a fixed dynamic currency selector', () => {
expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD']")
expect(source).toContain('<template #append>{{ draft.currency }}</template>')
expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"')
expect(source).toContain('placeholder="请选择币种"')
expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
})
it('keeps the 1920 desktop contract compact without horizontal form overflow', () => {
expect(source).toContain('@media (min-width: 1200px)')
expect(source).toContain('max-width:25%; flex:0 0 25%')
expect(source).toContain('class="relation-column"')
expect(source).toContain('class="wide-column"')
expect(source).toContain('max-width:50%; flex-basis:50%')
expect(source).not.toContain('overflow-x:auto')
})
}) })
@@ -2,22 +2,23 @@
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue' import { Eye, Save } from '@lucide/vue'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types' import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
import { listSuppliers, type Supplier } from '@/modules/suppliers' import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings' import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { isIso6346ContainerNumber } from '../iso6346'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>() const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean; submitBlocked?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>() const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const relationWarning = ref('') const relationWarning = ref('')
const bookingContainersLoading = ref(false) const bookingContainersLoading = ref(false)
const bookingContainersError = ref<ApiErrorDetails | null>(null) const bookingContainersError = ref<ApiErrorDetails | null>(null)
const bookingDocuments = ref<OceanBookingDocument[]>([]) const bookingDocuments = ref<OceanBookingDocument[]>([])
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 } const relationLocked = computed(() => Boolean(props.resource))
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } } const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue)) const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true }) watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true })
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true }) watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
@@ -32,96 +33,76 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
}, trigger: 'blur' }) }, trigger: 'blur' })
const rules: FormRules = { const rules: FormRules = {
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => { oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
if (!(bookings.value.some((item) => item.id === String(value) && item.status === 'RELEASED') || selectedBookingFallback.value?.id === String(value))) return callback(new Error('请选择有效的已放舱订舱')) if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择可创建拖车单的已放舱订舱'))
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试')) if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号')) if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件')) return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
}, trigger: 'change' }], }, trigger: 'change' }],
containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的真实柜号柜次')), trigger: 'change' }],
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }], carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }], plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')], fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)], packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')], grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')],
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
} }
const carriers = ref<Supplier[]>([]) const carriers = ref<Supplier[]>([])
const containers = ref<OceanBookingAllocatedContainer[]>([]) const candidates = ref<TruckAppointmentCandidate[]>([])
const bookings = ref<OceanBookingResource[]>([])
const optionsLoading = ref(false) const optionsLoading = ref(false)
const optionsError = ref('') const optionsError = ref('')
let bookingLoadSequence = 0
let optionsLoadSequence = 0
const selectableCarriers = computed(() => carriers.value.filter((item) => ( const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE' item.status === 'ACTIVE'
))) )))
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null) const bookings = computed(() => {
const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
const selectableContainers = computed(() => containers.value.filter((item) => isIso6346ContainerNumber(item.containerNo))) for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
const selectedContainerFallback = computed(() => { id: item.oceanBookingId,
const id = draft.containerBookingId bookingNo: item.oceanBookingNo,
const snapshot = props.resource billOfLadingNo: item.billOfLadingNo,
if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null warehouseId: item.warehouseId,
return { warehouseName: item.warehouseName,
id: String(id), bookingDocumentReady: item.bookingDocumentReady,
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`, })
} return [...result.values()]
}) })
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null)
const bookingDocumentReady = computed(() => relationLocked.value
? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
: Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
const selectedBookingFallback = computed(() => { const selectedBookingFallback = computed(() => {
const id = draft.oceanBookingId const id = draft.oceanBookingId
const snapshot = props.resource const snapshot = props.resource
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' } return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` }
}) })
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null } function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
function selectContainer(containerId: string) { async function loadCurrentBookingContext(bookingId: string) {
const item = selectableContainers.value.find((entry) => entry.containerBookingId === containerId) const sequence = ++bookingLoadSequence
draft.containerBookingId = item?.containerBookingId || null
relationWarning.value = ''
if (!item) {
draft.loadingPlanId = null
return
}
draft.loadingPlanId = null
}
async function loadBookingContainers(bookingId: string) {
bookingContainersLoading.value = true bookingContainersLoading.value = true
bookingContainersError.value = null bookingContainersError.value = null
try { try {
const booking = await getOceanBooking(bookingId) const booking = await getOceanBooking(bookingId)
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
if (booking.status !== 'RELEASED') { if (booking.status !== 'RELEASED') {
containers.value = []
bookingDocuments.value = [] bookingDocuments.value = []
draft.containerBookingId = null bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
draft.loadingPlanId = null
bookingContainersError.value = { message: '所选订舱尚未放舱,不能创建拖车预约' }
return return
} }
const bookingIndex = bookings.value.findIndex((item) => item.id === booking.id)
if (bookingIndex >= 0) bookings.value[bookingIndex] = booking
else bookings.value.push(booking)
draft.warehouseId = booking.warehouseId draft.warehouseId = booking.warehouseId
draft.billOfLadingNo = booking.billOfLadingNo draft.billOfLadingNo = booking.billOfLadingNo
containers.value = booking.containers || []
bookingDocuments.value = booking.documents || [] bookingDocuments.value = booking.documents || []
if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) {
draft.containerBookingId = null
draft.loadingPlanId = null
}
} catch (cause) { } catch (cause) {
containers.value = [] if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
bookingDocuments.value = [] bookingDocuments.value = []
draft.containerBookingId = null bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
draft.loadingPlanId = null
bookingContainersError.value = getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')
} finally { } finally {
bookingContainersLoading.value = false if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
} }
} }
async function selectBooking(bookingId: string) { async function selectBooking(bookingId: string) {
bookingLoadSequence += 1
const item = bookings.value.find((entry) => entry.id === bookingId) const item = bookings.value.find((entry) => entry.id === bookingId)
draft.oceanBookingId = item?.id || null draft.oceanBookingId = item?.id || null
draft.containerBookingId = null
draft.loadingPlanId = null
containers.value = []
bookingDocuments.value = [] bookingDocuments.value = []
relationWarning.value = '' relationWarning.value = ''
if (!item) { if (!item) {
@@ -131,37 +112,48 @@ async function selectBooking(bookingId: string) {
} }
draft.warehouseId = item.warehouseId draft.warehouseId = item.warehouseId
draft.billOfLadingNo = item.billOfLadingNo draft.billOfLadingNo = item.billOfLadingNo
await loadBookingContainers(item.id)
} }
async function loadOptions() { async function loadOptions() {
const sequence = ++optionsLoadSequence
optionsLoading.value = true; optionsError.value = '' optionsLoading.value = true; optionsError.value = ''
candidates.value = []
const results = await Promise.allSettled([ const results = await Promise.allSettled([
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }), listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
listOceanBookings({ page: 1, size: 200, status: 'RELEASED' }), relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }),
]) ])
if (sequence !== optionsLoadSequence) return
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE') if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
if (results[1].status === 'fulfilled') bookings.value = results[1].value.records.filter((item) => item.status === 'RELEASED') if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
const failed = ['承运商', '已放舱订舱'].filter((_, index) => results[index]?.status === 'rejected') const failed = ['承运商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败` if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false optionsLoading.value = false
if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId)) if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
else if (draft.oceanBookingId) {
const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId)
if (candidate) {
draft.warehouseId = candidate.warehouseId
draft.billOfLadingNo = candidate.billOfLadingNo
} else {
draft.oceanBookingId = null
}
}
} }
async function validate() { return formRef.value?.validate() } async function validate() { return formRef.value?.validate() }
async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') } async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate }) defineExpose({ validate, loadOptions })
onMounted(() => { void loadOptions() }) onMounted(() => { void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() }) watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() })
watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) void loadOptions() })
</script> </script>
<template> <template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form"> <el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已录入真实 ISO 集装箱号的柜次;仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div> <div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" /> <el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert> <el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col> <el-col :xs="24"><el-divider content-position="left">订舱(必填)</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable placeholder="请先选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可用的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'}${item.billOfLadingNo ? ` / 提单号:${item.billOfLadingNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col> <el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择可创建拖车单的已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="柜次(真实集装箱号)" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择真实柜号柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已录真实集装箱号的柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="`柜次号:${item.cabinetNo || '待生成'} / 集装箱号:${item.containerNo}`" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col> <el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col> <el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col> <el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
@@ -184,20 +176,19 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col> <el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col> <el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col> <el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>RMB</template></el-input></el-form-item></el-col> <el-col :xs="24" :sm="12"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>{{ draft.currency }}</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount" placeholder="请输入应收金额"><template #append>RMB</template></el-input></el-form-item></el-col> <el-col :xs="24" :sm="12"><el-form-item label="币种" prop="currency"><el-select v-model="draft.currency" aria-label="币种" placeholder="请选择币种" style="width:100%"><el-option v-for="currency in TRUCK_APPOINTMENT_CURRENCIES" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" placeholder="例如:RMB" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></el-col> <el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col> <el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col> <el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col> <el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col> <el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col> <el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col> <el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col> <el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col> <el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
</el-row> </el-row>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div> <div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />保存</el-button></div>
</el-form> </el-form>
</template> </template>
@@ -214,6 +205,10 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; } :deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } :deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)} .field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)}
@media (min-width: 1200px) {
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
}
@media (max-width: 480px) { @media (max-width: 480px) {
.truck-form { padding-inline: 2px; } .truck-form { padding-inline: 2px; }
.relation-note { align-items:flex-start; flex-direction:column; } .relation-note { align-items:flex-start; flex-direction:column; }
@@ -0,0 +1,118 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentCreateView from './TruckAppointmentCreateView.vue'
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, replace: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: routing.query }), useRouter: () => ({ replace: routing.replace, push: routing.push }) }))
vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() }))
const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({
id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, oceanBookingId: 'booking-1', attachments: [],
})
const page = (records: TruckAppointmentResource[]) => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
const formStub = {
name: 'TruckAppointmentForm',
props: ['modelValue', 'submitting', 'submitBlocked'],
emits: ['update:modelValue', 'submit', 'attachments'],
template: '<div data-form :data-blocked="String(submitBlocked)"><button data-submit @click="$emit(\'submit\')">保存</button></div>',
}
type ViewModel = {
form: SaveTruckAppointmentRequest
submit: () => Promise<void>
lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise<void>
existingAppointment: TruckAppointmentResource | null
existingLookupError: { message: string } | null
checkedOceanBookingId: string
}
function mountView() {
return mount(TruckAppointmentCreateView, { global: { plugins: [ElementPlus], stubs: { TruckAppointmentForm: formStub, TruckAttachmentDialog: true } } })
}
describe('TruckAppointmentCreateView existing appointment reuse', () => {
beforeEach(() => {
vi.clearAllMocks()
for (const key of Object.keys(routing.query)) delete routing.query[key]
routing.replace.mockResolvedValue(undefined)
routing.push.mockResolvedValue(undefined)
vi.mocked(listTruckAppointments).mockResolvedValue(page([]))
vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT'))
})
it('automatically reuses a DRAFT appointment for a query-prefilled booking', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')]))
const wrapper = mountView()
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ oceanBookingId: 'booking-1', page: 1, size: 20 })
expect(routing.replace).toHaveBeenCalledWith({ name: 'trucking-detail', params: { id: 'appointment-1' }, query: { edit: '1' } })
expect(createTruckAppointment).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
const wrapper = mountView()
await flushPromises()
await (wrapper.vm as unknown as ViewModel).submit()
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
const wrapper = mountView()
await flushPromises()
expect(wrapper.text()).toContain('预约查询失败')
expect(wrapper.text()).toContain('req-existing')
expect(wrapper.text()).toContain('trace-existing')
await (wrapper.vm as unknown as ViewModel).submit()
expect(createTruckAppointment).not.toHaveBeenCalled()
wrapper.unmount()
})
it('ignores a stale lookup response after the selected booking changes', async () => {
let resolveFirst!: (value: ReturnType<typeof page>) => void
let resolveSecond!: (value: ReturnType<typeof page>) => void
vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => {
const { oceanBookingId } = query
if (oceanBookingId === 'booking-1') resolveFirst = resolve
else resolveSecond = resolve
}))
const wrapper = mountView()
const vm = wrapper.vm as unknown as ViewModel
vm.form.oceanBookingId = 'booking-1'
await wrapper.vm.$nextTick()
vm.form.oceanBookingId = 'booking-2'
await wrapper.vm.$nextTick()
resolveSecond(page([]))
await flushPromises()
resolveFirst(page([existing('DRAFT')]))
await flushPromises()
expect(vm.checkedOceanBookingId).toBe('booking-2')
expect(vm.existingAppointment).toBeNull()
expect(routing.replace).not.toHaveBeenCalled()
wrapper.unmount()
})
it('suppresses duplicate create clicks while the first request is pending', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
let resolveCreate!: (value: TruckAppointmentResource) => void
vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve }))
const wrapper = mountView()
await flushPromises()
const vm = wrapper.vm as unknown as ViewModel
const first = vm.submit()
const second = vm.submit()
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
resolveCreate(existing('DRAFT'))
await Promise.all([first, second])
wrapper.unmount()
})
})
@@ -5,22 +5,36 @@ import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
describe('TruckAppointmentCreateView booking-bound creation contract', () => { describe('TruckAppointmentCreateView booking-bound creation contract', () => {
it('starts with required booking and container choices empty until the user selects them', () => { it('starts with only the required booking choice empty until the user selects it', () => {
expect(source).toContain('carrierId: null') expect(source).toContain('carrierId: null')
expect(source).not.toContain('customerId') expect(source).not.toContain('customerId')
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null') expect(source).toContain('oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null')
expect(source).not.toContain('initialContainerBookingId')
expect(source).toContain('plannedLoadingAt: null') expect(source).toContain('plannedLoadingAt: null')
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
expect(source).not.toContain('receivableAmount')
expect(source).toContain('createTruckAppointment({ ...form })') expect(source).toContain('createTruckAppointment({ ...form })')
}) })
it('keeps the form visible and exposes the real backend error details', () => { it('keeps the form visible and exposes the real backend error details', () => {
expect(source).toContain("getApiErrorDetails(err, '创建失败,请稍后重试')") expect(source).toContain("getApiErrorDetails(err, '创建失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="error" />') expect(source).toContain('<ApiErrorMeta :details="error" />')
expect(source).toContain('<TruckAppointmentForm v-model="form"') expect(source).toContain('<TruckAppointmentForm :model-value="form"')
expect(source).toContain('@update:model-value="updateForm"')
}) })
it('suppresses duplicate submits while the first create request is pending', () => { it('suppresses duplicate submits while the first create request is pending', () => {
expect(source).toContain('if (saving.value) return') expect(source).toContain('if (saving.value || existingLookupLoading.value) return')
})
it('reuses non-terminal appointments by booking and separates lookup failures from booking-file errors', () => {
expect(source).toContain("listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })")
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
expect(source).toContain('该订舱已有拖车单')
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
expect(source).toContain("router.replace({ name: 'trucking-detail'")
expect(source).toContain('serial !== lookupSerial')
}) })
it('places the return action beside the relation note instead of in a separate header row', () => { it('places the return action beside the relation note instead of in a separate header row', () => {
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@lucide/vue' import { ArrowLeft } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -10,17 +10,70 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue' import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue' import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { createTruckAppointment } from '../api/platformTrucking' import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest } from '../types' import { truckAppointmentStatusLabel, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null) const route = useRoute(); const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' }) const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } } const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
const existingAppointment = ref<TruckAppointmentResource | null>(null)
const existingLookupLoading = ref(false)
const existingLookupError = ref<ApiErrorDetails | null>(null)
const checkedOceanBookingId = ref('')
const navigatingToExisting = ref(false)
let lookupSerial = 0
const terminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])
const existingCanEdit = computed(() => ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(existingAppointment.value?.status || ''))
const submitBlocked = computed(() => existingLookupLoading.value || Boolean(existingLookupError.value) || Boolean(existingAppointment.value))
function updateForm(value: SaveTruckAppointmentRequest) { Object.assign(form, value) }
async function openExisting() {
if (!existingAppointment.value || navigatingToExisting.value) return
navigatingToExisting.value = true
try { await router.replace({ name: 'trucking-detail', params: { id: existingAppointment.value.id }, query: existingCanEdit.value ? { edit: '1' } : undefined }) }
finally { navigatingToExisting.value = false }
}
async function lookupExistingAppointment(oceanBookingId: string, autoNavigate = false) {
const normalizedId = oceanBookingId.trim()
const serial = ++lookupSerial
existingAppointment.value = null
existingLookupError.value = null
checkedOceanBookingId.value = ''
if (!normalizedId) { existingLookupLoading.value = false; return }
existingLookupLoading.value = true
try {
const result = await listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
checkedOceanBookingId.value = normalizedId
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
if (autoNavigate && existingAppointment.value) await openExisting()
} catch (cause) {
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
checkedOceanBookingId.value = normalizedId
existingLookupError.value = getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')
} finally {
if (serial === lookupSerial) existingLookupLoading.value = false
}
}
async function submit() {
if (saving.value || existingLookupLoading.value) return
const oceanBookingId = String(form.oceanBookingId || '')
if (oceanBookingId && checkedOceanBookingId.value !== oceanBookingId) await lookupExistingAppointment(oceanBookingId)
if (String(form.oceanBookingId || '') !== oceanBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
saving.value = true; error.value = null
try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) }
catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') }
finally { saving.value = false }
}
watch(() => form.oceanBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialOceanBookingId && String(value || '') === initialOceanBookingId)) }, { immediate: true })
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedOceanBookingId })
</script> </script>
<template> <template>
<AdminPage as="div" class="truck-editor-page"> <AdminPage as="div" class="truck-editor-page">
<DetailPageLayout aria-label="新增拖车预约"> <DetailPageLayout aria-label="新增拖车预约">
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section> <el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" /> <TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
</DetailPageLayout> </DetailPageLayout>
</AdminPage> </AdminPage>
@@ -32,18 +32,19 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain("statuses.indexOf(status) === index") expect(source).toContain("statuses.indexOf(status) === index")
}) })
it('initializes the required booking and container associations explicitly', () => { it('initializes the required booking association explicitly', () => {
expect(source).toContain("carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null") expect(source).toContain("carrierId: null, oceanBookingId: null, plannedLoadingAt: null")
expect(source).not.toContain('containerBookingId')
expect(source).not.toContain('customerId') expect(source).not.toContain('customerId')
expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map') expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map')
expect(source).toContain('normalizeTruckAppointmentCurrency(result.currency)')
expect(source).not.toContain('Object.assign(form, { ...result') expect(source).not.toContain('Object.assign(form, { ...result')
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')") expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
}) })
it('blocks dispatch until the released booking, allocated real container and frozen bill number are present', () => { it('blocks dispatch until the released booking and frozen documents are present', () => {
expect(source).toContain("return '确认派车前必须关联已放舱订舱'") expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'") expect(source).not.toContain('关联柜次')
expect(source).toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'") expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'") expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)') expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
@@ -51,6 +52,13 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'") expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
}) })
it('keeps pickup booking-scoped and leaves real container maintenance to container/preload flows', () => {
expect(source).not.toContain('ElMessageBox.prompt')
expect(source).not.toContain('normalizeIso6346ContainerNumber')
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 })')
expect(source).toContain('await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`')
})
it('keeps the main detail available when attachments or history fail', () => { it('keeps the main detail available when attachments or history fail', () => {
expect(source).toContain('Promise.allSettled([') expect(source).toContain('Promise.allSettled([')
expect(source).toContain("attachments.status === 'fulfilled'") expect(source).toContain("attachments.status === 'fulfilled'")
@@ -59,6 +67,14 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain('v-if="relatedError"') expect(source).toContain('v-if="relatedError"')
}) })
it('never exposes container identity or operator database ids', () => {
expect(source).not.toContain('cabinetNo')
expect(source).not.toContain('containerNo')
expect(source).not.toContain('containerBookingId')
expect(source).not.toContain('loadingPlanId')
expect(source).not.toContain('item.operatorId')
})
it('reloads a cached detail after an error without showing stale resource data', () => { it('reloads a cached detail after an error without showing stale resource data', () => {
expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''") expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''")
expect(source).toContain('onActivated(() => {') expect(source).toContain('onActivated(() => {')
@@ -13,15 +13,14 @@ import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookin
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking' import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue' import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue' import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types' import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
import { isIso6346ContainerNumber } from '../iso6346' const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, plannedLoadingAt: null })
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
const history = ref<TruckAppointmentStatusHistory[]>([]) const history = ref<TruckAppointmentStatusHistory[]>([])
const bookingReadiness = ref<OceanBookingDetail | null>(null) const bookingReadiness = ref<OceanBookingDetail | null>(null)
const relatedError = ref('') const relatedError = ref('')
let activatedOnce = false let activatedOnce = false
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit')) const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '') const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.billOfLadingNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无订舱关联' : '')
async function loadRelated() { async function loadRelated() {
if (!resource.value) return if (!resource.value) return
relatedError.value = '' relatedError.value = ''
@@ -35,7 +34,7 @@ async function loadRelated() {
if (booking.status === 'fulfilled') bookingReadiness.value = booking.value if (booking.status === 'fulfilled') bookingReadiness.value = booking.value
if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。' if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。'
} }
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } } async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : key === 'currency' ? normalizeTruckAppointmentCurrency(result.currency) : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } } async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' } const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' } const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
@@ -50,16 +49,17 @@ const dispatchBlockReason = computed(() => {
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return '' if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱' if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱' if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
if (!isIso6346ContainerNumber(resource.value.containerNo)) return '确认派车前关联柜次必须录入真实 ISO 集装箱号'
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号' if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件' if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
return '' return ''
}) })
async function confirmStatusChange(status: TruckAppointmentStatus) {
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
}
async function changeStatus(status: TruckAppointmentStatus) { async function changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status || statusSaving.value) return if (!resource.value || status === resource.value.status || statusSaving.value) return
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return } if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false } try { await confirmStatusChange(status); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 }) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
} }
onMounted(load) onMounted(load)
onActivated(() => { onActivated(() => {
@@ -77,7 +77,7 @@ onActivated(() => {
<section class="truck-overview surface-panel" aria-label="拖车预约概览"> <section class="truck-overview surface-panel" aria-label="拖车预约概览">
<div class="truck-overview__heading"> <div class="truck-overview__heading">
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button> <el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.containerNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱与柜次信息缺失' : '查看拖车预约详情' }}</p></div> <div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.billOfLadingNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱信息缺失' : '查看拖车预约详情' }}</p></div>
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span> <span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div> <div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
</div> </div>
@@ -92,7 +92,7 @@ onActivated(() => {
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource"> <template v-else-if="resource">
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section> <section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section> <section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" /> <TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
</template> </template>
</DetailPageLayout> </DetailPageLayout>
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentsView from './TruckAppointmentsView.vue' import TruckAppointmentsView from './TruckAppointmentsView.vue'
import { listTruckAppointments } from '../api/platformTrucking' import { listTruckAppointments } from '../api/platformTrucking'
import type { TruckAppointmentResource } from '../types' import type { TruckAppointmentResource } from '../types'
@@ -19,9 +20,9 @@ vi.mock('@/modules/iam', () => ({
})) }))
const row: TruckAppointmentResource = { const row: TruckAppointmentResource = {
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', containerBookingId: '77', cabinetNo: 'CAB-1', id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1',
containerNo: 'CONT-1', warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345', warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', receivableAmount: '3800.00', attachmentCount: 2, attachments: [], plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', currency: 'RMB', attachmentCount: 2, attachments: [],
} }
describe('TruckAppointmentsView', () => { describe('TruckAppointmentsView', () => {
@@ -37,15 +38,13 @@ describe('TruckAppointmentsView', () => {
it('loads the real list and renders relationship snapshots', async () => { it('loads the real list and renders relationship snapshots', async () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerBookingId: '', containerType: '' }) expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '' })
expect(wrapper.text()).toContain('TA-9001') expect(wrapper.text()).toContain('TA-9001')
expect(wrapper.text()).toContain('CONT-1')
expect(wrapper.text()).toContain('CAB-1')
expect(wrapper.text()).toContain('OB-1') expect(wrapper.text()).toContain('OB-1')
expect(wrapper.text()).toContain('BL-1')
expect(wrapper.text()).toContain('义乌仓') expect(wrapper.text()).toContain('义乌仓')
expect(wrapper.text()).toContain('船公司A') expect(wrapper.text()).toContain('船公司A')
expect(wrapper.text()).toContain('代理A') expect(wrapper.text()).toContain('代理A')
expect(wrapper.text()).not.toContain('77')
expect(wrapper.text()).toContain('宁波运输') expect(wrapper.text()).toContain('宁波运输')
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true) expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
}) })
@@ -64,10 +63,10 @@ describe('TruckAppointmentsView', () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
expect(wrapper.find('button[aria-label="查看 9002"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="查看拖车预约详情"]').exists()).toBe(true) expect(wrapper.find('button[aria-label="查看拖车预约详情"]').exists()).toBe(true)
const labels = wrapper.findAll('button[aria-label]').map((button) => button.attributes('aria-label')) const labels = wrapper.findAll('button[aria-label]').map((button) => button.attributes('aria-label'))
expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true) expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true)
expect(labels.every((label) => !label?.includes('9002'))).toBe(true)
expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)") expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)")
}) })
@@ -93,6 +92,48 @@ describe('TruckAppointmentsView', () => {
expect(source.match(/empty-text=/g)).toHaveLength(1) expect(source.match(/empty-text=/g)).toHaveLength(1)
}) })
it('renders the server empty state', async () => {
vi.mocked(listTruckAppointments).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 1 })
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('暂无符合筛选条件的拖车预约')
})
it('marks the table busy while the list request is pending', async () => {
let resolveLoad!: (value: Awaited<ReturnType<typeof listTruckAppointments>>) => void
vi.mocked(listTruckAppointments).mockImplementationOnce(() => new Promise((resolve) => { resolveLoad = resolve }))
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('section[aria-busy="true"]').exists()).toBe(true)
resolveLoad({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('section[aria-busy="false"]').exists()).toBe(true)
})
it('keeps a recoverable load error and retries without losing the page', async () => {
vi.mocked(listTruckAppointments).mockRejectedValueOnce(new ApiResponseError('候选服务暂不可用', 'A0503', 503, undefined, 'req-truck', 'trace-truck'))
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('候选服务暂不可用')
expect(wrapper.text()).toContain('req-truck')
vi.mocked(listTruckAppointments).mockResolvedValueOnce({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
await wrapper.get('button').trigger('click')
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('TA-9001')
})
it('shows create only with the independent create permission', async () => {
let wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('新增拖车')
wrapper.unmount()
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list', 'platform:firstleg:truck-appointment:create']
wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('新增拖车')
})
it('shows the two frequent row actions directly without a more menu', () => { it('shows the two frequent row actions directly without a more menu', () => {
expect(source).not.toContain('platform:firstleg:truck-appointment:view') expect(source).not.toContain('platform:firstleg:truck-appointment:view')
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)") expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
@@ -125,10 +166,12 @@ describe('TruckAppointmentsView', () => {
expect(source).not.toContain("key: 'customerName'") expect(source).not.toContain("key: 'customerName'")
expect(source).toContain("key: 'updatedAt'") expect(source).toContain("key: 'updatedAt'")
expect(source).toContain("key: 'payableAmount', label: '应付拖车费'") expect(source).toContain("key: 'payableAmount', label: '应付拖车费'")
expect(source).toContain("key: 'receivableAmount', label: '应收拖车费'")
expect(source.indexOf("key: 'store'")).toBeLessThan(source.indexOf("key: 'payableAmount'")) expect(source.indexOf("key: 'store'")).toBeLessThan(source.indexOf("key: 'payableAmount'"))
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'receivableAmount'")) expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source.indexOf("key: 'receivableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'")) expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
expect(source).not.toContain("key: 'containerNo'")
expect(source).not.toContain("key: 'cabinetNo'")
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })") expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
expect(source).toContain("key: 'attachmentCount'") expect(source).toContain("key: 'attachmentCount'")
}) })
@@ -145,4 +188,10 @@ describe('TruckAppointmentsView', () => {
expect(source).not.toContain("truck-appointment:view')\" link") expect(source).not.toContain("truck-appointment:view')\" link")
expect(source).toContain(':total="total" stripe preference-key="trucking-appointments"') expect(source).toContain(':total="total" stripe preference-key="trucking-appointments"')
}) })
it('uses compact status/type filters and a wider composite keyword field', () => {
expect(source).toContain("key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司', width: 280")
expect(source).toContain("key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160")
expect(source).toContain("key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', width: 160")
})
}) })
@@ -16,11 +16,11 @@ import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointme
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null) const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value }) const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '' }); const applied = ref({ ...filters.value })
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司' }, { key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司', width: 280 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
{ key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', options: [ { key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', width: 160, options: [
{ value: '20GP', label: '20GP' }, { value: '40GP', label: '40GP' }, { value: '40HC', label: '40HC' }, { value: '20GP', label: '20GP' }, { value: '40GP', label: '40GP' }, { value: '40HC', label: '40HC' },
] }, ] },
]) ])
@@ -28,8 +28,7 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 }, { key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
{ key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' }, { key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.cabinetNo || '—' }, { key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 160, formatter: (row: TruckAppointmentResource) => row.billOfLadingNo || '—' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '—' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' }, { key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' },
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' }, { key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
@@ -38,7 +37,6 @@ const columns: DataTableColumn[] = [
{ key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' }, { key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' },
{ key: 'store', label: '门店', minWidth: 140, slotName: 'store' }, { key: 'store', label: '门店', minWidth: 140, slotName: 'store' },
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.payableAmount, row.currency) }, { key: 'payableAmount', label: '应付拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.payableAmount, row.currency) },
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.receivableAmount, row.currency) },
{ key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) }, { key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) },
{ key: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'status', label: '状态', width: 130, slotName: 'status' },
{ key: 'attachmentCount', label: '附件', width: 90, align: 'right', formatter: (row: TruckAppointmentResource) => String(Number(row.attachmentCount ?? 0)) }, { key: 'attachmentCount', label: '附件', width: 90, align: 'right', formatter: (row: TruckAppointmentResource) => String(Number(row.attachmentCount ?? 0)) },
@@ -52,16 +50,14 @@ function formatAmount(value?: string | null, currency?: string | null) {
return currency ? `${display} ${currency}` : display return currency ? `${display} ${currency}` : display
} }
function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointmentResource) { function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointmentResource) {
const identifier = [row.appointmentNo, row.id] const identifier = String(row.appointmentNo ?? '').trim()
.map((value) => String(value ?? '').trim())
.find(Boolean)
return identifier ? `${action} ${identifier}` : `${action}拖车预约详情` return identifier ? `${action} ${identifier}` : `${action}拖车预约详情`
} }
function statusLabel(value: unknown) { function statusLabel(value: unknown) {
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null) return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
} }
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } } async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 } function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '' }; if (page.value === 1) void load(); else page.value = 1 }
onMounted(() => { void load() }) onMounted(() => { void load() })
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) } function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
</script> </script>
+46 -16
View File
@@ -52,7 +52,17 @@ export function truckAppointmentActionLabel(action: string | null | undefined) {
return truckAppointmentActionLabels[action] || '状态更新' return truckAppointmentActionLabels[action] || '状态更新'
} }
export type TruckAttachmentType = 'DISPATCH_ORDER' | 'DRIVER_DOCUMENT' | 'VEHICLE_DOCUMENT' | 'OTHER' export const TRUCK_ATTACHMENT_TYPES = ['DISPATCH_ORDER', 'DRIVER_DOCUMENT', 'VEHICLE_DOCUMENT', 'OTHER'] as const
export type TruckAttachmentType = typeof TRUCK_ATTACHMENT_TYPES[number]
export function isTruckAttachmentType(value: unknown): value is TruckAttachmentType {
return TRUCK_ATTACHMENT_TYPES.includes(value as TruckAttachmentType)
}
export const TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD'] as const
export type TruckAppointmentCurrency = typeof TRUCK_APPOINTMENT_CURRENCIES[number]
export function normalizeTruckAppointmentCurrency(value: unknown): TruckAppointmentCurrency {
const normalized = String(value ?? '').trim().toUpperCase()
return TRUCK_APPOINTMENT_CURRENCIES.includes(normalized as TruckAppointmentCurrency) ? normalized as TruckAppointmentCurrency : 'RMB'
}
export interface TruckAttachmentReference { export interface TruckAttachmentReference {
id?: string id?: string
@@ -89,9 +99,6 @@ export interface TruckAppointmentResource {
outboundOrderId?: string | null outboundOrderId?: string | null
oceanBookingId?: string | null oceanBookingId?: string | null
oceanBookingNo?: string | null oceanBookingNo?: string | null
containerBookingId?: string | null
cabinetNo?: string | null
loadingPlanId?: string | null
warehouseId?: string | null warehouseId?: string | null
warehouseName?: string | null warehouseName?: string | null
shippingCompanyId?: string | null shippingCompanyId?: string | null
@@ -121,14 +128,11 @@ export interface TruckAppointmentResource {
volumeCbm?: string | null volumeCbm?: string | null
palletCount?: number | string | null palletCount?: number | string | null
payableAmount?: string | null payableAmount?: string | null
receivableAmount?: string | null currency?: TruckAppointmentCurrency | null
currency?: string | null
poNo?: string | null poNo?: string | null
remark?: string | null remark?: string | null
orderId?: string | null orderId?: string | null
orderNo?: string | null orderNo?: string | null
containerNo?: string | null
relatedContainerType?: string | null
entrustingPartyName?: string | null entrustingPartyName?: string | null
attachments?: TruckAttachmentReference[] | null attachments?: TruckAttachmentReference[] | null
attachmentCount?: number | string | null attachmentCount?: number | string | null
@@ -140,8 +144,6 @@ export interface TruckAppointmentResource {
export interface SaveTruckAppointmentRequest { export interface SaveTruckAppointmentRequest {
outboundOrderId?: string | null outboundOrderId?: string | null
oceanBookingId: string | null oceanBookingId: string | null
containerBookingId: string | null
loadingPlanId?: string | null
warehouseId?: string | null warehouseId?: string | null
carrierId: string | null carrierId: string | null
carrierName?: string | null carrierName?: string | null
@@ -165,19 +167,18 @@ export interface SaveTruckAppointmentRequest {
volumeCbm?: string | null volumeCbm?: string | null
palletCount?: number | string | null palletCount?: number | string | null
payableAmount?: string | null payableAmount?: string | null
receivableAmount?: string | null currency?: TruckAppointmentCurrency | null
currency?: string | null
poNo?: string | null poNo?: string | null
remark?: string | null remark?: string | null
expectedVersion: number expectedVersion: number
} }
export const TRUCK_APPOINTMENT_SAVE_FIELDS = [ export const TRUCK_APPOINTMENT_SAVE_FIELDS = [
'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'outboundOrderId', 'oceanBookingId', 'warehouseId',
'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName', 'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName',
'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone', 'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone',
'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount', 'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount',
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'receivableAmount', 'currency', 'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'currency',
'poNo', 'remark', 'expectedVersion', 'poNo', 'remark', 'expectedVersion',
] as const satisfies readonly (keyof SaveTruckAppointmentRequest)[] ] as const satisfies readonly (keyof SaveTruckAppointmentRequest)[]
@@ -186,8 +187,37 @@ export interface TruckAppointmentQuery {
size?: number size?: number
keyword?: string keyword?: string
status?: TruckAppointmentStatus | '' status?: TruckAppointmentStatus | ''
containerBookingId?: string | '' oceanBookingId?: string | ''
containerType?: string | '' }
export interface TruckAppointmentCandidateQuery {
page?: number
size?: number
keyword?: string
}
export interface TruckAppointmentCandidate {
oceanBookingId: string
oceanBookingNo: string
billOfLadingNo: string | null
warehouseId: string
warehouseName: string | null
channelCode: string | null
channelName: string | null
bookingDocumentReady: boolean
}
export interface TruckAppointmentCandidatePage {
records: TruckAppointmentCandidate[]
page: number
size: number
total: number
pages: number
}
export interface TruckAppointmentActionRequest {
expectedVersion: number
reason?: string | null
} }
export interface TruckAppointmentPage { export interface TruckAppointmentPage {
@@ -8,6 +8,7 @@ import {
downloadPlatformProductImage, downloadPlatformProductImage,
listPlatformUnifiedOrders, listPlatformUnifiedOrders,
listPlatformUnifiedOrderFeeDetails, listPlatformUnifiedOrderFeeDetails,
updatePlatformUnifiedOrderDeclaration,
} from './unifiedOrders' } from './unifiedOrders'
vi.mock('@/shared/api/http', () => ({ vi.mock('@/shared/api/http', () => ({
@@ -147,6 +148,18 @@ describe('unified orders API contract', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } }) expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } })
}) })
it('updates customer declaration fields through the versioned tenant-scoped contract', async () => {
const request = {
expectedVersion: 7, customerRemark: '易碎', expectedArrivalAt: '2026-09-06T10:00:00',
channelCode: 'US-SEA', channelName: '美西海运',
cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }],
}
vi.mocked(http.put).mockResolvedValue({ data: { orderNo: 'FL-1', inboundCode: 'RK-1', previousVersion: 7, version: 8, channelChangedBefore: false, channelChanged: true, changes: [] } })
await expect(updatePlatformUnifiedOrderDeclaration('208', '90001', request)).resolves.toMatchObject({ version: 8, channelChanged: true })
expect(http.put).toHaveBeenCalledWith('/platform/v1/orders/90001/declaration', request, { params: { tenantId: '208' } })
})
it('loads fee detail rows from the inbound fee-detail contract', async () => { it('loads fee detail rows from the inbound fee-detail contract', async () => {
vi.mocked(http.get).mockResolvedValue({ data: [{ id: '1', inboundId: '88', reviewStatus: 'APPROVED', amount: 100 }] }) vi.mocked(http.get).mockResolvedValue({ data: [{ id: '1', inboundId: '88', reviewStatus: 'APPROVED', amount: 100 }] })
await expect(listPlatformUnifiedOrderFeeDetails('208', '88')).resolves.toHaveLength(1) await expect(listPlatformUnifiedOrderFeeDetails('208', '88')).resolves.toHaveLength(1)
@@ -93,6 +93,7 @@ export interface UnifiedOrderResource {
customerName: string | null customerName: string | null
channelCode: string | null channelCode: string | null
channelName: string | null channelName: string | null
channelChanged?: boolean
receivingWarehouseId: number | string | null receivingWarehouseId: number | string | null
receivingWarehouseName: string | null receivingWarehouseName: string | null
destinationWarehouseId: number | string | null destinationWarehouseId: number | string | null
@@ -102,6 +103,7 @@ export interface UnifiedOrderResource {
inboundCode: string | null inboundCode: string | null
expectedShipDate: string | null expectedShipDate: string | null
totalPackageCount: number | null totalPackageCount: number | null
expectedVolumeCbm?: number | null
locationCodes: string | null locationCodes: string | null
actualGrossWeightKg: number | null actualGrossWeightKg: number | null
actualVolumeCbm: number | null actualVolumeCbm: number | null
@@ -170,6 +172,7 @@ export interface UnifiedOrderTimelineEvent {
export interface UnifiedOrderDetailOrderSnapshot { export interface UnifiedOrderDetailOrderSnapshot {
id: number | string | null id: number | string | null
version: number | null
tenantId: number | string | null tenantId: number | string | null
orderNo: string | null orderNo: string | null
customerId: number | string | null customerId: number | string | null
@@ -329,6 +332,58 @@ function id(value: number | string | null | undefined) {
return value === null || value === undefined || value === '' ? null : String(value) return value === null || value === undefined || value === '' ? null : String(value)
} }
export interface UnifiedOrderDeclarationCargo {
itemNo: number
cargoName: string
cargoNameEn?: string | null
skuNo?: string | null
model?: string | null
packageCount: number
totalQuantity: number
lengthCm: number
widthCm: number
heightCm: number
grossWeightKg: number
volumeCbm: number
netWeightKg?: number | null
hsCode?: string | null
customsHsCode?: string | null
unitsPerPackage?: number | null
unitDeclaredValue?: number | null
declaredValue?: number | null
currency?: string | null
material?: string | null
usage?: string | null
brand?: string | null
cargoRemark?: string | null
}
export interface UpdateUnifiedOrderDeclarationRequest {
expectedVersion: number
customerRemark?: string | null
expectedArrivalAt?: string | null
channelCode: string
channelName: string
cargoes: UnifiedOrderDeclarationCargo[]
}
export interface UnifiedOrderDeclarationChange {
itemNo: number | null
field: string
beforeValue: string | null
afterValue: string | null
}
export interface UpdateUnifiedOrderDeclarationResult {
orderNo: string | null
inboundCode: string | null
previousVersion: number
version: number
channelChangedBefore: boolean
channelChanged: boolean
changes: UnifiedOrderDeclarationChange[]
}
function optionalText(value: string | null | undefined) { function optionalText(value: string | null | undefined) {
const normalized = value?.trim() const normalized = value?.trim()
return normalized || null return normalized || null
@@ -357,6 +412,8 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
childCount: integer(raw.relation.childCount, 0), childCount: integer(raw.relation.childCount, 0),
} : null, } : null,
totalPackageCount: nullableInteger(raw.totalPackageCount), totalPackageCount: nullableInteger(raw.totalPackageCount),
expectedVolumeCbm: numberValue(raw.expectedVolumeCbm),
channelChanged: Boolean(raw.channelChanged),
actualGrossWeightKg: numberValue(raw.actualGrossWeightKg), actualGrossWeightKg: numberValue(raw.actualGrossWeightKg),
actualVolumeCbm: numberValue(raw.actualVolumeCbm), actualVolumeCbm: numberValue(raw.actualVolumeCbm),
totalPalletCount: nullableInteger(raw.totalPalletCount), totalPalletCount: nullableInteger(raw.totalPalletCount),
@@ -413,7 +470,26 @@ export async function getPlatformUnifiedOrder(tenantId: string, orderId: string)
{ params: { tenantId } }, { params: { tenantId } },
) )
const result = unwrapApiResponse(response) const result = unwrapApiResponse(response)
return { ...result, summary: normalizeRow(result.summary) } return {
...result,
summary: normalizeRow(result.summary),
order: {
...result.order,
order: result.order.order ? { ...result.order.order, version: nullableInteger(result.order.order.version) } : null,
},
}
}
export function updatePlatformUnifiedOrderDeclaration(
tenantId: string,
orderId: string,
request: UpdateUnifiedOrderDeclarationRequest,
) {
return http.put<ApiEnvelope<UpdateUnifiedOrderDeclarationResult>, ApiEnvelope<UpdateUnifiedOrderDeclarationResult>>(
`${unifiedOrdersApiPath}/${orderId}/declaration`,
request,
{ params: { tenantId } },
).then(unwrapApiResponse)
} }
export function downloadPlatformProductImage(tenantId: string, fileId: string) { export function downloadPlatformProductImage(tenantId: string, fileId: string) {
@@ -0,0 +1,75 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { updatePlatformUnifiedOrderDeclaration } from '@/modules/unified-orders/api/unifiedOrders'
import OrderDeclarationEditorDialog from './OrderDeclarationEditorDialog.vue'
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ updatePlatformUnifiedOrderDeclaration: vi.fn() }))
const detail = {
summary: {
orderId: '90001', tenantId: '208', inboundCode: 'RK-001', mainStatus: 'WAIT_MEASURE',
receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓',
channelCode: 'OLD', channelName: '原渠道', channelChanged: false,
expectedArrivalAt: '2026-09-06T10:00:00', customerRemark: '原备注',
},
order: {
order: { version: 7, channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' },
cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }],
parties: [], internalNotes: [], statusHistory: [],
},
timeline: [],
} as never
function createWrapper() {
return mount(OrderDeclarationEditorDialog, {
props: { modelValue: true, detail },
global: {
plugins: [ElementPlus],
stubs: {
AppDialog: {
props: ['modelValue', 'confirmLoading'], emits: ['confirm', 'update:modelValue'],
template: '<section v-if="modelValue"><slot /><button data-testid="save" @click="$emit(\'confirm\')">保存</button></section>',
},
LoadErrorState: { props: ['title', 'message'], template: '<div class="load-error">{{ title }} {{ message }}<slot name="details" /></div>' },
ApiErrorMeta: true,
},
},
})
}
describe('OrderDeclarationEditorDialog', () => {
beforeEach(() => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockReset()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
})
it('requires a second confirmation when the channel changes and keeps inbound/warehouse facts read-only', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockResolvedValue({ version: 8 } as never)
const wrapper = createWrapper()
expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单')
expect(wrapper.text()).toContain('RK-001')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('NEW')
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('现有入仓码和箱唛不会改变'), '确认变更渠道', expect.any(Object))
expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW' }))
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
})
it('preserves the editor and entered values when the versioned update fails', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockRejectedValue(new Error('订单版本已变化,请刷新详情后重试'))
const wrapper = createWrapper()
const inputs = wrapper.findAll('input')
await inputs[1]!.setValue('保留填写的渠道名')
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(wrapper.find('.load-error').exists()).toBe(true)
expect((inputs[1]!.element as HTMLInputElement).value).toBe('保留填写的渠道名')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
updatePlatformUnifiedOrderDeclaration,
type UnifiedOrderDeclarationCargo,
type UnifiedOrderDetailResource,
} from '@/modules/unified-orders/api/unifiedOrders'
const props = defineProps<{
modelValue: boolean
detail: UnifiedOrderDetailResource
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: []
}>()
type EditableCargo = UnifiedOrderDeclarationCargo & { key: string }
const saving = ref(false)
const submitError = ref<ApiErrorDetails | null>(null)
const originalChannel = reactive({ code: '', name: '' })
const form = reactive({
expectedVersion: 0,
customerRemark: '',
expectedArrivalAt: '',
channelCode: '',
channelName: '',
cargoes: [] as EditableCargo[],
})
function text(row: Record<string, unknown>, key: string) {
const value = row[key]
return value === null || value === undefined ? '' : String(value)
}
function number(row: Record<string, unknown>, key: string, fallback = 0) {
const parsed = Number(row[key])
return Number.isFinite(parsed) ? parsed : fallback
}
function optionalNumber(row: Record<string, unknown>, key: string) {
const value = row[key]
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function resetForm() {
const snapshot = props.detail.order.order
form.expectedVersion = Number(snapshot?.version ?? 0)
form.customerRemark = snapshot?.customerRemark || props.detail.summary.customerRemark || ''
form.expectedArrivalAt = props.detail.summary.expectedArrivalAt || ''
form.channelCode = snapshot?.channelCode || props.detail.summary.channelCode || ''
form.channelName = snapshot?.channelName || props.detail.summary.channelName || ''
originalChannel.code = form.channelCode.trim()
originalChannel.name = form.channelName.trim()
form.cargoes = props.detail.order.cargoes.map((source, index) => {
const row = source as Record<string, unknown>
const itemNo = number(row, 'itemNo', index + 1)
return {
key: `${itemNo}-${index}`,
itemNo,
cargoName: text(row, 'cargoName'), cargoNameEn: text(row, 'cargoNameEn'),
skuNo: text(row, 'skuNo'), model: text(row, 'model'),
packageCount: number(row, 'packageCount'), totalQuantity: number(row, 'totalQuantity'),
lengthCm: number(row, 'lengthCm'), widthCm: number(row, 'widthCm'), heightCm: number(row, 'heightCm'),
grossWeightKg: number(row, 'grossWeightKg'), volumeCbm: number(row, 'volumeCbm'),
netWeightKg: optionalNumber(row, 'netWeightKg'),
hsCode: text(row, 'hsCode'), customsHsCode: text(row, 'customsHsCode'),
unitsPerPackage: optionalNumber(row, 'unitsPerPackage'),
unitDeclaredValue: optionalNumber(row, 'unitDeclaredValue'), declaredValue: optionalNumber(row, 'declaredValue'),
currency: text(row, 'currency'), material: text(row, 'material'), usage: text(row, 'usage'),
brand: text(row, 'brand'), cargoRemark: text(row, 'cargoRemark') || text(row, 'remark'),
}
})
submitError.value = null
}
watch(() => props.modelValue, (open) => { if (open) resetForm() }, { immediate: true })
function nullable(value: string | null | undefined) {
return value?.trim() || null
}
function validate() {
if (!form.channelCode.trim() || !form.channelName.trim()) return '渠道编码和渠道名称均不能为空'
if (!form.cargoes.length) return '订单缺少货品明细,不能保存'
for (const cargo of form.cargoes) {
if (!cargo.cargoName.trim()) return `第 ${cargo.itemNo} 项中文品名不能为空`
if (cargo.packageCount < 1 || cargo.totalQuantity < 1) return `第 ${cargo.itemNo} 项箱数和总数量必须大于 0`
if ([cargo.lengthCm, cargo.widthCm, cargo.heightCm, cargo.grossWeightKg, cargo.volumeCbm].some((value) => !(value > 0))) {
return `第 ${cargo.itemNo} 项尺寸、毛重和体积必须大于 0`
}
}
return ''
}
async function save() {
const validationMessage = validate()
if (validationMessage) { ElMessage.warning(validationMessage); return }
const channelChanged = form.channelCode.trim() !== originalChannel.code || form.channelName.trim() !== originalChannel.name
if (channelChanged) {
try {
await ElMessageBox.confirm(
'渠道变更后,系统会重新校验分柜、同柜及计费约束;现有入仓码和箱唛不会改变。确认继续吗?',
'确认变更渠道',
{ type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '返回检查' },
)
} catch { return }
}
const snapshot = props.detail.order.order
if (!snapshot?.version && snapshot?.version !== 0) { ElMessage.error('订单版本缺失,请刷新详情后重试'); return }
saving.value = true
submitError.value = null
try {
await updatePlatformUnifiedOrderDeclaration(String(props.detail.summary.tenantId), String(props.detail.summary.orderId), {
expectedVersion: form.expectedVersion,
customerRemark: nullable(form.customerRemark),
expectedArrivalAt: nullable(form.expectedArrivalAt),
channelCode: form.channelCode.trim(),
channelName: form.channelName.trim(),
cargoes: form.cargoes.map(({ key: _key, ...cargo }) => ({
...cargo,
cargoName: cargo.cargoName.trim(), cargoNameEn: nullable(cargo.cargoNameEn),
skuNo: nullable(cargo.skuNo), model: nullable(cargo.model), hsCode: nullable(cargo.hsCode),
customsHsCode: nullable(cargo.customsHsCode), currency: nullable(cargo.currency)?.toUpperCase() || null,
material: nullable(cargo.material), usage: nullable(cargo.usage), brand: nullable(cargo.brand),
cargoRemark: nullable(cargo.cargoRemark),
})),
})
ElMessage.success('客户申报资料已更新')
emit('update:modelValue', false)
emit('updated')
} catch (error) {
submitError.value = getApiErrorDetails(error, '客户申报资料保存失败,请核对权限、订单状态或刷新版本后重试')
} finally {
saving.value = false
}
}
</script>
<template>
<AppDialog
:model-value="modelValue"
title="编辑客户申报资料"
width="min(1500px, calc(100vw - 36px))"
confirm-text="保存申报资料"
:confirm-loading="saving"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@confirm="save"
>
<div class="declaration-editor">
<el-alert type="warning" :closable="false" title="仅修改客户原始申报资料;仓库/PDA 实测数据不会被覆盖。国内收货仓和中转仓不可修改,如需变更请重新下单。" />
<LoadErrorState v-if="submitError" compact title="保存失败,当前填写内容已保留" :message="submitError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="submitError" /></template>
</LoadErrorState>
<el-descriptions :column="4" border class="readonly-facts">
<el-descriptions-item label="入仓码">{{ detail.summary.inboundCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="国内收货仓">{{ detail.summary.receivingWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="目的仓/中转仓">{{ detail.summary.destinationWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ form.expectedVersion }}</el-descriptions-item>
</el-descriptions>
<el-form label-position="top" class="order-fields">
<el-row :gutter="14">
<el-col :span="6"><el-form-item label="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" clearable /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit /></el-form-item></el-col>
</el-row>
</el-form>
<h3>货品申报明细 <small>不允许新增、删除或调整明细序号</small></h3>
<div class="cargo-list">
<section v-for="cargo in form.cargoes" :key="cargo.key" class="cargo-card">
<header>第 {{ cargo.itemNo }} 项</header>
<div class="cargo-grid">
<label><span>中文品名 *</span><el-input v-model="cargo.cargoName" maxlength="128" /></label>
<label><span>英文品名</span><el-input v-model="cargo.cargoNameEn" maxlength="128" /></label>
<label><span>SKU NO.</span><el-input v-model="cargo.skuNo" maxlength="64" /></label>
<label><span>型号</span><el-input v-model="cargo.model" maxlength="64" /></label>
<label><span>箱数 *</span><el-input-number v-model="cargo.packageCount" :min="1" :precision="0" controls-position="right" /></label>
<label><span>总数量 *</span><el-input-number v-model="cargo.totalQuantity" :min="1" :precision="0" controls-position="right" /></label>
<label><span>长(cm) *</span><el-input-number v-model="cargo.lengthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>宽(cm) *</span><el-input-number v-model="cargo.widthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>高(cm) *</span><el-input-number v-model="cargo.heightCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>毛重(kg) *</span><el-input-number v-model="cargo.grossWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>净重(kg)</span><el-input-number v-model="cargo.netWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>体积(m³) *</span><el-input-number v-model="cargo.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></label>
<label><span>报关 HS CODE</span><el-input v-model="cargo.hsCode" maxlength="20" /></label>
<label><span>清关 HS CODE</span><el-input v-model="cargo.customsHsCode" maxlength="20" /></label>
<label><span>每箱数量</span><el-input-number v-model="cargo.unitsPerPackage" :min="1" :precision="0" controls-position="right" /></label>
<label><span>单价</span><el-input-number v-model="cargo.unitDeclaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>申报总价</span><el-input-number v-model="cargo.declaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>币种</span><el-input v-model="cargo.currency" maxlength="3" placeholder="USD" /></label>
<label><span>材质</span><el-input v-model="cargo.material" maxlength="128" /></label>
<label><span>用途</span><el-input v-model="cargo.usage" maxlength="128" /></label>
<label><span>品牌</span><el-input v-model="cargo.brand" maxlength="64" /></label>
<label class="wide"><span>货品备注</span><el-input v-model="cargo.cargoRemark" maxlength="500" /></label>
</div>
</section>
</div>
</div>
</AppDialog>
</template>
<style scoped>
.declaration-editor { display: grid; gap: 14px; max-height: calc(100vh - 190px); overflow: auto; padding-right: 4px; }
.readonly-facts { margin-top: 2px; }
.order-fields :deep(.el-date-editor), .order-fields :deep(.el-input) { width: 100%; }
h3 { margin: 0; color: #344054; font-size: 15px; } h3 small { margin-left: 8px; color: #98a2b3; font-weight: 400; }
.cargo-list { display: grid; gap: 12px; }
.cargo-card { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.cargo-card > header { padding: 9px 12px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: #475467; font-weight: 600; }
.cargo-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px 12px; padding: 12px; }
.cargo-grid label { display: grid; min-width: 0; gap: 5px; color: #667085; font-size: 12px; }
.cargo-grid label.wide { grid-column: span 2; }
.cargo-grid :deep(.el-input-number), .cargo-grid :deep(.el-input) { width: 100%; }
@media (max-width: 1200px) { .cargo-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
</style>
@@ -1,6 +1,8 @@
import { flushPromises, shallowMount } from '@vue/test-utils' import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders' import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
import { createPlatformExportTask } from '@/modules/downloads'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import UnifiedOrdersView from './UnifiedOrdersView.vue' import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
@@ -11,6 +13,8 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() })) vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } })) vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({ vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
cancelPlatformCoLoadGroup: vi.fn(), cancelPlatformCoLoadGroup: vi.fn(),
@@ -45,7 +49,7 @@ function mountView() {
AdminPage: { template: '<main><slot /></main>' }, AdminPage: { template: '<main><slot /></main>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' }, ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' }, QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div data-testid="data-table" />' }, DataTable: { name: 'DataTable', props: ['page', 'pageSize'], emits: ['update:page', 'update:pageSize', 'selection-change'], template: '<div data-testid="data-table"><slot name="toolbar-actions" /></div>' },
'el-button': { template: '<button><slot /></button>' }, 'el-button': { template: '<button><slot /></button>' },
}, },
}, },
@@ -55,6 +59,8 @@ function mountView() {
describe('UnifiedOrdersView list request coordination', () => { describe('UnifiedOrdersView list request coordination', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage()) vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '国内仓订单', taskNo: 'EXP-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
}) })
it('loads once initially and once for query, reset, status tab and pagination', async () => { it('loads once initially and once for query, reset, status tab and pagination', async () => {
@@ -135,4 +141,48 @@ describe('UnifiedOrdersView list request coordination', () => {
await flushPromises() await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ fullContainer: undefined })) expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ fullContainer: undefined }))
}) })
it('exports the selected business order numbers without loading other pages', async () => {
const wrapper = mountView()
await flushPromises()
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('selection-change', [
{ orderId: '1', orderNo: 'ORDER-1', tenantId: '8' },
{ orderId: '2', orderNo: 'ORDER-2', tenantId: '8' },
])
await wrapper.vm.$nextTick()
await wrapper.findAll('button').find((button) => button.text().includes('导出'))!.trigger('click')
await flushPromises()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'DOMESTIC_WAREHOUSE_ORDER_LIST', format: 'XLSX', taskName: '国内仓订单-已选2票',
parameters: { selectedOrderNos: ['ORDER-1', 'ORDER-2'] },
})
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
})
it('exports every result matching the applied filters instead of the current page', async () => {
const wrapper = mountView()
await flushPromises()
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), inboundCode: ' IN-8 ', loaded: 'false', createdRange: ['2026-08-01', '2026-08-31'],
})
toolbar.vm.$emit('search')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text() === '导出')!.trigger('click')
await flushPromises()
expect(createPlatformExportTask).toHaveBeenCalledWith(expect.objectContaining({
exportType: 'DOMESTIC_WAREHOUSE_ORDER_LIST', taskName: '国内仓订单-当前筛选',
parameters: expect.objectContaining({
inboundCode: 'IN-8', loaded: false,
createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59',
}),
}))
expect(createPlatformExportTask).not.toHaveBeenCalledWith(expect.objectContaining({
parameters: expect.objectContaining({ page: expect.anything(), size: expect.anything() }),
}))
})
}) })

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