Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1d75222a | ||
|
|
545205e28d | ||
|
|
f9dd30b8b9 | ||
|
|
76a63070c5 | ||
|
|
cec1e2d77c | ||
|
|
dd47143d6e | ||
|
|
10da46beab | ||
|
|
cc5ac2e68a | ||
|
|
e8e7465e4e | ||
|
|
b4b802a547 |
@@ -36,52 +36,61 @@
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
|
||||
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
|
||||
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
|
||||
|
||||
| 业务 | 首个入口 |
|
||||
|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` |
|
||||
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
|
||||
| 工作台与指标 | `modules/dashboard/` |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
|
||||
| 船期日历 | `modules/sailing-calendar/` |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`、`modules/overseas-warehouse/` |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts`、`createManualInboundQuote` |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts` |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/` |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
|
||||
| 消息中心 | `modules/notifications/` |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`modules/unified-orders/api/unifiedOrders.ts` |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`modules/inbounds/measurement/InboundMeasurementPanel.vue` |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/DropshipPalletPrintPanel.vue` |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/AGENTS.md` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/pages/DomesticContainersView.vue`、`modules/container-loading/pages/OverseasContainersView.vue` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/pages/UnifiedOrdersView.vue` |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue`、`modules/container-loading/pages/GlobalSmartLoadingView.vue` |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts`、`/candidates` |
|
||||
| 目的港码头拖车与还空预警 | `modules/container-loading/pages/OverseasContainersView.vue`、`modules/container-loading/pages/OverseasContainerDetailView.vue` |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` |
|
||||
| 海外仓业务合作仓与通用资料 | `modules/warehouses/`、`modules/overseas-master-data/` |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
|
||||
|
||||
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
海外仓作业先读 `src/modules/overseas-warehouse/AGENTS.md`;采购、车队等页面/API 联查按需查 `docs/business-index.md` 同名节。
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
||||
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
|
||||
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
|
||||
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
|
||||
|
||||
## 业务、安全与共享装配
|
||||
|
||||
@@ -118,7 +127,7 @@ pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# Admin业务详细索引
|
||||
|
||||
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
|
||||
|
||||
正文代码路径以工作区 `apps/admin-web/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
|
||||
|
||||
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
|
||||
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
|
||||
|
||||
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
|
||||
|
||||
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
||||
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
|
||||
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
|
||||
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
|
||||
@@ -31,12 +31,12 @@ describe('adminNavigation', () => {
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
@@ -196,15 +196,13 @@ describe('adminNavigation', () => {
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
|
||||
})
|
||||
|
||||
it('shows the restored supplier entry under general only with its list permission', () => {
|
||||
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
|
||||
const missing = filterAdminNavigation(adminNavigation, new Set())
|
||||
|
||||
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
||||
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
expect(getNavigationLeaves(filterAdminNavigation(adminNavigation, new Set(['platform:supplier:create', 'platform:supplier:edit']))).map(item => item.path)).toEqual(['/dashboard'])
|
||||
})
|
||||
|
||||
it('shows split container workbenches without a standalone smart-loading menu', () => {
|
||||
|
||||
@@ -134,7 +134,8 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
},
|
||||
@@ -146,7 +147,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
||||
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -204,7 +204,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
|
||||
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
||||
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
|
||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
|
||||
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
|
||||
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
|
||||
|
||||
@@ -1,80 +1,120 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
||||
|
||||
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
|
||||
const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
describe('four-type appointment calendar', () => {
|
||||
it('shows all four types by default with a calendar and persistent daily detail', () => {
|
||||
const wrapper = render(scheduleTypes.map(type => event(type)))
|
||||
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
|
||||
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
|
||||
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
|
||||
})
|
||||
it('filters locally without requesting another range', async () => {
|
||||
const wrapper = render([event('arrival'), event('dispatch')])
|
||||
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
|
||||
expect(wrapper.text()).toContain('当天暂无安排')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
it('offers creation in the header only when authorized', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
|
||||
await wrapper.setProps({ canCreate: true })
|
||||
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([[]])
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
describe('appointment calendar interactions', () => {
|
||||
it('keeps creation beside the title and record actions beside data notices', () => {
|
||||
const wrapper = mount(AppointmentCalendarPanel, {
|
||||
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
|
||||
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
|
||||
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
|
||||
expect(actions.text()).toContain('未定时安排')
|
||||
expect(actions.text()).toContain('取消记录')
|
||||
expect(actions.get('summary').text()).toContain('数据提示')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('changes the selected date locally and requests one range per mode or period change', async () => {
|
||||
const render = () => mount(AppointmentCalendarPanel, {
|
||||
props: { initialDate: '2026-09-24', canCreate: true, events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
|
||||
] },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
|
||||
it('opens a readable appointment without exposing write actions', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
|
||||
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
|
||||
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
|
||||
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
|
||||
await wrapper.setProps({events:[event]})
|
||||
const actions=wrapper.get('.detail-event footer')
|
||||
expect(actions.text()).toBe('查看预约')
|
||||
await actions.get('button').trigger('click')
|
||||
expect(wrapper.emitted('view')?.[0]).toEqual([event])
|
||||
expect(wrapper.emitted('edit')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('limits previews to three while retaining every event in the daily details', () => {
|
||||
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
|
||||
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
|
||||
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
|
||||
})
|
||||
it('keeps undated records outside date cells and exposes them explicitly', async () => {
|
||||
const wrapper = render([{ ...event('warehouse'), date: null }])
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||
await wrapper.get('.undated-button').trigger('click')
|
||||
it('hides optional references by default and counts only appointments after enabling them', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] },
|
||||
{ key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] },
|
||||
{ key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] },
|
||||
] })
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
|
||||
const switches = wrapper.get('[aria-label="参考图层"]').findAll('input')
|
||||
await switches[0]!.setValue(true); await switches[1]!.setValue(true)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(3)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约')
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
it('retains business links and exposes editing only with authoritative eligibility', async () => {
|
||||
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
|
||||
const wrapper = render([row])
|
||||
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
|
||||
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('open')).toEqual([[row]])
|
||||
expect(wrapper.emitted('edit')).toEqual([[row]])
|
||||
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
|
||||
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
|
||||
})
|
||||
it('distinguishes loading and partial failure from a complete empty day', async () => {
|
||||
it('selects an already loaded date without requesting data again', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ loading: true })
|
||||
expect(wrapper.text()).toContain('正在读取安排')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
await wrapper.setProps({ loading: false, incomplete: true })
|
||||
expect(wrapper.text()).toContain('部分数据不可用')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
|
||||
})
|
||||
|
||||
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
{ key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] },
|
||||
{ key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] },
|
||||
] })
|
||||
expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓')
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requests exactly one range for a view switch and navigates seven days', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
|
||||
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
|
||||
})
|
||||
|
||||
it('keeps busy days to three previews in both views and exposes the full count', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({
|
||||
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
|
||||
title: `船舶 ${index}`, fields: [],
|
||||
})) })
|
||||
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
|
||||
for (const view of ['month', 'week']) {
|
||||
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
|
||||
expect(day.findAll('.event-chip')).toHaveLength(3)
|
||||
expect(day.attributes('title')).toContain('共 233 条安排')
|
||||
expect(day.get('.event-more').text()).toBe('还有 230 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(233)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
|
||||
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
})
|
||||
|
||||
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [{
|
||||
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
|
||||
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
|
||||
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
|
||||
}] })
|
||||
const card = wrapper.get('.detail-event')
|
||||
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
|
||||
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
|
||||
expect(card.get('.inbound-codes').text()).toContain('20 个')
|
||||
expect(card.get('footer').text()).toContain('编辑')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, occursOnDate, type CalendarMode } from './calendar'
|
||||
|
||||
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
|
||||
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
|
||||
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
|
||||
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
|
||||
export interface CalendarEvent {
|
||||
key: string
|
||||
date: string
|
||||
endAt?: string
|
||||
time: string
|
||||
type: 'arrival' | 'delivery' | 'pickup' | 'trip'
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
inboundCodes?: Array<string | null | undefined>
|
||||
canEdit?: boolean
|
||||
canView?: boolean
|
||||
canDelete?: boolean
|
||||
sourcePath?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
notices?: string[]
|
||||
events: CalendarEvent[]
|
||||
loading?: boolean
|
||||
incomplete?: boolean
|
||||
canCreate?: boolean
|
||||
initialDate?: string
|
||||
}>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
|
||||
const emit = defineEmits<{
|
||||
layers: [layers: { arrivals: boolean; trips: boolean }]
|
||||
range: [range: { from: string; to: string }]
|
||||
create: [date: string]
|
||||
edit: [event: CalendarEvent]
|
||||
view: [event: CalendarEvent]
|
||||
delete: [event: CalendarEvent]
|
||||
open: [event: CalendarEvent]
|
||||
}>()
|
||||
const showArrivals = ref(false), showTrips = ref(false)
|
||||
function updateLayers() { emit('layers', { arrivals: showArrivals.value, trips: showTrips.value }) }
|
||||
const visibleEvents = computed(() => props.events.filter(event => (event.type !== 'arrival' || showArrivals.value) && (event.type !== 'trip' || showTrips.value)))
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date()))
|
||||
const mode = ref<CalendarMode>('month')
|
||||
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
|
||||
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
|
||||
const grouped = computed(() => {
|
||||
const result = new Map<string, ScheduleEvent[]>()
|
||||
const result = new Map<string, CalendarEvent[]>()
|
||||
for (const event of visibleEvents.value) {
|
||||
const date = event.date || 'undated'
|
||||
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
|
||||
for (const date of dates.value) {
|
||||
if (!occursOnDate(event.date, event.endAt, date)) continue
|
||||
const group = result.get(date) || []
|
||||
group.push(event)
|
||||
result.set(date, group)
|
||||
}
|
||||
}
|
||||
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
return result
|
||||
})
|
||||
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
|
||||
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
|
||||
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
|
||||
const appointmentCount = computed(() => selectedEvents.value.filter(event => event.type === 'delivery' || event.type === 'pickup').length)
|
||||
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
|
||||
const heading = computed(() => mode.value === 'month'
|
||||
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
|
||||
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
|
||||
function select(date: string) {
|
||||
const before = calendarRange(selectedDate.value, mode.value)
|
||||
selectedDate.value = date; undated.value = false
|
||||
selectedDate.value = date
|
||||
const after = calendarRange(date, mode.value)
|
||||
emit('select', date, mode.value)
|
||||
if (before.from !== after.from || before.to !== after.to) emit('range', after)
|
||||
}
|
||||
function changeMode(next: CalendarMode) {
|
||||
if (mode.value === next) return
|
||||
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
|
||||
mode.value = next
|
||||
emit('range', calendarRange(selectedDate.value, next))
|
||||
}
|
||||
defineExpose({ selectedDate, mode })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="appointment-calendar admin-density" :aria-busy="loading">
|
||||
<div class="calendar-main">
|
||||
@@ -43,76 +84,126 @@ function changeMode(next: CalendarMode) {
|
||||
<div class="calendar-label">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<strong>预约日历</strong>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
|
||||
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
|
||||
<div class="view-toggle" role="group" aria-label="预约视图">
|
||||
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
|
||||
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
|
||||
</div>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
|
||||
<strong class="calendar-period">{{ heading }}</strong>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</div>
|
||||
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
|
||||
<slot name="toolbar-actions" />
|
||||
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-options">
|
||||
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
|
||||
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
|
||||
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
|
||||
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
|
||||
<div class="calendar-layers" role="group" aria-label="参考图层"><label><input v-model="showArrivals" type="checkbox" @change="updateLayers">显示到港参考</label><label><input v-model="showTrips" type="checkbox" @change="updateLayers">显示用车计划</label></div>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||||
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
|
||||
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ Number(date.slice(8)) }}</span>
|
||||
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
|
||||
<div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
|
||||
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day"
|
||||
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
|
||||
<span class="day-events">
|
||||
<span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`">
|
||||
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
|
||||
</span>
|
||||
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail" aria-label="当天安排">
|
||||
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
|
||||
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ appointmentCount }}</b> 项预约<span v-if="selectedEvents.length > appointmentCount">,{{ selectedEvents.length - appointmentCount }} 条参考计划</span><span v-if="incomplete">(当前范围不完整)</span></p></header>
|
||||
<div class="detail-events" aria-live="polite">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
|
||||
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</span></div>
|
||||
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
|
||||
<dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
|
||||
<footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
|
||||
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
|
||||
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
|
||||
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
|
||||
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
|
||||
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.calendar-data-notices { position: relative; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.calendar-data-notices summary { cursor: pointer; padding: var(--admin-space-2) var(--admin-space-3); }
|
||||
.calendar-notice-content { position: absolute; top: 100%; right: 0; z-index: 5; width: 320px; padding: var(--admin-space-5); border: 1px solid var(--admin-table-border); border-radius: var(--admin-radius); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); }
|
||||
.calendar-notice-content p { margin: 0; line-height: 1.6; }
|
||||
.calendar-notice-content p + p { margin-top: var(--admin-space-3); }
|
||||
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: #344054; }
|
||||
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
|
||||
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.calendar-label > svg { color: #617ce9; }
|
||||
.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
|
||||
.calendar-legend, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||
.calendar-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-1); cursor: pointer; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.calendar-layers input { margin: 0; accent-color: var(--el-color-primary); }
|
||||
.legend { font-size: 12px; white-space: nowrap; }
|
||||
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
|
||||
.legend.delivery::before { background: #27a87a; }
|
||||
.legend.pickup::before { background: #c17a20; }
|
||||
.legend.trip::before { background: #8562b0; }
|
||||
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
|
||||
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
|
||||
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
|
||||
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
|
||||
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
|
||||
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: 14px; white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
|
||||
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
|
||||
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 112px; min-width: 0; padding: var(--admin-space-2); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
|
||||
.calendar-day:hover { border-color: #bac8ed; background: #f8faff; }
|
||||
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { display: block; font-size: 12px; font-weight: 600; }
|
||||
.day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
|
||||
.event-chip { display: block; padding: 1px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
|
||||
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
|
||||
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
|
||||
.trip { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
|
||||
.event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; }
|
||||
.event-more { padding: 1px 6px; line-height: 16px; }
|
||||
.event-more, .empty-day { color: #667085; font-size: 12px; }
|
||||
.week-grid .calendar-day { height: 220px; }
|
||||
.calendar-detail { align-self: start; }
|
||||
.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
|
||||
.calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
|
||||
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
|
||||
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
|
||||
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
|
||||
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }
|
||||
.detail-event .wide-field { grid-column: 1 / -1; }
|
||||
.detail-event dt { font-size: 12px; color: #8491a5; }
|
||||
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -1,143 +1,235 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { inject, onBeforeUnmount } from 'vue'
|
||||
import { ElDialog } from 'element-plus'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
|
||||
import { listArrivalCalendar } from './api'
|
||||
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
|
||||
function render(realDialog = false) {
|
||||
return mount(AppointmentCalendarView, { attachTo: document.body, global: { components: { ElDialog }, directives: { loading: {} }, stubs: {
|
||||
teleport: true,
|
||||
ElTooltip: { template: '<slot />' },
|
||||
ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '<div v-if="modelValue" class="el-dialog" :class="{ \'is-fullscreen\': fullscreen }"><button aria-label="关闭工作面板" @click="beforeClose(() => $emit(\'update:modelValue\', false))">关闭</button><slot name="header" /><slot /><slot name="footer" /></div>' },
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
|
||||
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
|
||||
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
|
||||
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
|
||||
ElButton: { props: ['type'], template: '<button type="button" :data-variant="type"><slot /></button>' },
|
||||
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '<div data-calendar><slot name="inbox" /></div>' },
|
||||
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
|
||||
const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
|
||||
const unregister = register(async () => routing.allowLeave)
|
||||
onBeforeUnmount(unregister)
|
||||
}, template: '<div data-detail>{{ itemKey }}</div>' },
|
||||
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
|
||||
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
|
||||
ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
})
|
||||
describe('simplified appointment entry', () => {
|
||||
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(true)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
|
||||
expect(panel.props('canWrite')).toBe(true)
|
||||
vi.mocked(getCalendarSources).mockClear()
|
||||
panel.vm.$emit('created'); await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
describe('appointment work calendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
|
||||
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
|
||||
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
|
||||
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
})
|
||||
it('retains the old batch and page when a resolved later batch continues into its vehicle work', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'old-batch-work' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-batch-work'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-vehicle-work', followAction); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-vehicle-work')
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('old-batch-work')
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ view: 'UNDATED', page: 3 }), expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
|
||||
routing.denied.add(permission)
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(false)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('预约工作日历')
|
||||
expect(wrapper.get('[data-calendar] .work-inbox').attributes('aria-label')).toBe('全局待办')
|
||||
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps unsaved input when the operator declines to leave', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
it('keeps day/type selection and pagination context in the overlay and clears selection on close', async () => {
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 30, pages: 4 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
|
||||
calendar.vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
|
||||
expect(calendar.props()).toMatchObject({ selectedDate: '2026-10-15', selectedType: 'PICKUP' })
|
||||
expect(wrapper.get('.panel-header').text()).toContain('2026-10-15 · 提柜')
|
||||
expect(wrapper.find('.panel-body .work-pagination').exists()).toBe(false)
|
||||
expect(wrapper.get('.panel-footer').text()).toContain('共 30 项')
|
||||
await wrapper.get('.panel-footer .work-pagination button:last-child').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-10-15', types: ['PICKUP'], page: 2 }), expect.any(AbortSignal))
|
||||
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(false)
|
||||
expect(calendar.props('selectedDate')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
|
||||
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
|
||||
it('expands the existing detail without remounting or bypassing the unsaved close guard', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
|
||||
routing.allowLeave = false
|
||||
await wrapper.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(wrapper.get('.el-dialog').classes()).toContain('is-fullscreen')
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
|
||||
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(true)
|
||||
routing.allowLeave = true
|
||||
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
|
||||
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
|
||||
expect(wrapper.text()).toContain('计划到仓读取失败')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
|
||||
it.each(['mask', 'escape'] as const)('closes from %s through the real dialog without bypassing draft protection', async method => {
|
||||
const wrapper = render(true); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.panel-body').trigger('click')
|
||||
expect(wrapper.find('[data-detail]').exists()).toBe(true)
|
||||
const dismiss = async () => {
|
||||
if (method === 'mask') {
|
||||
const mask = wrapper.get('.el-overlay-dialog')
|
||||
await mask.trigger('mousedown'); await mask.trigger('mouseup'); await mask.trigger('click')
|
||||
} else {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }))
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
routing.allowLeave = false
|
||||
await dismiss()
|
||||
expect(wrapper.find('[data-detail]').exists()).toBe(true)
|
||||
routing.allowLeave = true
|
||||
await dismiss()
|
||||
expect(wrapper.find('.work-panel').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not request unreadable domains and describes the incomplete scope', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少车次读取权限')
|
||||
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
|
||||
it.each(['VIEW_DETAIL', 'VIEW_HISTORY', 'SAVE_FOLLOWUP'] as const)('keeps %s navigation intact while distinguishing read-only actions', async code => {
|
||||
const nextAction = { ...followAction, code, label: '服务端动作文案' }
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ nextAction })], page: 1, size: 8, total: 1, pages: 1 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
const button = wrapper.get('.work-row-action button')
|
||||
expect(button.attributes('data-variant')).toBe(code === 'SAVE_FOLLOWUP' ? 'primary' : 'default')
|
||||
expect(wrapper.get('.item-schedule').text()).toContain('作业日期未定')
|
||||
expect(wrapper.get('.item-schedule').text()).toContain('America/Los_Angeles')
|
||||
expect(wrapper.get('.item-context').text()).toContain('地点:ONT8')
|
||||
expect(wrapper.get('.item-context').text()).toContain('负责人:测试调度')
|
||||
expect(wrapper.get('.item-context').text()).toContain('跟进:2026-10-15')
|
||||
await button.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(nextAction)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores stale calendar responses when the date range changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
|
||||
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
|
||||
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
|
||||
it('sends an explicit safe return context with external business navigation', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
|
||||
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('提交结果尚未确认')
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
|
||||
it('keeps a successful save in the current detail and retains the original global page on return', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
|
||||
detail.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
|
||||
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-detail]').exists()).toBe(false)
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires an explicit reread after a conflict and retains the entered date', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
|
||||
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
|
||||
calendar.vm.$emit('anchor', 1); await flushPromises()
|
||||
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
|
||||
const calls = vi.mocked(getWorkCalendar).mock.calls.length
|
||||
calendar.vm.$emit('types', []); await flushPromises()
|
||||
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the selected date and view in the source return link', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
|
||||
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows a failed source as an error, not zero work or all complete', async () => {
|
||||
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
|
||||
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
|
||||
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a late response after the global scope changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
|
||||
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
|
||||
expect(wrapper.text()).toContain('返回原事项')
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('goes directly to the current container unfinished list after route confirmation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
|
||||
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
|
||||
wrapper.unmount()
|
||||
routing.canReadSource = false
|
||||
const denied = render(); await flushPromises()
|
||||
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
|
||||
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,200 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { isAxiosError } from 'axios'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
|
||||
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import WorkDetailPanel from './WorkDetailPanel.vue'
|
||||
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import type { WorkAction } from './workApi'
|
||||
import { appointmentExternalTarget } from './workNavigation'
|
||||
|
||||
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
|
||||
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
|
||||
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
|
||||
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const creating = ref(false)
|
||||
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||||
function initialDate() {
|
||||
const value = typeof route.query.date === 'string' ? route.query.date : ''
|
||||
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
|
||||
}
|
||||
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
|
||||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||||
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
|
||||
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||
let generation = 0, controller: AbortController | null = null
|
||||
async function load(next = range.value) {
|
||||
range.value = next
|
||||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||||
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
let result: CalendarSources | null = null
|
||||
try {
|
||||
result = await getCalendarSources(next, signal)
|
||||
if (request !== generation) return
|
||||
sources.value = result
|
||||
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
|
||||
} catch (cause) { fail('到港与还柜读取失败', cause) }
|
||||
if (request !== generation) return
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
|
||||
}
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
|
||||
if (request === generation) warehouses.value = rows
|
||||
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||||
})(),
|
||||
(async () => {
|
||||
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
|
||||
try {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
if (request === generation) loading.value = false
|
||||
}
|
||||
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
|
||||
async function openSource(event: ScheduleEvent) {
|
||||
if (!event.sourcePath || !await canLeave()) return
|
||||
const context = { date: selectedDate.value, mode: mode.value }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||||
}
|
||||
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||||
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
|
||||
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
|
||||
function edit(event: ScheduleEvent) {
|
||||
if (!event.canEdit || !event.warehouse) return
|
||||
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||||
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
|
||||
}
|
||||
async function save() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row || saving.value || conflict.value || !plannedAt.value) return
|
||||
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveWarehouseArrival(pending.id, pending.body, pending.key)
|
||||
pending = null; unknownResult.value = false; editor.value = null
|
||||
ElMessage.success('计划到仓已保存')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||||
unknownResult.value = !rejectedCommand(cause)
|
||||
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||||
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||||
if (!unknownResult.value) pending = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function rereadEditor() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row) return
|
||||
saving.value = true
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
|
||||
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
|
||||
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
|
||||
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
|
||||
const savedAnchor = stringQuery('workAnchor')
|
||||
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
|
||||
const anchor = ref(initialAnchor())
|
||||
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
|
||||
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
|
||||
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
|
||||
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
|
||||
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
|
||||
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
|
||||
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
|
||||
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
|
||||
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
|
||||
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
|
||||
const navigationError = ref('')
|
||||
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
|
||||
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
|
||||
const range = computed(() => calendarRange(anchor.value, mode.value))
|
||||
const heading = computed(() => {
|
||||
const value = scope.value
|
||||
if (!value) return ''
|
||||
if (value.kind === 'global') return viewLabels[value.view]
|
||||
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
|
||||
if (value.kind === 'container') return `${value.label} · 本柜待办`
|
||||
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
|
||||
return `查找:${value.keyword}`
|
||||
})
|
||||
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
|
||||
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
|
||||
const panelBody = ref<HTMLElement | null>(null)
|
||||
const selectedDate = computed(() => scope.value?.kind === 'day' || scope.value?.kind === 'arrivals' ? scope.value.date : undefined)
|
||||
const selectedType = computed(() => scope.value?.kind === 'day' && scope.value.types.length === 1 ? scope.value.types[0] : undefined)
|
||||
const panelTitle = computed(() => sourceLink.value ? '货源与预约' : detailKey.value ? '事项详情' : heading.value)
|
||||
const listVisible = computed(() => !sourceLink.value && !detailKey.value && scope.value?.kind !== 'arrivals')
|
||||
watch([detailKey, sourceLinkKey, scope, page], () => { if (panelBody.value) panelBody.value.scrollTop = 0 }, { flush: 'post' })
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||
async function canLeave() {
|
||||
if (saving.value || unknownResult.value) return false
|
||||
for (const guard of guards) if (!await guard()) return false
|
||||
if (editor.value && plannedAt.value !== originalAt.value) {
|
||||
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
|
||||
catch { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
|
||||
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
|
||||
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
|
||||
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
|
||||
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
|
||||
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
|
||||
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
|
||||
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
|
||||
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
|
||||
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
|
||||
origins.value = []
|
||||
initialAction.value = null
|
||||
}, { immediate: true })
|
||||
async function openWork(key: string, action?: WorkAction) {
|
||||
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
|
||||
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
|
||||
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
|
||||
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
|
||||
async function loadCalendar() {
|
||||
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
|
||||
if (!types.value.length) { calendarLoading.value = false; return }
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
const result = await getWorkCalendar({ from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }, calendarController.signal)
|
||||
if (request === calendarRequest) calendar.value = result
|
||||
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
|
||||
finally { if (request === calendarRequest) calendarLoading.value = false }
|
||||
}
|
||||
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
|
||||
async function closeBusiness(done: () => void) {
|
||||
async function loadSummary() {
|
||||
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
|
||||
try { const result = await getWorkSummary(summaryController.signal); if (request === summaryRequest) summary.value = result }
|
||||
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
|
||||
finally { if (request === summaryRequest) summaryLoading.value = false }
|
||||
}
|
||||
function itemQuery(): WorkItemsQuery | null {
|
||||
const value = scope.value
|
||||
if (!value || value.kind === 'arrivals') return null
|
||||
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
|
||||
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
|
||||
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
|
||||
if (value.kind === 'search') query.keyword = value.keyword
|
||||
return query
|
||||
}
|
||||
async function loadItems() {
|
||||
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
|
||||
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
|
||||
itemsLoading.value = true
|
||||
try { const result = await getWorkItems(query, itemsController.signal); if (request === itemsRequest) items.value = result }
|
||||
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
|
||||
finally { if (request === itemsRequest) itemsLoading.value = false }
|
||||
}
|
||||
async function loadArrivals() {
|
||||
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
|
||||
if (!reference.value) { referenceLoading.value = false; return }
|
||||
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
|
||||
referenceLoading.value = true
|
||||
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
|
||||
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
|
||||
finally { if (request === referenceRequest) referenceLoading.value = false }
|
||||
}
|
||||
async function setScope(value: Scope) {
|
||||
if (!await canLeave()) return
|
||||
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
|
||||
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
|
||||
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
|
||||
}
|
||||
async function openItem(key: string, action?: WorkAction) {
|
||||
if (!await canLeave()) return
|
||||
navigationError.value = ''
|
||||
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
|
||||
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
|
||||
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
|
||||
}
|
||||
async function back() {
|
||||
if (!await canLeave()) return
|
||||
const origin = origins.value.pop()
|
||||
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
|
||||
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
|
||||
if (!origin) await loadItems()
|
||||
}
|
||||
let closingPanel = false
|
||||
async function closePanel(done: () => void) {
|
||||
if (closingPanel) return
|
||||
closingPanel = true
|
||||
try {
|
||||
if (!await canLeave()) return
|
||||
done()
|
||||
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
|
||||
} finally { closingPanel = false }
|
||||
}
|
||||
async function filterCalendar(change: () => void) {
|
||||
if (!await canLeave()) return
|
||||
change()
|
||||
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
|
||||
await Promise.all([loadCalendar(), loadArrivals()])
|
||||
}
|
||||
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
|
||||
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
|
||||
async function changePage(next: number) { page.value = next; await loadItems() }
|
||||
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
|
||||
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
|
||||
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
|
||||
const container = result.item.containers[0]
|
||||
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
|
||||
} else { if (origins.value.length) detailKey.value = origins.value.pop()!; initialAction.value = null; sourceLink.value = null }
|
||||
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
|
||||
}
|
||||
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
|
||||
async function external(path: string) {
|
||||
if (!await canLeave()) return
|
||||
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
|
||||
const value = scope.value
|
||||
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(path, context))
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
|
||||
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
|
||||
onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
onMounted(() => {
|
||||
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
|
||||
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
|
||||
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
|
||||
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
|
||||
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
|
||||
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
|
||||
detailKey.value = stringQuery('workItem') || null
|
||||
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
|
||||
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
|
||||
})
|
||||
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
sourceLink.value = null; linkError.value = ''
|
||||
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
|
||||
if (sourceKey || orderId || customerId || destinationKey) {
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
|
||||
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
|
||||
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
|
||||
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
|
||||
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
|
||||
}
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density appointment-schedule" spacing="compact">
|
||||
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<template v-if="editor">
|
||||
<p>{{ editor.title }}</p>
|
||||
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
|
||||
<label class="arrival-field">计划到仓时间<input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required></label>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
|
||||
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
|
||||
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
|
||||
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
|
||||
</AppDialog>
|
||||
<AdminPage class="admin-density appointment-work" spacing="compact">
|
||||
<div class="work-toolbar">
|
||||
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
|
||||
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
|
||||
</div>
|
||||
<p v-if="linkError" role="alert">{{ linkError }}</p>
|
||||
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
|
||||
<div class="work-layout">
|
||||
<WorkCalendarPanel :selected-date="selectedDate" :selected-type="selectedType" :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })">
|
||||
<template #inbox>
|
||||
<nav class="work-inbox" aria-label="全局待办">
|
||||
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong :class="{ 'is-zero': !summaryLoading && !summaryError && summary?.[button.field] === 0, 'is-overdue': !summaryLoading && !summaryError && button.view === 'OVERDUE' && (summary?.overdue ?? 0) > 0 }">{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>全局待办 · 不受日历筛选影响 · 数量可重叠</span>
|
||||
</nav>
|
||||
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</WorkCalendarPanel>
|
||||
<AppDialog :model-value="panelOpen" :title="panelTitle" class="admin-density-dialog admin-density appointment-work work-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :close-on-click-modal="true" :close-on-press-escape="true" :before-close="closePanel" :show-footer="listVisible && !!items && !itemsError && !itemsLoading" destroy-on-close>
|
||||
<template #header>
|
||||
<header class="panel-header">
|
||||
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><h3>{{ panelTitle }}</h3></div>
|
||||
<template v-if="!detailKey && !sourceLink">
|
||||
<p class="scope-description">{{ scopeDescription }}</p>
|
||||
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
|
||||
</template>
|
||||
</header>
|
||||
</template>
|
||||
<section v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
|
||||
<div ref="panelBody" class="panel-body">
|
||||
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
|
||||
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
|
||||
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
|
||||
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
|
||||
<template v-else-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
|
||||
<template v-else>
|
||||
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
|
||||
<p v-if="itemsLoading" role="status">正在读取明细…</p>
|
||||
<template v-else-if="items && !itemsError">
|
||||
<article v-for="item in items.records" :key="item.workItemKey" class="work-row">
|
||||
<div class="work-row-action"><el-button v-if="item.nextAction" :type="['VIEW_DETAIL', 'VIEW_HISTORY'].includes(item.nextAction.code) ? 'default' : 'primary'" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div>
|
||||
<div class="work-row-content">
|
||||
<div class="item-heading"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><span class="item-status">{{ workStatus(item) }}</span></div>
|
||||
<p class="item-schedule"><span class="item-date">{{ workDate(item) }}</span><span>{{ item.timezone || '地点时区待核实' }}</span></p>
|
||||
<p class="item-context"><span v-if="!item.title.endsWith(typeLabels[item.type])">{{ typeLabels[item.type] }}</span><span>{{ flowLabels[item.flow] }}</span><span>地点:{{ item.destinationName || item.originName || '地点待核实' }}</span><span>负责人:{{ item.owner?.name || '待分派' }}</span><span v-if="item.followupDate">跟进:{{ item.followupDate }}</span></p>
|
||||
<p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!items.records.length">当前范围没有工作事项。</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<template #footer><footer v-if="items" class="panel-footer"><span>共 {{ items.total }} 项</span><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></footer></template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.arrival-field { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-block: var(--admin-space-5); }
|
||||
.arrival-field input { box-sizing: border-box; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); font: inherit; }
|
||||
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
|
||||
.work-toolbar, .work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-3); flex-wrap: wrap; }
|
||||
.work-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); }
|
||||
.work-heading h2 { margin: 0; }
|
||||
.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-inbox { padding: var(--admin-space-2) var(--admin-space-5); }
|
||||
.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }
|
||||
.work-inbox strong { font-variant-numeric: tabular-nums; }
|
||||
.work-inbox strong.is-zero { color: var(--el-text-color-secondary); font-weight: 400; }
|
||||
.work-inbox strong.is-overdue { color: var(--el-color-danger-dark-2); }
|
||||
.appointment-work .work-inbox button[aria-pressed=true] { box-shadow: inset 0 -2px 0 var(--el-color-primary); }
|
||||
.work-search input { width: 280px; }
|
||||
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }
|
||||
.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
|
||||
.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }
|
||||
.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible, .panel-header h3:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.work-layout { position: relative; min-width: 0; }
|
||||
:global(.work-dialog.el-dialog:not(.is-fullscreen)) { height: 88dvh; max-height: 88dvh; margin-top: 6dvh; }
|
||||
:global(.work-dialog .el-dialog__body) { display: flex; flex: 1; overflow: hidden; }
|
||||
:global(.work-dialog .app-dialog__body) { display: flex; flex: 1; min-width: 0; }
|
||||
.work-panel { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; overflow-wrap: anywhere; }
|
||||
.panel-header { flex: none; min-width: 0; font-size: var(--admin-font-body); font-weight: 400; }
|
||||
.panel-nav { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.panel-header h3 { font-size: var(--admin-font-title); margin: var(--admin-space-2) 0; }
|
||||
.scope-description { margin: var(--admin-space-1) 0; }
|
||||
.panel-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--admin-space-1); }
|
||||
.panel-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); flex-wrap: wrap; flex: none; font-size: var(--admin-font-caption); }
|
||||
.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
|
||||
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-2); line-height: 20px; }
|
||||
.work-row-action :deep(.el-button > span) { min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; line-height: inherit; }
|
||||
.work-row-content { min-width: 0; }
|
||||
.item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
.item-status { flex: none; color: var(--el-text-color-regular); background: var(--el-fill-color-light); border-radius: var(--admin-radius); padding: 0 var(--admin-space-2); font-size: var(--admin-font-caption); line-height: 24px; }
|
||||
.work-row-content p { margin: var(--admin-space-1) 0; color: var(--el-text-color-regular); font-size: var(--admin-font-body); line-height: 1.6; }
|
||||
.item-schedule, .item-context { display: flex; flex-wrap: wrap; column-gap: var(--admin-space-4); row-gap: 2px; }
|
||||
.item-date { color: var(--el-text-color-regular); font-variant-numeric: tabular-nums; }
|
||||
.appointment-work .work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-color-primary); background: transparent; font-weight: 600; }
|
||||
.work-row-content .work-warning { color: var(--el-color-warning-dark-2); border-left: 2px solid var(--el-color-warning-light-5); padding-left: var(--admin-space-2); }
|
||||
.work-pagination, .bucket-buttons { display: flex; align-items: center; gap: var(--admin-space-2); flex-wrap: wrap; }
|
||||
.bucket-buttons { margin-top: var(--admin-space-2); }
|
||||
.bucket-buttons button { font-size: var(--admin-font-caption); }
|
||||
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
</style>
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppointmentCreatePanel from './AppointmentCreatePanel.vue'
|
||||
import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||
import type { QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||
const guards = new Set<LeaveGuard>()
|
||||
function render(canWrite = true) {
|
||||
return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: {
|
||||
provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } },
|
||||
stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters" /><slot name="toolbar-actions" /><slot name="notices" /><div v-for="row in data" :key="row.sourceKey"><span>{{ row.inboundCode }}</span><slot name="select" :row="row" /><slot name="eligibility" :row="row" /></div><button data-page @click="$emit(\'update:page\', 2)">下一页</button></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '<button data-query @click="$emit(\'search\')">查询</button>' },
|
||||
ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'change\', $event.target.checked)">' },
|
||||
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot /></button>' },
|
||||
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input data-date type="date" v-model="model.plannedDate" :disabled="disabled">' },
|
||||
LoadErrorState: { props: ['message'], emits: ['retry'], template: '<p role="alert">{{ message }}<button data-retry @click="$emit(\'retry\')">重试</button><slot name="details" /></p>' }, ApiErrorMeta: true,
|
||||
},
|
||||
} })
|
||||
}
|
||||
async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); guards.clear()
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8'])
|
||||
})
|
||||
it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const toolbar = wrapper.getComponent({name:'QueryToolbar'})
|
||||
const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
expect(destination().props?.filterable).toBe(true)
|
||||
expect(listPlanningDestinations).not.toHaveBeenCalled()
|
||||
const open = destination().props!.onVisibleChange as (visible:boolean)=>void
|
||||
open(true); await flushPromises()
|
||||
expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}])
|
||||
open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1)
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => {
|
||||
vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningDestinations).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not read or create per-row details until the operator selects cargo and proceeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
await selectAndPrepare(wrapper)
|
||||
expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId })
|
||||
expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }])
|
||||
expect(wrapper.text()).not.toContain(cargo.orderId)
|
||||
wrapper.unmount(); expect(guards.size).toBe(0)
|
||||
})
|
||||
it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => {
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.get('[data-date]').setValue('2026-10-25')
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) }))
|
||||
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
|
||||
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
|
||||
expect(wrapper.get('[data-date]').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '返回选择货物')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false)
|
||||
expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('disables blocked cargo and keeps selected cargo across pages', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports source failures and does not offer a write path without permission', async () => {
|
||||
const readonly = render(false); await flushPromises()
|
||||
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
|
||||
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a source resolution that arrives after the dialog is closed', async () => {
|
||||
let complete!: (value: Awaited<ReturnType<typeof resolvePlanningSource>>) => void
|
||||
vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises()
|
||||
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { FbaAppointmentFields, createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
|
||||
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
|
||||
import type { SourceReference } from './workApi'
|
||||
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
|
||||
|
||||
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
|
||||
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
|
||||
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
|
||||
let destinationsRequest = 0, destinationsController: AbortController | null = null
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
|
||||
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
|
||||
options: destinations.value.map(code => ({ label: code, value: code })),
|
||||
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
|
||||
if (visible && !destinationsLoaded.value) void loadDestinations()
|
||||
} } },
|
||||
])
|
||||
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
|
||||
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
|
||||
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
|
||||
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
|
||||
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 140 },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140 },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ prop: 'packages', label: '箱数', width: 80 },
|
||||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
||||
]
|
||||
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
||||
function reason(row: PlanningCargo) {
|
||||
if (row.detained) return '货物暂扣中'
|
||||
if (row.mixedDestinations) return '多个目的地,需先核对'
|
||||
if (row.hasActiveAppointment) return '已有有效预约'
|
||||
const anchor = selectedRows.value[0]
|
||||
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
||||
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
||||
return ''
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: boolean | string | number) {
|
||||
if (!props.canWrite || resolving.value) return
|
||||
if (!checked) delete selected.value[key(row)]
|
||||
else if (!reason(row)) selected.value[key(row)] = row
|
||||
selectionError.value = null
|
||||
}
|
||||
async function load(nextPage = page.value) {
|
||||
if (!props.canWrite) return
|
||||
page.value = nextPage
|
||||
const current = ++generation; controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
|
||||
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
|
||||
if (current === generation) { rows.value = result.records; total.value = result.total }
|
||||
} catch (cause) {
|
||||
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
|
||||
} finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
keyword.value = String(query.value.keyword || '').trim()
|
||||
destination.value = String(query.value.destination || '').trim()
|
||||
void load(1)
|
||||
}
|
||||
async function loadDestinations() {
|
||||
if (!props.canWrite || destinationsLoading.value) return
|
||||
const current = ++destinationsRequest
|
||||
destinationsController?.abort(); destinationsController = new AbortController()
|
||||
destinationsLoading.value = true; destinationError.value = null
|
||||
try {
|
||||
const result = await listPlanningDestinations(destinationsController.signal)
|
||||
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
|
||||
} catch (cause) {
|
||||
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
|
||||
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
|
||||
}
|
||||
async function prepare() {
|
||||
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
|
||||
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
|
||||
resolving.value = true; selectionError.value = null
|
||||
try {
|
||||
const sources: SourceReference[] = []
|
||||
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
|
||||
for (const row of snapshot) {
|
||||
if (current !== selectionGeneration) return
|
||||
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
|
||||
if (current !== selectionGeneration) return
|
||||
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
|
||||
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
|
||||
}
|
||||
if (current === selectionGeneration) references.value = sources
|
||||
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
|
||||
finally { if (current === selectionGeneration) resolving.value = false }
|
||||
}
|
||||
async function back() { if (await props.canLeave()) references.value = null }
|
||||
function saved(appointment: FbaAppointmentDto) {
|
||||
created.value = appointment; references.value = null; selected.value = {}
|
||||
emit('created', appointment)
|
||||
}
|
||||
async function again() {
|
||||
if (!await props.canLeave()) return
|
||||
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
|
||||
await load()
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="新增FBA送仓预约">
|
||||
<template v-if="created">
|
||||
<p role="status">预约已保存:<strong>{{ created.appointmentNo || '预约编号未返回' }}</strong></p>
|
||||
<p>预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
|
||||
<el-button v-if="canWrite" @click="again">继续新增预约</el-button>
|
||||
<FbaAppointmentFields :model="createdForm" :appointment-no="created.appointmentNo" :timezone="created.timezone" :disabled="true" />
|
||||
</template>
|
||||
<template v-else-if="references">
|
||||
<el-button @click="back">返回选择货物</el-button>
|
||||
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" empty-text="暂无可预约货物" @update:page="load($event)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
||||
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||||
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
||||
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
||||
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,31 +0,0 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
describe('calendar cache lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
})
|
||||
it('aborts reads when cached and reloads on return without creating background work polling', async () => {
|
||||
const active = ref(true)
|
||||
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) })
|
||||
const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '<main><slot /></main>' }, AppointmentCalendarPanel: { template: '<section />' }, AppDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]!
|
||||
active.value = false; await flushPromises()
|
||||
expect(signal.aborted).toBe(true)
|
||||
active.value = true; await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,47 +1,9 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-19 四类日历恢复(当前结论)
|
||||
|
||||
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
|
||||
|
||||
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|
||||
|---|---|---|---|
|
||||
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
|
||||
| 预约列表 | 月视图周数 | 用户截图 `codex-clipboard-372a5dc5-0c82-4298-93db-089b6c97c8ca.png`;周一至周日,只覆盖当月所需完整周 | 4/5/6周自适应;2026年10月五周,保留末周11月1日,不追加11月2—8日整周;待人工截图确认 |
|
||||
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
|
||||
| 预约列表 | 新增预约 | 用户本轮要求恢复顶部按钮,随后确认先支持FBA;1080px居中弹框,先选择已有货物,再登记预约时间 | 复用真实来源预览及预约保存流程,原权限/幂等/未保存保护;保存成功显示预约编号和只读内容;普通客户仓不在本次范围;待人工截图确认 |
|
||||
| 新增FBA送仓预约 | 按目的仓查询 | 用户截图 `codex-clipboard-44b338fe-af49-45e2-abf2-3bc0bd27224a.png`;在原查询栏增加可搜索、可清空的目的FBA仓下拉框 | 选项使用同货物查询权限的权威美国FBA目录;提交查询后从第一页按目的仓过滤,翻页沿用条件;待人工截图确认 |
|
||||
| 预约列表 | 审核、删除 | 不恢复通用入口 | 在原业务处理,不在日历改业务状态 |
|
||||
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
|
||||
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
|
||||
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
|
||||
|
||||
2026-10-19 目的FBA仓筛选补修:原查询栏增加可搜索、可清空的下拉框,首次展开读取完整权威美国FBA目录;复用原货物读取权限,不新增仓库管理权限。查询回第一页,翻页沿用目的仓条件,重置清除条件;选项失败独立提示并可重试,不隐藏已加载货物或伪装为空结果。预约模块37文件/276项及生产构建(含类型检查)通过,Gateway路由合同5项通过。后端生产源码编译通过,本次两个测试文件单独javac编译并经Surefire执行13项通过;常规Maven流程仍被派送模块三份测试中的5项编译错误阻挡,不能据定向结果宣称后端完整门禁通过。Admin全量405文件/3388项通过、1项输入提示合同失败,原因是其他在途运输单表单的7个输入框缺少placeholder;单独复查相同失败,未修改其他模块。日志为`.logs/appointment-destination-{test,build,full-test,placeholder-check}.log`,后端为同名前缀的`route`、`backend`、`javac`及`targeted-backend`日志。本补修未提交或部署,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 新增FBA预约入口补修:顶部按钮要求原FBA预约写权限及尾程订单列表权限,点击后才读取候选;支持同起运仓、同目的仓的跨客户货物选择,核对最新真实来源后复用原创建表单。未知结果保留原请求并阻止关闭/返回,成功后移除创建表单、显示真实预约编号和只读内容,刷新原日历;不生成虚拟车次或第五类日历。本次已明确普通客户仓暂不加入。
|
||||
|
||||
验证:新增入口直接5文件/59项通过;`pnpm build`含类型检查通过,仅既有AnalyticsChart大分块提示。工作区首次全量为405文件/3385项通过、1项排车详情布局合同失败,后续该项单独6项复查通过;再次全量出现9文件/11项失败(以5秒超时为主),在不修改超时阈值和其他业务代码的情况下,以2个工作进程复查整个预约模块、密度及上述全部失败文件,47文件/385项全过。不将这些分次结果记录为单次全量绿灯。证据为`.logs/appointment-create-{focused-final,build,full-test,full-test-final,layout-recheck,bounded-recheck}.log`。额外临时候选核对未作为通过证据;工作区其他业务在途修改保留。本次未提交、发布或新增真实业务记录,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 月视图周数补修:按月实际周数生成日期格,并同步查询开区间;覆盖2026年10月五周、跨年五周、完整二月四周与确需六周的八月。Admin全量405文件/3375项、类型检查及生产构建通过,`git diff --check`通过;仅既有AnalyticsChart大分块提示。本补修尚未提交或发布,人工视觉待确认;证据为`.logs/appointment-calendar-month-weeks-{test,build}.log`。
|
||||
|
||||
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
|
||||
|
||||
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
|
||||
|
||||
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
|
||||
|
||||
2026-10-16 本次 UI 检查:用户重新要求检查后,运行 Admin 全量测试,397 文件 / 3292 项通过;发现并修正 QueryToolbar 必填 `ariaLabel` 属性的模板写法。补充真实 KeepAlive + Teleport 离开/恢复及点击提醒弹层回归后,最新定向检查 3 文件 / 66 项通过,最终 `pnpm typecheck`、`pnpm build` 和 `git diff --check` 通过。构建仍有既有 AnalyticsChart 约 588 kB 分块提示。此记录更新下文历史“未运行验证”的结论;本地自动检查不代表测试环境或人工视觉验收。所属客户显示缺项仍待后端合同补齐,INP 采集报错的脚本来源未确认,本次未部署。日志为 `.logs/appointment-ui-current-{test,focused,typecheck,build}.log`。
|
||||
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
|
||||
|
||||
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
|
||||
|
||||
2026-10-16 操作列样式核对:用户截图 `codex-clipboard-9d54345c-c9c1-4991-82ba-b319948f781b.png` 明确要求与外层表格统一。
|
||||
|
||||
| 页面 | 操作 | 截图依据与布局 | 当前实现 | 本轮结论 |
|
||||
|---|---|---|---|---|
|
||||
| 预约工作明细表格 | 查看详情或服务端提供的下一步操作 | 操作列内蓝色普通文字,无边框、底色或装饰图标 | `RowActions` 默认描边样式 | 使用公共 `text-actions` 模式,保留当前文案、权限、点击承载和长文案换行;待人工截图确认 |
|
||||
|
||||
2026-10-16 当前承载调整:用户反馈“点击外面关闭不了,也看不清”。日期/类型/全局明细、事项详情、原侧边编辑表单和到港参考统一改用带遮罩的居中 `AppDialog`,常规宽度 1120px、高度 88dvh,支持全屏;标题与分页固定、仅正文滚动。遮罩、Esc 和关闭按钮统一经过未保存/提交结果待确认保护。此结论优先于下表早期侧边承载描述;原业务独立页面跳转、确认流程和统计口径保持原样。改后待人工截图确认。
|
||||
|
||||
V13 日历布局:按已确认定高要求,月日格固定 146px,周日格固定 270px;只展示五类服务端计数和独立到港参考,不追加逐事项卡片、不依明细条数扩高。替代下表早期“随内容自然增高”的结论;不裁掉五类入口,仍需人工核对 1920×1080、100% 下完整内容。
|
||||
@@ -79,7 +41,6 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
|
||||
| V13 场景 | 当前前端与直接检查 | 依赖/剩余验收 |
|
||||
|---|---|---|
|
||||
| 事项列表统一表格 | 用户截图 `codex-clipboard-1bde1991-b226-4956-8530-6de44074f818.png` 明确要求统一表格或卡片,不能混排 | 所有日期/类型/全局待办列表统一使用 DataTable 与 RowActions,替代早期多行卡片排版。操作固定最左;事项、类型、状态、作业时间、时区、流程、地点、负责人、下次跟进、提醒分列,时间保留实际/确认/目标口径。列表弹框加宽,表头与分页由共享表格承载;保留每页 8 项、筛选和返回上下文。当前调整覆盖旧卡片与独立分页结论;未运行验证或部署,待人工截图确认 |
|
||||
| 01 整客户 160 移出续办 | `WorkDetailPanel.test.ts` 保存后读取主要下一步;`WorkExistingDispatchForm.test.ts` 沿用真实排车单继续安排本批车辆 | 单客户移出命令、当前数量/车辆版本、原车 120 保留结果及后续目标;真实发车签收待集成 |
|
||||
| 02 100 先送、60 后送 | `AppointmentBatchForm.test.ts` 保留普通分批与来源版本;`WorkDetailPanel.test.ts` 区分未定作业日和跟进日 | 已确认预约/装载关系的部分移出与调整回复语义,不能以普通分批代替 |
|
||||
| 03 后送 60 加另一客户 40 共车 | 本批与另一客户按实际托盘各建排车单;结果页“与同仓其他排车单共车”沿业务号核对两单后复用 TripPreparationDialog/Fleet prepare,可加入已有计划车次;WorkDispatchForm、WorkSharedTripForm、TripPreparationDialog、trips API 测试覆盖传递与导航 | 实物须能对应本次数量,不能对应时先走原库存拆托;共车后的路线、容量、审核及发车须真实 HTTP 验收,非任意箱级拆托开发缺口 |
|
||||
@@ -181,12 +142,8 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
|
||||
| 本次操作 | 证据与现状 | 调整结论 |
|
||||
|---|---|---|
|
||||
| 未定日期范围内搜索 | 用户截图 `codex-clipboard-07b5ab03-e467-4ce1-8410-13d05ac5bc51.png` 反馈大量数据缺少搜索,且测试环境仍显示旧卡片 | 全局待办弹框接入 QueryToolbar,支持柜号、批次号、车次号、地点关键词;沿现有 items API 同时传 view(含 UNDATED)与 keyword,服务端全范围筛选后分页,不过滤当前 8 条。搜索/清空回第 1 页,返回明细保留搜索词和分页,原业务跳转的 return URL 保留 workListKeyword。保留全局统计口径;表格与搜索均为当前工作区改动,本任务未部署,未运行验证 |
|
||||
| 所属客户缺失排查 | 用户截图 `codex-clipboard-645f5354-0dfd-4b90-b6f7-05ff9b1f2ffb.png`;`BusinessReference` 只有 kind/key/businessNo/label,客户引用存在 `CUSTOMER + 内部客户键 + null + 所属客户` 的生产路径,详情查询仅合并引用 | 前端按 `businessNo || '—'` 展示,缺少真实客户名称;不能据此认定没有关联客户,也不能回退内部客户键。待工作事项后端按权威客户目录补充名称/业务编号并明确展示合同;遵循开发协作契约,本任务不保留后端改动。未完成接口或环境验收 |
|
||||
| 提醒列与跳转遮罩 | 用户截图 `codex-clipboard-54e0b56d-448c-40da-a147-15798c932091.png` 和按钮反馈 | 提醒列固定 105px,仅显示去重后的条数,点击展开完整可滚动提醒,不撑高表格行。预约页被 KeepAlive 停用时卸载挂到 body 的弹框,恢复时保留父级事项/分页上下文。用户 startTime 截图实际是 INP 采集的空 entries 读取,脚本来源尚待确认,不能将该报错认定为已修复;本轮未运行验证或部署 |
|
||||
| 事项详情的信息层级 | 用户 2026-10-16 截图 `codex-clipboard-cad4abc6-51d1-42f2-8e7b-7914b264348e.png` 反馈排版不清楚、缺少重点 | 顶部突出事项名称与状态;作业时间、时区、地点、负责人和下次跟进复用共享描述表;原有提醒去重后集中展示,保留完成态与跟进日期语义;当前操作与关联资料/历史分区,保留所有动作资格、原业务跳转、默认历史展开条件和未保存保护。空原业务进度不渲染空标题;已知事件代码使用中文名称,未知代码保留在辅助信息中,时间保留原时区。本轮未运行验证或部署,待人工截图确认 |
|
||||
| 列表信息层级与日历视觉细化 | 用户 2026-10-16 截图 `codex-clipboard-6858cde3-eda3-4a02-a043-e1a47143f67c.png`、`codex-clipboard-5a68965d-cdbd-4ce9-b8b3-385d9d76b4f2.png`,并明确要求按建议优化 | 查看详情/历史使用普通描边按钮,办理动作保留主色且仍在左侧;完整标题、状态、作业时间、时区、地点、流程、负责人、跟进日期及异常分层显示。日历类型改浅色底与弱边框,保留语义色、今天/选中态;周末表头与到港参考保持辅助层级,零值弱化、超期数字提示。保留现有覆盖面板、字号、统计和动作链;改后待人工截图确认 |
|
||||
| 工作明细左侧长操作按钮 | 用户 2026-10-16 截图 `codex-clipboard-b36b905a-a223-4fa6-98ca-7ece08149a37.png` 反馈按钮过大;原 108px 列宽、全宽实心按钮导致长文案换行撑高 | 操作列调整为 184px,按钮按文案宽度使用共享 `density-action` 描边样式和 28px 最小高度;完整保留服务端动作标签及点击行为,超长文案仍可换行。业务编号使用深色标题,辅助信息使用说明字号,行间距收紧。适用全部工作明细弹框及全屏态,覆盖早期实心主按钮结论;改后待人工截图确认,本轮未运行验证或部署 |
|
||||
| 工作明细左侧长操作按钮 | Admin #621 真实 1920×1080 截图 `baiyue-firstleg/reports/appointment-browser-20261015/admin-621-follow-today-1920.png`;按钮与文本实际行高仅 13px | 保留完整文案和 108px 操作列,统一使用 20px 行高、自然换行和自适应高度;普通/展开面板及主操作/查看详情使用同一规则 |
|
||||
| 流程与工具栏分组 | 用户后续流程下拉、日期工具栏及上下操作区截图(`115df974`、`d217350b`、`80dd9fa1`) | 流程改为四个直接切换按钮;全局待办、流程、日期与类型筛选收进连续白底工作区,各行靠左,保留全局与区间统计边界 |
|
||||
| 日历浏览与日期选择 | 主页面截图;原型 `renderCalendar()`、`.day.selected`、`.todaymark` | 缩小日格空白,今天与选择态分别标识;零项日期只显示日期;显示实际查询起止日期,保留加载失败状态 |
|
||||
| 日期/类型/全局明细 | 展开截图中日历被挤窄;原型第 11 行 `.workspace`、`.panel` 为右侧覆盖布局 | 恢复覆盖式侧边工作面板,不压缩日历;增加展开/收起,标题和关闭固定,正文滚动,分页固定;仍保留左侧业务操作 |
|
||||
@@ -210,8 +167,5 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
| 客户仓批次用车 | 原排车与车次安排入口 | 按 DISPATCH 引用读取原排车单,再进入原车次安排 | `WorkExistingDispatchForm.vue`;核对不可变批次键与真实业务号,保留原实物;不调用 FBA 候选,不新建排车单;原派送进度按明确业务引用进入 |
|
||||
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
|
||||
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
|
||||
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
|
||||
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
|
||||
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
|
||||
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getFbaAppointment: vi.fn() }))
|
||||
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, recordVersion: 0, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
|
||||
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
|
||||
function render(overrides: Partial<InstanceType<typeof TripAppointmentPanel>['$props']> = {}) { return mount(TripAppointmentPanel, { props: { dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }], previousDispatches: [], historyUnavailableReason: null, canWrite: true, ...overrides }, global: { stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function render() { return mount(WorkBatchContinuation, { props: { batchKey: 'FBA_APPOINTMENT:40', labels: [{ customerId: 'customer-a', label: 'IN-A' }] }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></div>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('shows synchronization after a successful save and continues by reading without resubmitting', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('同步中')
|
||||
expect(wrapper.text()).toContain('无需重复提交')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['actual-key']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires a customer selection for multiple real work candidates and uses the selected resolver result', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: null, candidates: [{ customerId: 'customer-a', workItemKey: 'real-work-a', batchNo: 'AP-40' }, { customerId: 'customer-b', workItemKey: 'real-work-b', batchNo: 'AP-40' }] }).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: workDetail({ item: workItem({ workItemKey: 'current-work-a' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
|
||||
@@ -4,15 +4,13 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { resolveBatchWork, type BatchWorkResolution } from './workApi'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> }>()
|
||||
const emit = defineEmits<{ open: [key: string] }>()
|
||||
const resolution = ref<BatchWorkResolution | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
const controller = new AbortController()
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load(customerId = props.customerId) {
|
||||
loading.value = true; error.value = null
|
||||
try { const value = await syncRead.read(() => resolveBatchWork(props.batchKey, customerId, controller.signal), controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
try { const value = await resolveBatchWork(props.batchKey, customerId, controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
catch (cause) { if (!controller.signal.aborted) error.value = getApiErrorDetails(cause, '当前批次下一步读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -21,7 +19,7 @@ onBeforeUnmount(() => controller.abort())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="当前批次续办">
|
||||
<p role="status">{{ syncing ? '预约工作清单同步中,正在自动读取当前批次下一步;已保存内容无需重复提交。' : '正在定位当前批次的工作事项;已保存的内容无需重复提交。' }}</p>
|
||||
<p>正在定位当前批次的工作事项;已保存的内容无需重复提交。</p>
|
||||
<LoadErrorState v-if="error" title="当前批次下一步读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="resolution && !resolution.detail && !error"><p>本批有多个客户,请选择本次继续办理的货物。</p><el-button v-for="(candidate, index) in resolution.candidates" :key="candidate.workItemKey" :disabled="loading" @click="load(candidate.customerId)">{{ candidate.batchNo || '—' }} · {{ labels?.find(row => row.customerId === candidate.customerId)?.label || `客户范围 ${index + 1}` }}</el-button><p v-if="!resolution.candidates.length">当前没有可定位的送仓事项,请核对服务端同步结果。</p></template>
|
||||
</section>
|
||||
|
||||
@@ -57,7 +57,7 @@ it('shows the actual cross-month interval and hides empty-day zeros while keepin
|
||||
})
|
||||
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(35)
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(42)
|
||||
const day = wrapper.get('[data-date="2026-10-15"]')
|
||||
expect(day.get('.day-heading').text()).toContain('2 项')
|
||||
await day.get('[data-type="DELIVERY"]').trigger('click')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -11,7 +11,7 @@ import type { SourceReference } from './workApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import { workReason } from './workPresentation'
|
||||
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
|
||||
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -35,7 +35,7 @@ async function save() {
|
||||
try {
|
||||
const result = await createSourceAppointment(pending.value)
|
||||
pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved', result)
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved')
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '创建结果待确认,请重试本次请求')
|
||||
if (rejectedCommand(cause)) { pending.value = null; await load() }
|
||||
|
||||
@@ -21,21 +21,6 @@ function render(detail: WorkDetail, initialAction?: WorkAction) {
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('navigates the server-selected transport next step outside the delivery write permissions', async () => {
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ nextAction: transport, allowedActions: [followAction] }) }))
|
||||
await flushPromises()
|
||||
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
|
||||
expect(wrapper.text()).not.toContain('操作资格已变化')
|
||||
expect(wrapper.find('[data-dispatch]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
const target = render(workDetail({ item: workItem({ workItemKey: 'transport-current', type: 'TRANSPORT', allowedActions: [], nextAction: null }) }), transport)
|
||||
await flushPromises()
|
||||
expect(target.find('[data-dispatch]').exists()).toBe(false)
|
||||
expect(target.emitted('open')).toBeUndefined()
|
||||
target.unmount()
|
||||
})
|
||||
it('stays on the saved item and offers only the newly fetched primary next step', async () => {
|
||||
const wrapper = render(workDetail(), followAction); await flushPromises()
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
|
||||
@@ -17,18 +17,9 @@ import WorkReceivingPanel from './WorkReceivingPanel.vue'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
|
||||
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
|
||||
const attentionReasons = computed(() => [...new Set([
|
||||
...(detail.value?.item.blockReasons || []),
|
||||
...(detail.value?.item.missingFields || []),
|
||||
...(detail.value?.item.overdueReasons || []),
|
||||
].map(workReason))])
|
||||
const needsFollowup = computed(() => !!detail.value && detail.value.item.executionStatus !== 'COMPLETED' && (!detail.value.item.owner || !detail.value.item.followupDate))
|
||||
const historyLabels: Record<string, string> = { BASELINE_OBSERVED: '同步原业务状态', GENERATED: '创建工作事项', CONFIRM_ROUTE: '确认路线', SAVE_FOLLOWUP: '记录跟进', RESOLVE_REVIEW: '处理复核', SAVE_REQUIREMENT: '保存预约要求', HANDOFF_RETURN: '交接还空责任', ACCEPT_RETURN: '确认接手还空' }
|
||||
function historyTime(value: string) { return value.replace('T', ' ').replace(/\.\d+(?=Z|[+-]\d{2}:\d{2}$)/, '').replace(/Z$/, ' UTC') }
|
||||
const receivingOwners = computed(() => [detail.value?.item.owner, ...(detail.value?.relatedItems.map(item => item.owner) || [])].filter((person): person is { id: string; name: string } => !!person?.id))
|
||||
const actionNotice = ref('')
|
||||
const saveCompleted = ref(false)
|
||||
@@ -37,11 +28,9 @@ const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | nu
|
||||
const receivingKey = ref<string | null>(null)
|
||||
const continuation = ref<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> } | null>(null)
|
||||
let controller: AbortController | null = null, request = 0
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load() {
|
||||
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
|
||||
const signal = controller.signal
|
||||
try { const result = await syncRead.read(() => getWorkDetail(props.itemKey, signal), signal); if (generation === request) detail.value = result }
|
||||
try { const result = await getWorkDetail(props.itemKey, controller.signal); if (generation === request) detail.value = result }
|
||||
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
|
||||
finally { if (generation === request) loading.value = false }
|
||||
}
|
||||
@@ -71,9 +60,7 @@ const secondaryActions = computed(() => detail.value?.item.allowedActions.filter
|
||||
async function choose(next: WorkAction) {
|
||||
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
|
||||
actionNotice.value = ''
|
||||
// 跨事项的主要下一步只用于导航,目标面板会重新读取并校验自己的写入资格。
|
||||
const linkedNext = next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey && detail.value.item.nextAction && sameAction(next, detail.value.item.nextAction)
|
||||
if (!linkedNext && !actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
if (!actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
saveCompleted.value = false
|
||||
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
|
||||
const path = actionRoute(next, detail.value.references, detail.value.item)
|
||||
@@ -113,33 +100,17 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<div class="work-detail" :aria-busy="loading">
|
||||
<div :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
|
||||
<p v-if="loading">正在读取当前事项与操作资格…</p>
|
||||
<template v-if="detail">
|
||||
<header class="detail-overview">
|
||||
<div class="detail-title"><h3>{{ detail.item.title }}</h3><el-tag :type="detail.item.executionStatus === 'COMPLETED' ? 'success' : detail.item.reviewRequired ? 'warning' : 'info'" size="small">{{ workStatus(detail.item) }}</el-tag></div>
|
||||
<p class="detail-category">{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }}</p>
|
||||
</header>
|
||||
<dl class="density-fields detail-facts" aria-label="事项关键信息">
|
||||
<div><dt>作业时间</dt><dd>{{ workDate(detail.item) }}</dd></div>
|
||||
<div><dt>作业时区</dt><dd>{{ detail.item.timezone || '地点时区待核实' }}</dd></div>
|
||||
<div><dt>作业地点</dt><dd>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }}</dd></div>
|
||||
<div><dt>负责人</dt><dd>{{ detail.item.owner?.name || '待分派' }}</dd></div>
|
||||
<div class="followup-summary"><dt>下次跟进</dt><dd>{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'" class="field-note">作业日期未定,跟进日不作为作业日期</span></dd></div>
|
||||
</dl>
|
||||
<div v-if="detail.item.containers.length" class="container-links"><span>关联货柜</span><el-button v-for="container in detail.item.containers" :key="container.containerBookingId" class="density-action" data-tone="primary" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</el-button></div>
|
||||
<aside v-if="needsFollowup || attentionReasons.length" class="attention-panel" aria-label="事项提醒">
|
||||
<h4>事项提醒</h4>
|
||||
<ul>
|
||||
<li v-if="needsFollowup">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</li>
|
||||
<li v-for="reason in attentionReasons" :key="reason">{{ reason }}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
|
||||
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
|
||||
<p class="followup-summary">下次跟进:{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'"> · 作业日期未定,跟进日不作为作业日期</span></p>
|
||||
<p v-if="detail.item.executionStatus !== 'COMPLETED' && (!detail.item.owner || !detail.item.followupDate)" class="work-warning">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</p>
|
||||
<div class="container-links"><button v-for="container in detail.item.containers" :key="container.containerBookingId" type="button" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</button></div>
|
||||
<p v-for="reason in [...detail.item.blockReasons, ...detail.item.missingFields, ...detail.item.overdueReasons]" :key="reason" class="work-warning">{{ workReason(reason) }}</p>
|
||||
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
|
||||
<section class="current-work" aria-label="当前处理">
|
||||
<h4>{{ detail.item.executionStatus === 'COMPLETED' ? '事项结果' : '当前处理' }}</h4>
|
||||
<p v-if="detail.item.executionStatus === 'COMPLETED'" class="completion-summary">当前事项已完成,可查看关联工作和历史记录。</p>
|
||||
<template v-if="!action && !continuation">
|
||||
<p v-if="saveCompleted" role="status" class="save-result">本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}</p>
|
||||
<el-button v-if="detail.item.nextAction" class="primary-next-action" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
|
||||
@@ -157,43 +128,15 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" @batch="openBatch" />
|
||||
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<section v-else-if="action?.target.kind === 'READ_ONLY' && action.target.references.length" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
|
||||
<section v-else-if="action?.target.kind === 'READ_ONLY'" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
|
||||
<details v-if="secondaryActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in secondaryActions" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
|
||||
</section>
|
||||
<section class="detail-records" aria-label="关联资料与历史">
|
||||
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button class="density-action" @click="emit('open', related.workItemKey)">查看</el-button></div><p v-if="!detail.relatedItems.length">暂无关联工作</p></details>
|
||||
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p><p v-if="!detail.references.length">暂无原业务记录</p></details>
|
||||
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录 · {{ detail.history.length }} 条</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><div class="history-heading"><b>{{ historyLabels[entry.action] || '事项记录' }}</b><time :datetime="entry.occurredAt">{{ historyTime(entry.occurredAt) }}</time></div><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }}<span v-if="!historyLabels[entry.action]"> · {{ entry.action }}</span></small></div><p v-if="!detail.history.length">暂无记录</p></details>
|
||||
</section>
|
||||
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button @click="emit('open', related.workItemKey)">查看</el-button></div></details>
|
||||
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p></details>
|
||||
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><b>{{ entry.action }}</b><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}</small></div><p v-if="!detail.history.length">暂无记录</p></details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-detail { font-size: var(--admin-font-body); line-height: 1.6; color: var(--el-text-color-primary); }
|
||||
.detail-overview { margin-bottom: var(--admin-space-5); }
|
||||
.detail-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.detail-title h3 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.detail-category { margin: var(--admin-space-1) 0 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-detail .detail-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.followup-summary { grid-column: 1 / -1; }
|
||||
.field-note { margin-left: var(--admin-space-5); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.container-links, .more-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.container-links { margin-top: var(--admin-space-3); }
|
||||
.container-links > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.attention-panel { margin-top: var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-5); border-left: 3px solid var(--el-color-warning); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); }
|
||||
.attention-panel h4, .current-work > h4 { margin: 0 0 var(--admin-space-1); font-size: var(--admin-font-section); }
|
||||
.attention-panel ul { margin: 0; padding-left: var(--admin-space-6); font-size: var(--admin-font-caption); }
|
||||
.current-work { margin-top: var(--admin-space-5); padding-block: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.current-work > p { margin: var(--admin-space-2) 0; }
|
||||
.completion-summary { color: var(--el-color-success-dark-2); }
|
||||
.detail-records { border-top: 1px solid var(--cc-border); }
|
||||
.detail-records > details { border-bottom: 1px solid var(--cc-border); }
|
||||
summary { cursor: pointer; padding-block: var(--admin-space-3); font-weight: 600; }
|
||||
summary:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
|
||||
.detail-records p { margin: var(--admin-space-1) 0; }
|
||||
.related-row, .history-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }
|
||||
.related-row:last-child, .history-entry:last-child { border-bottom: 0; }
|
||||
.related-row p, .history-entry small, .history-heading time { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.work-warning { color: var(--el-color-warning-dark-2); }
|
||||
.container-links, .more-actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }.container-links button { background: transparent; border: 0; color: var(--el-color-primary); cursor: pointer; font: inherit; }
|
||||
.related-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }details { margin-top: var(--admin-space-5); }summary { cursor: pointer; padding-block: var(--admin-space-3); }.work-warning { color: var(--el-color-warning-dark-2); }
|
||||
</style>
|
||||
|
||||
@@ -1,162 +1,94 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true, denied: [] as string[] }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const batch = 'FBA_APPOINTMENT:1'
|
||||
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
|
||||
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
|
||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
}
|
||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||
function render(props: { batchKeys?: string[]; canWrite?: boolean } = {}) { return mount(WorkDispatchForm, { props: { batchKeys: [batch], canWrite: true, ...props }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
it.each([0, '0', '9007199254740993'])('preserves authoritative allocation version %s from the existing batch provider', async recordVersion => {
|
||||
const value = row(); value.allocations[0]!.recordVersion = recordVersion
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [value], blockReasons: [] })
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
|
||||
const value = row(); value.allocations[0]!.recordVersion = recordVersion as unknown as number
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 0, candidates: [value], blockReasons: [] })
|
||||
const wrapper = render()
|
||||
try { await flushPromises(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选'); expect(createDispatchDraft).not.toHaveBeenCalled() }
|
||||
finally { wrapper.unmount() }
|
||||
})
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; access.denied = []
|
||||
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
||||
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
|
||||
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
|
||||
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
|
||||
vi.mocked(convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: [{ draftContainerId: 'container-1', dispatch: proof(body.groups[0]!.dispatch.idempotencyKey) }] }))
|
||||
vi.mocked(getDispatch).mockImplementation(async no => detail(no)); vi.mocked(listDispatchDrafts).mockImplementation(async () => [clone(draft)])
|
||||
vi.mocked(releaseDispatchDraft).mockImplementation(async () => ({ ...clone(draft), status: 'RELEASED', containers: [], sources: [] }))
|
||||
vi.clearAllMocks(); access.allowed = true
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([])
|
||||
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
|
||||
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
|
||||
})
|
||||
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
|
||||
const actual = await vi.importActual<typeof import('../overseas-warehouse/api/dispatches')>('../overseas-warehouse/api/dispatches')
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
|
||||
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
} finally { wrapper.unmount(); get.mockRestore() }
|
||||
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] })
|
||||
expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch')
|
||||
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
|
||||
expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] })
|
||||
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS/EXISTING', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
|
||||
}
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
|
||||
it('opens quantity splitting for only the current batch without locking physical cargo or creating a new full-source appointment', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await button(wrapper, '按当前批次箱数分批').trigger('click')
|
||||
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']])
|
||||
expect(lockDispatchDraftPallet).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
|
||||
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
|
||||
vi.mocked(getReceivingContext).mockResolvedValueOnce(receiving(batch, [{ dispatchNo: 'DS-OLD', batchKey: batch, customerId: 'customer-b', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const mismatch = render(); await flushPromises(); expect(mismatch.text()).toContain('排车单与工作批次关联不一致'); expect(mismatch.text()).not.toContain('核对本批剩余可排实物'); expect(createDispatchDraft).not.toHaveBeenCalled(); mismatch.unmount()
|
||||
it('preselects only eligible physical cargo of the entered batch, never unrelated allocations or unknown quantity', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: 'other-batch', allocations: [{ ...candidate.allocations[0]!, batchKey: 'FBA_APPOINTMENT:999' }] }, { ...candidate, planPalletId: 'unknown', packageCount: null }] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
const inputs = wrapper.findAll('input[type=checkbox]')
|
||||
expect(inputs.map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false])
|
||||
expect(inputs[1]!.attributes('disabled')).toBeDefined()
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId])
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(candidate.allocations)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
|
||||
vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false)
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
|
||||
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
|
||||
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
|
||||
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
|
||||
it('recovers a draft container from the same token when its creation response was lost', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }])
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
|
||||
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
|
||||
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
|
||||
it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b', allocations: [{ ...candidate.allocations[0]!, customerId: 'customer-b', planPalletId: '2' }] }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
|
||||
const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
|
||||
expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
|
||||
await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
|
||||
expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount()
|
||||
})
|
||||
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
|
||||
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
|
||||
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
|
||||
it('does not read or write scoped cargo without all original permissions', async () => {
|
||||
access.allowed = false; const wrapper = render(); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
|
||||
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
|
||||
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
})
|
||||
it('fails closed on incomplete native candidates, missing versions, and cross-warehouse batches', async () => {
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.text()).toContain('未完整返回'); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
|
||||
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.get('input').attributes('disabled')).toBeDefined(); missing.unmount()
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
|
||||
})
|
||||
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
|
||||
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
|
||||
expect(await guard()).toBe(false); wrapper.unmount()
|
||||
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
|
||||
})
|
||||
it('retries the exact container command after a lost response instead of generating another container key', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
|
||||
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
|
||||
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
|
||||
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
|
||||
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
|
||||
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
|
||||
})
|
||||
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
|
||||
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
|
||||
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
|
||||
})
|
||||
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
|
||||
access.allowed = false; const denied = render(); await flushPromises(); expect(getReceivingContext).not.toHaveBeenCalled(); denied.unmount()
|
||||
access.allowed = true; access.denied = ['platform:overseas-warehouse:dispatch:create']; const readonly = render(); await flushPromises(); expect(button(readonly, '创建整车排车单').attributes('disabled')).toBeDefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
|
||||
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
|
||||
it('continues the actual later 60-box dispatch through the shared-trip entry without re-creating or reallocating cargo', async () => {
|
||||
const sixty = { ...candidate, packageCount: 60, allocations: [{ ...candidate.allocations[0]!, packageCount: 60 }] }
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [sixty], blockReasons: [] })
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-later', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'origin-1', packageCount: 60 } }])
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '与同仓其他排车单共车').trigger('click'); await flushPromises()
|
||||
const shared = wrapper.getComponent({ name: 'WorkSharedTripForm' })
|
||||
expect(shared.props()).toMatchObject({ dispatchNo: 'DS-60', canWrite: true })
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(sixty.allocations)
|
||||
shared.vm.$emit('busy', true); await flushPromises(); expect(button(wrapper, '继续安排其他实物').attributes('disabled')).toBeDefined()
|
||||
shared.vm.$emit('external', '/overseas-master-data/trips/TR-100'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-100']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,226 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, dispatchVehicleSpecs, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getReceivingContext } from './receivingApi'
|
||||
import { useWorkDraft } from './useWorkDraft'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
||||
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
|
||||
type ReadyRow = BatchRow & { native: Candidate }
|
||||
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
|
||||
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
||||
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||||
const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]>([]), selected = ref<string[]>([])
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
|
||||
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
||||
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
|
||||
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
let generation = 0
|
||||
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
|
||||
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||
function reasons(row: BatchRow) { return row.blockReasons }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
|
||||
const sharedTripOpen = ref(false), sharedTripBusy = ref(false)
|
||||
const selectionBaseline = ref('[]')
|
||||
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
|
||||
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
|
||||
function reasons(row: BatchDispatchCandidate) {
|
||||
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
|
||||
return [...row.blockReasons, ...(row.packageCount == null || row.packageCount <= 0 ? ['实物箱数待核实'] : []), ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(row.allocations.some(allocation => !props.batchKeys.includes(allocation.batchKey) || allocation.customerId !== row.customerId) ? ['候选不属于当前批次和客户范围'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
|
||||
}
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.planPalletId)))
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value))
|
||||
async function load() {
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
||||
loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []
|
||||
if (!readable.value || pending.value) return
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
||||
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
||||
if (current !== generation) return
|
||||
const references: typeof existing.value = []
|
||||
receiving.forEach((context, index) => {
|
||||
const batchKey = batches[index]!
|
||||
if (context.batch.batchKey !== batchKey) throw new DispatchInputError('运输关系与当前预约批次不一致,请重新读取')
|
||||
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
|
||||
if (!vehicle.dispatchNo) throw new DispatchInputError('本批已有运输安排但缺少排车单号,请核对原记录')
|
||||
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
|
||||
}
|
||||
})
|
||||
existing.value = references
|
||||
if (references.length && !arrangingRemaining.value) return
|
||||
const origins = [...new Set(receiving.map(context => context.batch.originWarehouseId).filter(Boolean))]
|
||||
if (origins.length !== 1 || receiving.some(context => !context.batch.originWarehouseId)) throw new DispatchInputError('所选批次须有明确且相同的起运仓,请从同仓批次继续')
|
||||
warehouseId.value = origins[0]!
|
||||
const batchCandidates = await Promise.all(batches.map(key => getBatchDispatchCandidates(key)))
|
||||
if (current !== generation) return
|
||||
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
||||
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
||||
contexts.value = batchCandidates
|
||||
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
|
||||
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
|
||||
if (current !== generation) return
|
||||
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
|
||||
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
|
||||
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
|
||||
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
||||
const candidate = matches.length === 1 ? matches[0]! : null
|
||||
const sources = receiving[index]!.batch.items
|
||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||||
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||||
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
||||
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||||
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
|
||||
}))
|
||||
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
|
||||
const eligible = rows.value.filter(row => !reasons(row).length)
|
||||
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
||||
baseline.value = JSON.stringify(selected.value)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
contexts.value = await Promise.all([...new Set(props.batchKeys)].map(getBatchDispatchCandidates))
|
||||
const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0)
|
||||
selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : []
|
||||
selectionBaseline.value = JSON.stringify(selected.value)
|
||||
}
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '批次可排实物读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function select(row: BatchRow, event: Event) {
|
||||
function select(row: BatchDispatchCandidate, checked: boolean) {
|
||||
if (!writable.value || pending.value || saving.value || loading.value || error.value || reasons(row).length) return
|
||||
if (!checked) { selected.value = selected.value.filter(id => id !== row.planPalletId); return }
|
||||
if (selected.value.includes(row.planPalletId)) return
|
||||
if (chosen.value.some(current => current.customerId !== row.customerId)) { ElMessage.warning('一张排车单只选择同一客户实物;其余客户可继续分别安排'); return }
|
||||
if (selected.value.length >= capacity.value) { ElMessage.warning('已达到所选车型托位上限'); return }
|
||||
selected.value = [...selected.value, row.planPalletId]
|
||||
}
|
||||
function changeSelection(row: BatchDispatchCandidate, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
|
||||
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
|
||||
else if (!selected.value.includes(row.key)) {
|
||||
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
||||
else selected.value.push(row.key)
|
||||
}
|
||||
input.checked = selected.value.includes(row.key)
|
||||
}
|
||||
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
|
||||
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
|
||||
async function executeStep() {
|
||||
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
|
||||
command.attempted = true; persist()
|
||||
try {
|
||||
let value: DispatchDraft | ConvertedDraft
|
||||
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
|
||||
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
|
||||
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
|
||||
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
|
||||
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
|
||||
ensureOwner()
|
||||
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
|
||||
if (command.action === 'CONVERT') {
|
||||
const converted = value as ConvertedDraft
|
||||
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
|
||||
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null })
|
||||
request.converted = converted
|
||||
} else {
|
||||
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
||||
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
|
||||
if (command.action === 'CONTAINER') {
|
||||
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
|
||||
request.containerId = draft.containers[0]!.draftContainerId
|
||||
}
|
||||
if (command.action === 'LOCK') {
|
||||
const selectedSource = command.body.source as DraftSelection['source']
|
||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||
const group = requireSelectionGroup(source)
|
||||
assertSelectionGroupVersions([expected!.native], [source])
|
||||
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
||||
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
||||
}
|
||||
request.draft = draft
|
||||
}
|
||||
request.step = null; persist()
|
||||
} catch (cause) {
|
||||
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
|
||||
persist(); throw cause
|
||||
}
|
||||
select(row, input.checked); input.checked = selected.value.includes(row.planPalletId)
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
|
||||
saveError.value = null
|
||||
if (!writable.value || saving.value || loading.value || error.value || result.value) return
|
||||
if (!pending.value) {
|
||||
if (!chosen.value.length || chosen.value.length > capacity.value || chosen.value.some(row => reasons(row).length) || new Set(chosen.value.map(row => row.customerId)).size !== 1) { ElMessage.warning('请核对所选客户、实物资格和托位数量'); return }
|
||||
pending.value = { token: crypto.randomUUID(), capacity: capacity.value, rows: structuredClone(chosen.value.map(row => ({ ...row, allocations: row.allocations.map(allocation => ({ ...allocation })), blockReasons: [...row.blockReasons] }))), containerId: null, lockedIds: [], submitting: false }
|
||||
}
|
||||
saving.value = true; saveError.value = null; rejected.value = false
|
||||
try {
|
||||
ensureOwner()
|
||||
if (!pending.value) {
|
||||
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
|
||||
}
|
||||
saving.value = true
|
||||
const request = pending.value
|
||||
while (!request.converted) {
|
||||
if (!request.step) {
|
||||
if (request.releasing) { step('RELEASE', common(request.draft!)) }
|
||||
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
|
||||
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
|
||||
else {
|
||||
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
|
||||
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
|
||||
else {
|
||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
await executeStep()
|
||||
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
|
||||
if (!request.containerId) {
|
||||
const drafts = await listDispatchDrafts(), existing = drafts.find(draft => draft.draftToken === request.token)?.containers || []
|
||||
if (existing.length > 1) throw new Error('本次草稿出现多组装车容器,请核对保存结果')
|
||||
request.containerId = existing[0]?.id || (await createDispatchDraftContainer(request.token, request.capacity)).id
|
||||
}
|
||||
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
|
||||
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
|
||||
ensureOwner(); validateCurrentDispatch(latest, proof)
|
||||
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
|
||||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
|
||||
if (!request.submitting) for (const row of request.rows) if (!request.lockedIds.includes(row.planPalletId)) { await lockDispatchDraftPallet(row.planPalletId, request.token, request.containerId); request.lockedIds.push(row.planPalletId) }
|
||||
request.submitting = true
|
||||
const response = await preArrangeDraftDispatch({ draftToken: request.token, groups: [{ draftContainerId: request.containerId, planPalletIds: request.rows.map(row => row.planPalletId), returnReceiptIds: [], batchAllocations: request.rows.flatMap(row => row.allocations) }] })
|
||||
if (response.length !== 1 || response[0]?.draftContainerId !== request.containerId || !response[0].dispatch.dispatchNo) throw new Error('排车结果与本次分组不一致,请重试核对原请求')
|
||||
result.value = response[0].dispatch; pending.value = null; selected.value = []
|
||||
ElMessage.success(`排车单 ${result.value.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '本次排车结果待确认,请重试原请求'); rejected.value = rejectedCommand(cause) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function releaseRejected() {
|
||||
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
ensureOwner(); const request = pending.value
|
||||
if (!request.draft) { pending.value = null; persist(); return }
|
||||
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
|
||||
ensureOwner()
|
||||
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
|
||||
request.draft = owned; request.releasing = true; request.step = null; persist()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
|
||||
async function refreshRejected() {
|
||||
if (!rejected.value || !pending.value || saving.value) return
|
||||
saving.value = true
|
||||
try { await releaseDispatchDraft(pending.value.token); pending.value = null; selected.value = []; rejected.value = false; await load() }
|
||||
catch (cause) { saveError.value = getApiErrorDetails(cause, '本次草稿尚未释放,请重试') }
|
||||
finally { saving.value = false }
|
||||
if (pending.value?.releasing) await save()
|
||||
else if (!pending.value) await load()
|
||||
}
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; await load() } }
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="按预约批次创建整车排车">
|
||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||
<section class="work-form" aria-label="按预约批次排车">
|
||||
<h3>按批次选择真实托盘</h3><p>单客户单批入口仅预选本批当前合格实物;每次选择同一客户创建排车单,再安排车辆和司机。逐来源数量由服务端候选确定。</p>
|
||||
<el-button v-if="batchKeys.length === 1 && !result" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||||
<template v-if="existing.length && !loading && !error && !result && !pending">
|
||||
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
|
||||
<p v-if="loading">正在核对批次、客户与实物来源…</p>
|
||||
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" :disabled="sharedTripBusy" @click="prepareOpen = true">继续安排本批车辆</el-button><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list') && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" @click="sharedTripOpen = true">与同仓其他排车单共车</el-button><WorkSharedTripForm v-if="sharedTripOpen" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="sharedTripBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button :disabled="sharedTripBusy" @click="continueArranging">继续安排其他实物</el-button></template>
|
||||
<template v-else-if="readable"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
|
||||
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
|
||||
</template>
|
||||
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||||
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
||||
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
||||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
|
||||
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||||
</template>
|
||||
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="rejected && pending" :loading="saving" @click="refreshRejected">释放本次草稿并重新核对</el-button>
|
||||
<TripPreparationDialog v-if="result?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="result.overseasWarehouseId" :warehouse-name="result.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[result.dispatchNo]" @saved="tripSaved" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,42 +1,29 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getDispatch, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
import { getDispatch } from '@/modules/overseas-warehouse'
|
||||
const state = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
|
||||
it.each(['customer-b', undefined])('rejects a FBA reference outside the exact batch customer: %s', async customerId => {
|
||||
const value = detail(); value.dispatchInfo.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', recordVersion: 0, sources: [] }]; vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: value.dispatchNo, customerId }]); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
|
||||
function render() { return mount(WorkExistingDispatchForm, { props: { dispatches: [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }], canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
|
||||
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
|
||||
await wrapper.findAll('button').find(button => button.text() === '继续安排本批车辆')!.trigger('click')
|
||||
const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] })
|
||||
dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
})
|
||||
it('reads a shared whole dispatch once and verifies every nested batch and real customer', async () => {
|
||||
const value = detail(); value.dispatchInfo.appointmentBatches = ['a', 'b'].map((customerId, index) => ({ batchKey: `FBA_APPOINTMENT:${index + 1}`, batchNo: `AP-${index + 1}`, customerId, recordVersion: 0, sources: [] })); vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
const wrapper = render(value.dispatchInfo.appointmentBatches.map(row => ({ batchKey: row.batchKey, customerId: row.customerId, dispatchNo: value.dispatchNo }))); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.findAll('tbody tr')).toHaveLength(1); expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], status: 'PENDING_DEPARTURE' })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('resolves the opaque dispatch batch in the work domain and opens the original independent detail', async () => {
|
||||
const wrapper = render(); await flushPromises(); expect(resolveBatchWork).toHaveBeenCalledWith('DISPATCH:9007199254740999', undefined)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FORIGINAL']])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
|
||||
it('opens an already bound real trip instead of preparing another one', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('继续安排本批车辆')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount()
|
||||
})
|
||||
it('rejects mismatched authority or returned business identity instead of deriving an internal ID', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'DISPATCH:9007199254740999', customerId: 'a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-OTHER' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.find('table').exists()).toBe(false); expect(wrapper.text()).toContain('关联不一致'); wrapper.unmount()
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.find('table').exists()).toBe(false); wrong.unmount()
|
||||
})
|
||||
it('opens same-dispatch adding only under the server gate, and keeps already scanned cargo out of the old merge flow', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '为本车添加同仓货物').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props()).toMatchObject({ dispatchNo: 'DS/ORIGINAL', canWrite: true }); wrapper.unmount()
|
||||
const value = detail(); value.stage = 'LOADING'; value.counts.pickedPackageCount = 1; value.actions.ADD_CARGO!.allowed = false; vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const scanned = render(); await flushPromises(); expect(scanned.text()).not.toContain('为本车添加同仓货物'); expect(scanned.text()).not.toContain('共车'); scanned.unmount()
|
||||
})
|
||||
it('does not read dispatch or batch authority without the actual list permissions', async () => {
|
||||
access.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('does not read the dispatch without its original list permission', async () => {
|
||||
state.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,61 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getDispatch, stageLabel, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; ready: [valid: boolean] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchDetail[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const addingNo = ref(''), addingBusy = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list') && (!props.dispatches.some(row => row.batchKey.startsWith('DISPATCH:')) || access.can('platform:appointment:list')))
|
||||
function path(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
function canEdit(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.actions.EDIT?.allowed === true }
|
||||
function canAdd(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.stage === 'BEFORE_LOADING' && row.actions.ADD_CARGO?.allowed === true }
|
||||
let generation = 0
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
|
||||
async function load() {
|
||||
const current = ++generation; emit('ready', false); rows.value = []; error.value = null
|
||||
if (!readable.value) return
|
||||
loading.value = true
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const references = props.dispatches.map(row => ({ ...row }))
|
||||
const details = await Promise.all([...new Set(references.map(row => row.dispatchNo))].map(async no => {
|
||||
const row = await getDispatch(no)
|
||||
if (row.dispatchNo !== no || row.status === 'CANCELLED') throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
for (const reference of references.filter(value => value.dispatchNo === no)) {
|
||||
let matches = false
|
||||
if (/^FBA_APPOINTMENT:\d+$/.test(reference.batchKey)) {
|
||||
matches = Boolean(reference.customerId && row.dispatchInfo.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId))
|
||||
} else if (/^DISPATCH:\d+$/.test(reference.batchKey)) {
|
||||
// The work domain resolves its opaque batch identity; Detail has no internal dispatch ID.
|
||||
const resolution = await resolveBatchWork(reference.batchKey, reference.customerId)
|
||||
matches = resolution.batchKey === reference.batchKey && (resolution.detail
|
||||
? resolution.detail.item.batchKey === reference.batchKey && resolution.detail.item.batchNo === no && (!reference.customerId || resolution.customerId === reference.customerId)
|
||||
: resolution.candidates.length > 0 && resolution.candidates.every(candidate => candidate.batchNo === no) && (!reference.customerId || resolution.candidates.some(candidate => candidate.customerId === reference.customerId)))
|
||||
}
|
||||
if (!matches) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
}
|
||||
rows.value = await Promise.all(props.dispatches.map(async reference => {
|
||||
const row = await getDispatch(reference.dispatchNo)
|
||||
if (`DISPATCH:${row.id}` !== reference.batchKey || row.dispatchNo !== reference.dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
return row
|
||||
}))
|
||||
if (current !== generation) return
|
||||
rows.value = details; emit('ready', details.length > 0)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function add(row: DispatchDetail) { if (canAdd(row) && !loading.value && !error.value && !addingBusy.value) addingNo.value = row.dispatchNo }
|
||||
function updated() { emit('updated'); void load() }
|
||||
watch(() => props.dispatches, load, { immediate: true, deep: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
function prepare(row: DispatchSummaryDto) { if (writable.value && !loading.value && !error.value && row.status === 'PENDING_DEPARTURE' && !row.workflow && row.overseasWarehouseId) { selected.value = row; prepareOpen.value = true } }
|
||||
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3>
|
||||
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
|
||||
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table>
|
||||
<WorkSharedTripForm v-if="addingNo" :key="addingNo" :dispatch-no="addingNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="updated" @external="value => emit('external', value)" />
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">继续安排本批车辆</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
|
||||
<TripPreparationDialog v-if="selected?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="selected.overseasWarehouseId" :warehouse-name="selected.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[selected.dispatchNo]" @saved="saved" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, DispatchWorkflowDialog: { name: 'DispatchWorkflowDialog', props: ['modelValue', 'dispatchNo', 'action'], template: '<div data-add-cargo />' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}<button @click="$emit(\'retry\')">重试</button></p>' }, ApiErrorMeta: true } } }) }
|
||||
function detail(no = 'DS-60', allowed = true) { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, counts: { packageCount: 60 }, vehicleInfo: { vehicle: null, driver: null }, actions: { ADD_CARGO: { allowed, blockCode: allowed ? null : 'PICKED', blockReason: allowed ? null : '货物已开始真实拣货' } } } as DispatchDetail }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()) })
|
||||
it('opens the native add-cargo workflow for the same dispatch instead of asking for another customer dispatch', async () => {
|
||||
const wrapper = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-60'); expect(wrapper.find('input').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('60 箱'); await button(wrapper, '选择同仓未安排货物').trigger('click')
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }); expect(dialog.props()).toMatchObject({ modelValue: true, dispatchNo: 'DS-60', action: 'ADD_CARGO' }); expect(await guard()).toBe(false)
|
||||
dialog.vm.$emit('saved'); await flushPromises(); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true); wrapper.unmount()
|
||||
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
TripPreparationDialog: { name: 'TripPreparationDialog', props: ['modelValue', 'warehouseId', 'dispatchNos'], template: '<div />' },
|
||||
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
} } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockImplementation(async number => ({ id: `internal-${number}`, dispatchNo: number, appointmentBatches: [], customerId: number === 'DS-60' ? 'customer-b' : 'customer-d', overseasWarehouseId: 'warehouse-real', overseasWarehouseName: '实际起运仓', packageCount: number === 'DS-60' ? 60 : 40, status: 'PENDING_DEPARTURE' })) })
|
||||
it('checks exact 60 and 40 dispatch business numbers and passes both to the existing Fleet preparation before navigating to its real trip', async () => {
|
||||
const wrapper = render()
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getDispatch).mock.calls.map(call => call[0])).toEqual(['DS-60', 'DS-40'])
|
||||
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
|
||||
expect(prepare.props()).toMatchObject({ modelValue: true, warehouseId: 'warehouse-real', dispatchNos: ['DS-60', 'DS-40'] })
|
||||
expect(wrapper.text()).toContain('60 箱'); expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).not.toContain('internal-')
|
||||
expect(await guard()).toBe(false)
|
||||
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-SHARED' } }); await flushPromises()
|
||||
expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-SHARED']]); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('honors native stage/gate and permissions and rejects a different returned business number', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail('DS-60', false), stage: 'LOADING' }); const scanned = render(); await flushPromises(); expect(button(scanned, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(scanned.text()).toContain('货物已开始真实拣货'); scanned.unmount()
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount()
|
||||
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
|
||||
it('rejects duplicate, cross-warehouse, already-bound and mismatched dispatches without opening Fleet preparation', async () => {
|
||||
const wrapper = render(); await wrapper.get('input').setValue('DS-60'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).not.toHaveBeenCalled()
|
||||
for (const change of [{ overseasWarehouseId: 'another-warehouse' }, { workflow: { tripNo: 'TR-OTHER', dispatchReview: { status: 'PENDING' as const, reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING' as const, reason: null, reviewedAt: null } } }, { status: 'SHIPPED' }, { dispatchNo: 'WRONG' }]) {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ id: 'a', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE' }).mockResolvedValueOnce({ id: 'b', dispatchNo: 'DS-40', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE', ...change })
|
||||
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false); expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(await guard()).toBe(false)
|
||||
await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
expect(recovery.pending.value?.body.idempotencyKey).toBe('original-key'); wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('update:modelValue', false); await flushPromises(); expect(await guard()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('retries lookup as a read and keeps the original detail navigation', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
await button(wrapper, '查看原排车单').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS-60']]); wrapper.unmount()
|
||||
it('keeps a failed lookup retry read-only and respects original permissions', async () => {
|
||||
const wrapper = render(); vi.mocked(getDispatch).mockRejectedValueOnce(new Error('读取失败'))
|
||||
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false)
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(4); expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props('dispatchNos')).toEqual(['DS-60', 'DS-40'])
|
||||
wrapper.unmount(); access.allowed = false
|
||||
const readonly = render(); await readonly.get('button').trigger('click'); expect(getDispatch).toHaveBeenCalledTimes(4); readonly.unmount()
|
||||
})
|
||||
|
||||
@@ -1,51 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { DispatchWorkflowDialog, DispatchInputError, getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const detail = ref<DispatchDetail | null>(null), open = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const allowed = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.actions.ADD_CARGO?.allowed === true)
|
||||
const busy = computed(() => loading.value || open.value || Boolean(recovery.pending.value))
|
||||
watch(busy, value => emit('busy', value), { immediate: true })
|
||||
useWorkDraft(computed(() => false), busy)
|
||||
const access = useAccessPreviewStore(), otherNo = ref(''), rows = ref<DispatchSummaryDto[]>([])
|
||||
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), prepareOpen = ref(false)
|
||||
const allowed = computed(() => props.canWrite && ['platform:overseas-warehouse:dispatch:list', 'platform:fleet:trip:list', 'platform:fleet:trip:edit'].every(permission => access.can(permission)))
|
||||
watch(prepareOpen, value => emit('busy', value))
|
||||
useWorkDraft(computed(() => false), prepareOpen)
|
||||
let generation = 0
|
||||
async function load() {
|
||||
const current = ++generation; detail.value = null; error.value = null
|
||||
if (!readable.value) return
|
||||
loading.value = true
|
||||
function changed() { ++generation; loading.value = false; rows.value = []; error.value = null }
|
||||
async function prepare() {
|
||||
if (!allowed.value || loading.value || prepareOpen.value) return
|
||||
rows.value = []; error.value = null
|
||||
const other = otherNo.value.trim()
|
||||
if (!other || other === props.dispatchNo) { error.value = { message: '请填写另一张已备好的排车单业务号,不能重复选择本单。' }; return }
|
||||
const current = ++generation; loading.value = true
|
||||
try {
|
||||
const value = await getDispatch(props.dispatchNo)
|
||||
const numbers = [props.dispatchNo, other]
|
||||
const details = await Promise.all(numbers.map(getDispatch))
|
||||
if (current !== generation) return
|
||||
if (value.dispatchNo !== props.dispatchNo) throw new DispatchInputError('读取结果与原排车单不一致,请重新核对')
|
||||
detail.value = value
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
|
||||
if (details.some((row, index) => row.dispatchNo !== numbers[index])) throw new Error('返回的排车单与所选业务号不一致,请重新核对。')
|
||||
if (details.some(row => row.status !== 'PENDING_DEPARTURE' || row.workflow)) throw new Error('请选择尚未关联车次的待发运排车单;已关联的货物请从原车次继续处理。')
|
||||
if (!details[0]!.overseasWarehouseId || details.some(row => row.overseasWarehouseId !== details[0]!.overseasWarehouseId)) throw new Error('两张排车单必须有明确且相同的起运仓。')
|
||||
rows.value = details; prepareOpen.value = true
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '共车排车单核对失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) open.value = true }
|
||||
function recover() {
|
||||
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
|
||||
}
|
||||
function saved() { open.value = false; emit('updated'); void load() }
|
||||
watch(() => props.dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
onBeforeUnmount(() => { ++generation; emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="为原排车添加同仓货物">
|
||||
<h4>为原排车添加同仓货物</h4><p>本车 {{ dispatchNo }}。只选择尚未安排的真实货物加入本车,不合并另一张排车单或搬迁已扫码货物。</p>
|
||||
<p>箱数无法对应完整实物时,先在原库存作业完成拆托;预约分批不代替实物拆托。</p>
|
||||
<LoadErrorState v-if="error" title="本车加货资格读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
|
||||
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
||||
<p v-if="recovery.pending.value">原排车请求尚未核对,不能另发加货命令。<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">核对原加货请求</el-button></p>
|
||||
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" />
|
||||
<section class="work-form" aria-label="与同仓其他排车单共车">
|
||||
<h4>与同仓其他排车单共车</h4>
|
||||
<p>本批排车单:{{ dispatchNo }}。请先按各客户实际托盘分别备好排车单,再核对同仓两单并安排同一车次。</p>
|
||||
<p>所需箱数无法对应完整实物时,请先在原库存作业中完成拆托,再返回核对;分批计划不会代替实物拆托。</p>
|
||||
<label>另一张排车单号<input v-model="otherNo" aria-label="共车排车单号" maxlength="64" :disabled="loading || prepareOpen || !allowed" @input="changed" @keydown.enter.prevent="prepare"></label>
|
||||
<el-button :disabled="!allowed || prepareOpen" :loading="loading" @click="prepare">核对两单并安排共车</el-button>
|
||||
<LoadErrorState v-if="error" title="共车排车单核对失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-for="row in rows" :key="row.dispatchNo">{{ row.dispatchNo }} · {{ row.overseasWarehouseName || '起运仓名称待核实' }} · {{ row.packageCount ?? '箱数待核实' }}<span v-if="row.packageCount != null"> 箱</span></p>
|
||||
<TripPreparationDialog v-if="rows[0]?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="rows[0].overseasWarehouseId" :warehouse-name="rows[0].overseasWarehouseName || '起运仓名称待核实'" :dispatch-nos="rows.map(row => row.dispatchNo)" @saved="saved" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -2,17 +2,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
|
||||
|
||||
describe('appointment calendar', () => {
|
||||
it.each([
|
||||
['2026-10-19', '2026-09-28', '2026-11-02', 35],
|
||||
['2027-01-15', '2026-12-28', '2027-02-01', 35],
|
||||
['2027-02-15', '2027-02-01', '2027-03-01', 28],
|
||||
['2026-08-15', '2026-07-27', '2026-09-07', 42],
|
||||
] as const)('shows only the complete weeks needed for %s', (anchor, from, to, count) => {
|
||||
const dates = calendarDates(anchor, 'month')
|
||||
expect(dates).toHaveLength(count)
|
||||
expect(dates[0]).toBe(from)
|
||||
expect(dates.at(-1)).toBe(addDays(to, -1))
|
||||
expect(calendarRange(anchor, 'month')).toEqual({ from, to })
|
||||
it('shows six complete Monday-first weeks across year boundaries', () => {
|
||||
const dates = calendarDates('2027-01-15', 'month')
|
||||
expect(dates).toHaveLength(42)
|
||||
expect(dates[0]).toBe('2026-12-28')
|
||||
expect(dates.at(-1)).toBe('2027-02-07')
|
||||
expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
|
||||
})
|
||||
|
||||
it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
|
||||
|
||||
@@ -29,19 +29,16 @@ export function addDays(value: string, days: number): string {
|
||||
|
||||
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
|
||||
const date = parseDate(anchor)
|
||||
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1, 12)
|
||||
if (mode === 'month') date.setDate(1)
|
||||
date.setDate(date.getDate() - (date.getDay() + 6) % 7)
|
||||
const from = dateKey(date)
|
||||
// 月视图只补齐月末所在周,不追加整周的下月日期;结束日期仍为开区间。
|
||||
return { from, to: mode === 'month' ? addDays(dateKey(nextMonth), (8 - nextMonth.getDay()) % 7) : addDays(from, 7) }
|
||||
// A half-open interval also works directly with database timestamps.
|
||||
return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
|
||||
}
|
||||
|
||||
export function calendarDates(anchor: string, mode: CalendarMode): string[] {
|
||||
const { from, to } = calendarRange(anchor, mode)
|
||||
const dates: string[] = []
|
||||
for (let date = from; date < to; date = addDays(date, 1)) dates.push(date)
|
||||
return dates
|
||||
const { from } = calendarRange(anchor, mode)
|
||||
return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
|
||||
}
|
||||
|
||||
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPlanningCargo, listPlanningDestinations, listPlanningContainers, resolvePlanningOrder } from './planningApi'
|
||||
import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
|
||||
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
|
||||
@@ -20,15 +20,6 @@ it('keeps basic container listing on its own endpoint rather than requiring impa
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
it('uses authoritative destination options and sends the selected destination to server pagination', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValueOnce({data:['ABE4','ONT8']})
|
||||
expect(await listPlanningDestinations()).toEqual(['ABE4','ONT8'])
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/cargo/destinations',{signal:undefined})
|
||||
vi.mocked(http.get).mockResolvedValueOnce({data:{records:[],page:2,size:20,total:0,pages:0}})
|
||||
await listPlanningCargo({page:2,size:20,keyword:'IN-A',destination:'ONT8'})
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-planning/cargo',{params:{page:2,size:20,keyword:'IN-A',destination:'ONT8'},signal:undefined})
|
||||
})
|
||||
it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}})
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface PlanningCargo {
|
||||
mixedDestinations: boolean
|
||||
}
|
||||
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
|
||||
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string; destination?: string }, signal?: AbortSignal) {
|
||||
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
|
||||
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
|
||||
)
|
||||
@@ -39,12 +39,6 @@ export async function listPlanningCargo(query: { page: number; size: number; key
|
||||
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
|
||||
}
|
||||
|
||||
export async function listPlanningDestinations(signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
|
||||
'/platform/v1/last-leg/appointment-planning/cargo/destinations', { signal },
|
||||
))
|
||||
}
|
||||
|
||||
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
|
||||
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
|
||||
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record<FollowupStage, number> }
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectContainerSchedule } from './schedule'
|
||||
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
|
||||
const range = { from: '2026-11-01', to: '2026-12-01' }
|
||||
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
describe('four-type schedule projection', () => {
|
||||
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
|
||||
})
|
||||
it('adds two local calendar days across months without creating a time', () => {
|
||||
const events = projectContainerSchedule(sources, [warehouse], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
|
||||
})
|
||||
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
|
||||
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
|
||||
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
|
||||
})
|
||||
it('keeps manually moved containers visible when their arrival is outside this month', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.date).toBe('2026-11-08')
|
||||
})
|
||||
it('does not replace the planned date with PDA actual arrival', () => {
|
||||
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
|
||||
expect(event.date).toBe('2026-11-05')
|
||||
expect(event.canEdit).toBe(false)
|
||||
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
|
||||
})
|
||||
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
|
||||
expect(events.map(row => row.date)).toEqual([null, null])
|
||||
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
|
||||
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
|
||||
})
|
||||
it('does not invent default plans when the authoritative warehouse query failed', () => {
|
||||
expect(projectContainerSchedule(sources, null, range, true)).toEqual([])
|
||||
})
|
||||
it('uses the actual return date after completion while preserving the deadline in details', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true)
|
||||
expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' })
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { addDays } from './calendar'
|
||||
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
||||
|
||||
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
|
||||
export type ScheduleType = typeof scheduleTypes[number]
|
||||
export const scheduleLabels: Record<ScheduleType, string> = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' }
|
||||
export interface ScheduleEvent {
|
||||
key: string
|
||||
type: ScheduleType
|
||||
date: string | null
|
||||
time: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
sourcePath?: string
|
||||
warehouse?: WarehouseArrival
|
||||
canEdit?: boolean
|
||||
}
|
||||
const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to
|
||||
const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—'
|
||||
|
||||
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
|
||||
const events: ScheduleEvent[] = []
|
||||
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
|
||||
for (const row of sources.containers) {
|
||||
const label = row.containerNo || row.cabinetNo || '—'
|
||||
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
|
||||
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
|
||||
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
|
||||
const date = row.arrivalAt?.slice(0, 10) || null
|
||||
if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date,
|
||||
time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath,
|
||||
subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定',
|
||||
fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] })
|
||||
// 读取失败时不能用默认值遮掉无法读取的人工安排。
|
||||
if (warehouses === null) continue
|
||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||
warehouseMap.delete(row.containerBookingId)
|
||||
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
|
||||
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
|
||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
||||
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||
canEdit: warehouse?.canEdit === true,
|
||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
||||
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
||||
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
||||
}
|
||||
// 人工改期可能跨月,与当前月份的到港记录无关。
|
||||
for (const row of warehouseMap.values()) {
|
||||
const date = row.plannedAt?.slice(0, 10) || null
|
||||
if (!date || !inRange(date, range)) continue
|
||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
|
||||
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
|
||||
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
|
||||
}
|
||||
for (const row of sources.emptyReturns) {
|
||||
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
|
||||
if (!at || !inRange(at.slice(0, 10), range)) continue
|
||||
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
|
||||
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
|
||||
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
|
||||
{ label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] })
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface CalendarContainer {
|
||||
containerBookingId: string
|
||||
cabinetNo: string | null
|
||||
containerNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
destinationPortName: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
arrivalAt: string | null
|
||||
arrivalType: 'ACTUAL' | 'ESTIMATED'
|
||||
}
|
||||
export interface CalendarReturn {
|
||||
containerBookingId: string
|
||||
containerNo: string | null
|
||||
fleetSupplierName: string | null
|
||||
actualEmptyReturnAt: string | null
|
||||
emptyReturnWarning: { status: string; deadlineAt: string | null }
|
||||
}
|
||||
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
|
||||
export interface WarehouseArrival {
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
warehouseName: string | null
|
||||
warehouseCode: string | null
|
||||
plannedAt: string | null
|
||||
actualAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
canEdit: boolean
|
||||
}
|
||||
export interface ScheduleRange { from: string; to: string }
|
||||
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
|
||||
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
|
||||
))
|
||||
}
|
||||
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
|
||||
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
|
||||
))
|
||||
}
|
||||
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
|
||||
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
|
||||
return unwrapApiResponse(await http.put<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body,
|
||||
{ headers: { 'Idempotency-Key': key } },
|
||||
))
|
||||
}
|
||||
@@ -7,13 +7,6 @@ function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'Amer
|
||||
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('counts a shared trip once and excludes cancelled arrangements', () => {
|
||||
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
|
||||
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
|
||||
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
|
||||
expect(result.events).toHaveLength(1)
|
||||
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
|
||||
})
|
||||
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
|
||||
const localRange = {from:'2026-09-28',to:'2026-11-09'}
|
||||
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
|
||||
export interface TripCalendarEntry {
|
||||
sourceType: 'TRIP_PLAN'
|
||||
@@ -54,10 +54,9 @@ function localStamp(value: string | null, zone: string | null) {
|
||||
} catch { return null }
|
||||
}
|
||||
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
|
||||
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
|
||||
for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
|
||||
if (row.status === 'CANCELLED') continue
|
||||
for (const row of result.records) {
|
||||
// Executed work stays on its actual local date even if the plan changes later.
|
||||
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
|
||||
if (row.originCountryCode !== 'US' || !occurrence) {
|
||||
@@ -66,8 +65,8 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
|
||||
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
|
||||
fields: [
|
||||
{ label: '车次状态', value: labels[row.status] || '待核实' },
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const pending = () => new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409, undefined, 'request-sync', 'trace-sync')
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('waits only for the full-work synchronization guard and returns the fresh result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValueOnce(pending()).mockResolvedValue('fresh')
|
||||
const result = reader.read(load, new AbortController().signal)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(reader.syncing.value).toBe(true)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(await result).toBe('fresh')
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it.each([new ApiResponseError('事项版本已变化', 'A0409', 409), new ApiResponseError('无权查看', 'A0403', 403), new Error('network')])('does not retry other failures: %s', async error => {
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
await expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it('stops waiting at the limit and preserves diagnostic IDs on the original error', async () => {
|
||||
vi.useFakeTimers()
|
||||
const error = pending(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
const result = expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
await result
|
||||
expect(load).toHaveBeenCalledTimes(16)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(error.traceId).toBe('trace-sync')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
it('cancels a waiting read without sending another request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(pending())
|
||||
const result = expect(reader.read(load, controller.signal)).rejects.toHaveProperty('name', 'AbortError')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.abort()
|
||||
await result
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
|
||||
// 只等待工作清单同步守卫;版本冲突、权限失败和写请求均不重试。
|
||||
export function useWorkSyncRead() {
|
||||
const syncing = ref(false)
|
||||
let generation = 0
|
||||
async function read<T>(load: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
const current = ++generation
|
||||
syncing.value = false
|
||||
try {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
signal.throwIfAborted()
|
||||
try { return await load() }
|
||||
catch (cause) {
|
||||
const error = getApiErrorDetails(cause)
|
||||
if (error.code !== 'A0409' || error.message !== '预约批次或运输关联已变化,服务端正在协调完整工作清单' || attempt >= 15) throw cause
|
||||
if (current === generation) syncing.value = true
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => { clearTimeout(timer); reject(signal.reason) }
|
||||
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 2000)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) { signal.removeEventListener('abort', abort); abort() }
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally { if (current === generation) syncing.value = false }
|
||||
}
|
||||
return { syncing, read }
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
@@ -387,7 +386,7 @@ onMounted(load)
|
||||
<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 || '请刷新计划获取最新作业状态。'" />
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案 <InlineHelp title="装柜说明"><p>智能建议必须经人工核对。</p><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></InlineHelp></h2></div></template>
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
|
||||
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
@@ -397,7 +396,7 @@ onMounted(load)
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2></div></template>
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
|
||||
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
|
||||
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
|
||||
@@ -72,7 +72,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section class="overseas-fee-panel admin-density customer-density">
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load">重新加载</el-button></div>
|
||||
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
|
||||
<template v-else-if="config">
|
||||
@@ -88,7 +88,7 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||||
.edit-group-heading h3 { min-width:0; }
|
||||
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
|
||||
@@ -1083,7 +1083,7 @@ onMounted(() => { void loadCustomers() })
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<FormSection title="基础资料"><div class="form-grid three-cols">
|
||||
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid three-cols">
|
||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item>
|
||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||
@@ -1093,7 +1093,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
|
||||
|
||||
<FormSection title="联系与商务"><div class="form-grid three-cols">
|
||||
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid three-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="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>
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('DashboardView', () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.dashboard-context > p').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
|
||||
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
|
||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.dashboard-metric p').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数'])
|
||||
@@ -77,7 +77,7 @@ describe('DashboardView', () => {
|
||||
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getTodayNewOrderCount).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('.dashboard-context > p').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
|
||||
expect(wrapper.text()).toContain('12 票')
|
||||
})
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<template>
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<section class="dashboard-context" aria-label="数据看板操作">
|
||||
<p v-if="!hasMetrics || metricsError">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
|
||||
<section class="dashboard-context" aria-label="数据看板说明">
|
||||
<p>{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
@@ -119,7 +119,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
|
||||
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
|
||||
.dashboard-head-actions { margin-left: auto; display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||
.dashboard-outline-button { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
||||
.dashboard-outline-button:hover:not(:disabled) { border-color: var(--cc-primary); color: var(--cc-primary); }
|
||||
.dashboard-outline-button:disabled { cursor: wait; opacity: .65; }
|
||||
|
||||
@@ -21,10 +21,10 @@ import './domesticContainersSummary.css'
|
||||
|
||||
type TabName = 'inbound' | 'outbound' | 'inventory'
|
||||
const router = useRouter(); const access = useAccessPreviewStore(); const activeTab = ref<TabName>('inbound')
|
||||
const businessTabs: Array<{ value: TabName; label: string }> = [
|
||||
{ value: 'inbound', label: '整柜入库单' },
|
||||
{ value: 'outbound', label: '整柜出库单' },
|
||||
{ value: 'inventory', label: '整柜库存' },
|
||||
const businessTabs: Array<{ value: TabName; label: string; icon: string }> = [
|
||||
{ value: 'inbound', label: '整柜入库单', icon: '📥' },
|
||||
{ value: 'outbound', label: '整柜出库单', icon: '📤' },
|
||||
{ value: 'inventory', label: '整柜库存', icon: '📦' },
|
||||
]
|
||||
const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 })
|
||||
@@ -76,7 +76,7 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
||||
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
|
||||
<template #toolbar-start><h1>整柜业务</h1></template>
|
||||
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
|
||||
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
|
||||
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# 费用项目默认价格核对
|
||||
|
||||
本轮依据:2026-10-19 用户确认“固定价格,使用的地方也能修改,只是带入”。原型入口为 `docs/悦/货代SAAS平台后台/prototype-src/modules/10-general.html:8-12` 的 `page-gen-fee`、`tbody-gen-fee`;原型没有默认价格字段及该页新增/编辑表单定义,新增字段按本轮确认实现。
|
||||
|
||||
| 页面 | 操作 | 原型承载/布局 | 当前实现及本轮结论 |
|
||||
|---|---|---|---|
|
||||
| 费用项目 | 列表 | 页面内表格,原型无价格列 | 沿用列表,增加默认价格列,空值显示“—” |
|
||||
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 沿用现有 620px 抽屉与表单,增加可空默认价格及说明 |
|
||||
| 入库费用 | 新增、编辑、调整时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入单价和金额,支持人工覆盖,回显保留原值 |
|
||||
| 柜次费用 | 新增时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入金额,支持人工覆盖,调整回显保留原值 |
|
||||
|
||||
详情、审核、文件预览、删除和停用操作无变更。自动检查与视觉验收分开;1920×1080、100% 缩放待人工截图确认。
|
||||
@@ -19,7 +19,6 @@ const payload: SavePlatformFeeItemRequest = {
|
||||
category: '仓储成本',
|
||||
tradeScope: 'INTERNATIONAL',
|
||||
feeTypeName: '仓库租赁费',
|
||||
defaultPrice: 60,
|
||||
supplierCategory: 'TRUCKING',
|
||||
pickupPayable: false,
|
||||
pickupReceivable: false,
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface PlatformFeeItemResource {
|
||||
feeCode: string | null
|
||||
supplierCategory: SupplierCategory | null
|
||||
feeTypeName: string
|
||||
defaultPrice: number | null
|
||||
pickupPayable: boolean
|
||||
pickupReceivable: boolean
|
||||
warehousePayable: boolean
|
||||
@@ -29,7 +28,6 @@ export interface SavePlatformFeeItemRequest {
|
||||
category: string
|
||||
tradeScope: FeeItemTradeScope
|
||||
feeTypeName: string
|
||||
defaultPrice: number | null
|
||||
supplierCategory: SupplierCategory | null
|
||||
pickupPayable: boolean
|
||||
pickupReceivable: boolean
|
||||
|
||||
@@ -6,7 +6,7 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { createPlatformFeeItem, listPlatformFeeItems, updatePlatformFeeItem } from '@/modules/fee-items/api/platformFeeItems'
|
||||
import { listPlatformFeeItems } from '@/modules/fee-items/api/platformFeeItems'
|
||||
import FeeItemsView from './FeeItemsView.vue'
|
||||
|
||||
const source = readFileSync(join(process.cwd(), 'src/modules/fee-items/pages/FeeItemsView.vue'), 'utf8').replace(/\r\n/g, '\n')
|
||||
@@ -54,26 +54,6 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('FeeItemsView public master data and access', () => {
|
||||
it('saves configured and zero prices and clears the default on edit', async () => {
|
||||
const wrapper = await mountView(['firstleg:fee-item:view', 'firstleg:fee-item:create', 'firstleg:fee-item:edit'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
openCreate(): void; saveFeeItem(): Promise<void>
|
||||
form: { id: string; category: string; feeTypeName: string; defaultPrice: number | null }
|
||||
} }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
Object.assign(state.form, { category: '操作费', feeTypeName: '加封费', defaultPrice: 60 })
|
||||
await state.saveFeeItem(); await flushPromises()
|
||||
expect(createPlatformFeeItem).toHaveBeenLastCalledWith(expect.objectContaining({ defaultPrice: 60 }))
|
||||
state.openCreate(); await flushPromises()
|
||||
Object.assign(state.form, { category: '操作费', feeTypeName: '零元费', defaultPrice: 0 })
|
||||
await state.saveFeeItem(); await flushPromises()
|
||||
expect(createPlatformFeeItem).toHaveBeenLastCalledWith(expect.objectContaining({ defaultPrice: 0 }))
|
||||
state.openCreate(); await flushPromises()
|
||||
Object.assign(state.form, { id: '1', category: '操作费', feeTypeName: '加封费', defaultPrice: null })
|
||||
await state.saveFeeItem(); await flushPromises()
|
||||
expect(updatePlatformFeeItem).toHaveBeenCalledWith('1', expect.objectContaining({ defaultPrice: null }))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
|
||||
@@ -71,7 +71,6 @@ function emptyForm(): FeeItemForm {
|
||||
category: '',
|
||||
tradeScope: 'DOMESTIC',
|
||||
feeTypeName: '',
|
||||
defaultPrice: null,
|
||||
supplierCategory: null,
|
||||
pickupPayable: false,
|
||||
pickupReceivable: false,
|
||||
@@ -87,7 +86,6 @@ const rules: FormRules<FeeItemForm> = {
|
||||
category: [{ required: true, message: '请输入费用分类', trigger: 'blur' }],
|
||||
tradeScope: [{ required: true, message: '请选择贸易范围', trigger: 'change' }],
|
||||
feeTypeName: [{ required: true, message: '请输入费用名称', trigger: 'blur' }],
|
||||
defaultPrice: [{ type: 'number', min: 0, max: 9999999999.99, message: '默认价格须为非负金额,最多两位小数', trigger: 'change' }],
|
||||
}
|
||||
const hasPayableDirection = computed(() => form.pickupPayable || form.warehousePayable)
|
||||
|
||||
@@ -120,7 +118,6 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'feeTypeName', label: '费用项目', minWidth: 210, slotName: 'feeItem' },
|
||||
{ key: 'category', prop: 'category', label: '费用分类', minWidth: 150 },
|
||||
{ key: 'defaultPrice', label: '默认价格(CNY)', width: 150, formatter: (row: PlatformFeeItemResource) => row.defaultPrice == null ? '—' : Number(row.defaultPrice).toFixed(2) },
|
||||
{ key: 'tradeScope', label: '贸易范围', width: 110, slotName: 'tradeScope' },
|
||||
{ key: 'pickup', label: '提货', minWidth: 160, slotName: 'pickup' },
|
||||
{ key: 'warehouse', label: '仓库', minWidth: 160, slotName: 'warehouse' },
|
||||
@@ -206,7 +203,6 @@ function assignForm(item: PlatformFeeItemResource) {
|
||||
category: item.category,
|
||||
tradeScope: item.tradeScope,
|
||||
feeTypeName: item.feeTypeName,
|
||||
defaultPrice: item.defaultPrice,
|
||||
supplierCategory: item.supplierCategory,
|
||||
pickupPayable: item.pickupPayable,
|
||||
pickupReceivable: item.pickupReceivable,
|
||||
@@ -240,7 +236,6 @@ function buildPayload(): SavePlatformFeeItemRequest {
|
||||
category: form.category.trim(),
|
||||
tradeScope: form.tradeScope,
|
||||
feeTypeName: form.feeTypeName.trim(),
|
||||
defaultPrice: form.defaultPrice ?? null,
|
||||
supplierCategory: hasPayableDirection.value ? form.supplierCategory : null,
|
||||
pickupPayable: form.pickupPayable,
|
||||
pickupReceivable: form.pickupReceivable,
|
||||
@@ -437,7 +432,7 @@ onMounted(() => {
|
||||
:confirm-loading="saving"
|
||||
>
|
||||
<el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
|
||||
<FormSection class="density-form-section" title="基本信息">
|
||||
<FormSection class="density-form-section" title="基本信息" description="费用项目为平台公共主数据,不按客户单独维护。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item>
|
||||
<el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item>
|
||||
@@ -445,10 +440,6 @@ onMounted(() => {
|
||||
<el-form-item label="贸易范围" prop="tradeScope" required>
|
||||
<el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认价格(CNY)" prop="defaultPrice">
|
||||
<el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" />
|
||||
</el-form-item>
|
||||
<p class="muted-value">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</p>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="费用方向" description="选择费用在提货和仓库环节的应收、应付属性。">
|
||||
<div class="direction-grid">
|
||||
|
||||
@@ -431,7 +431,7 @@ onMounted(loadInitialData)
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<el-form class="density-form" ref="formRef" :model="form" :rules="formRules" label-position="top">
|
||||
<FormSection class="density-form-section" title="用户资料">
|
||||
<FormSection class="density-form-section" title="用户资料" description="维护登录身份、联系方式与平台角色。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="用户名" prop="username" required>
|
||||
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
|
||||
|
||||
@@ -264,7 +264,7 @@ onMounted(loadRoles)
|
||||
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="role-editor-form density-form">
|
||||
<div class="role-editor-layout">
|
||||
<FormSection title="基本信息" class="role-editor-basic density-form-section">
|
||||
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。" class="role-editor-basic density-form-section">
|
||||
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
|
||||
|
||||
@@ -32,7 +32,7 @@ type FilterModel = QueryModel & {
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
|
||||
const props = defineProps<{ mode: InboundTaskMode }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
@@ -356,9 +356,8 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
:aria-label="`${taskLabel}待办列表`"
|
||||
@selection-change="handleReviewSelectionChange"
|
||||
>
|
||||
<template #toolbar-start><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></template>
|
||||
<template #toolbar-start><h1>{{ isReview ? '测量数据审核' : '订单测量' }}</h1></template>
|
||||
<template #filters>
|
||||
<slot name="status-tabs" />
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
:ariaLabel="`${taskLabel}待办筛选`"
|
||||
|
||||
@@ -107,6 +107,7 @@ onBeforeUnmount(() => {
|
||||
<h2>入库码:{{ title }}</h2>
|
||||
<StatusBadge v-if="detail" :status="statusLabel" />
|
||||
</div>
|
||||
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" :disabled="!customerId || !inboundId" @click="refreshWorkspace">
|
||||
<RefreshCw :size="16" />刷新
|
||||
@@ -234,6 +235,7 @@ onBeforeUnmount(() => {
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
|
||||
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
|
||||
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.workspace-state { margin: 24px; }
|
||||
.workspace-skeleton { padding: 0; }
|
||||
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Check, CircleDollarSign, PencilLine, RefreshCw, Send } from '@lucide/vue'
|
||||
@@ -380,11 +379,12 @@ defineExpose({ reload: loadFee })
|
||||
<template>
|
||||
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
|
||||
<header class="fee-heading" :class="{ 'fee-heading--compact': compact }">
|
||||
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
|
||||
<h3 v-if="compact" id="inbound-fee-title" class="fee-heading-title--compact">头程自动初算</h3>
|
||||
<div v-else class="fee-heading-copy">
|
||||
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
|
||||
<div>
|
||||
<h3 id="inbound-fee-title">入库费用快照 <InlineHelp title="计费说明"><p>测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p><p>实际重量与体积重量取大后计费,金额以服务端快照为准。</p></InlineHelp></h3>
|
||||
<h3 id="inbound-fee-title">入库费用快照</h3>
|
||||
<p>测量审核后的头程费用,人工确认后才发布。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fee-heading-actions">
|
||||
@@ -470,6 +470,7 @@ defineExpose({ reload: loadFee })
|
||||
</el-alert>
|
||||
|
||||
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
|
||||
<p class="empty-note">测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p>
|
||||
</el-empty>
|
||||
|
||||
<div v-else-if="snapshot" class="fee-content">
|
||||
@@ -503,7 +504,7 @@ defineExpose({ reload: loadFee })
|
||||
|
||||
<section class="calculation-section" aria-labelledby="calculation-title">
|
||||
<div class="section-title">
|
||||
<div><h4 id="calculation-title">计费依据</h4></div>
|
||||
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
|
||||
<div class="calculation-tags">
|
||||
<el-tag effect="plain">订单报关:{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
|
||||
<el-tag effect="plain">报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }}</el-tag>
|
||||
|
||||
@@ -3,7 +3,6 @@ export { createInboundHoldIdempotencyKey, downloadInboundCartonLabelsBatch, down
|
||||
export { default as DropshipPalletPrintPanel } from './core/DropshipPalletPrintPanel.vue'
|
||||
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
|
||||
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
|
||||
export { default as InboundTaskList } from './components/InboundTaskList.vue'
|
||||
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
|
||||
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
|
||||
export {
|
||||
|
||||
@@ -922,7 +922,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
.measurement-comparison thead th { background: #f8fafc; }
|
||||
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
|
||||
.measurement-comparison thead th:nth-child(3) { background: #f4fbf8; color: #18795d; }
|
||||
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
|
||||
.measurement-comparison__difference { color: var(--el-color-warning-dark-2); font-weight: 600; }
|
||||
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.measurement-extra-facts strong { color: var(--cc-text); }
|
||||
.measurement-panel { overflow: hidden; }
|
||||
@@ -947,7 +947,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) 20px; }
|
||||
.measurement-summary-header span { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
|
||||
.measurement-deviation-alert { --el-color-warning: var(--el-color-danger-dark-2); --el-color-warning-dark-2: var(--el-color-danger-dark-2); margin: 0; width: auto; }
|
||||
.measurement-deviation-alert { margin: 0; width: auto; }
|
||||
.measurement-deviation-alert p { margin: 3px 0 0; line-height: 1.55; }
|
||||
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2, 6px); margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
|
||||
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
|
||||
import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||
|
||||
describe('container fee API', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
it('retains configured, zero and empty default prices when loading fee choices', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { records: [
|
||||
{ id: 1, feeTypeName: '操作费', defaultPrice: '60.25' },
|
||||
{ id: 2, feeTypeName: '零元费', defaultPrice: 0 },
|
||||
{ id: 3, feeTypeName: '手工费', defaultPrice: null },
|
||||
] } })
|
||||
await expect(listApplicableFeeItems()).resolves.toMatchObject([
|
||||
{ id: '1', defaultPrice: 60.25 }, { id: '2', defaultPrice: 0 }, { id: '3', defaultPrice: null },
|
||||
])
|
||||
})
|
||||
it('passes accountId and exact controller DTOs', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, loadingPlanId: 9, containerBookingId: 99, feeItemId: 7, feeItemNameSnapshot: '操作费', direction: 'PAYABLE', supplierId: 41, supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-7', paymentVoucherFileIds: [9001], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } })
|
||||
|
||||
@@ -84,7 +84,6 @@ export interface ContainerFeeAllocationGroupAdjustRequest {
|
||||
}
|
||||
|
||||
export interface ApplicableFeeItem {
|
||||
defaultPrice: number | null
|
||||
id: string
|
||||
name: string
|
||||
feeCode: string
|
||||
@@ -225,7 +224,7 @@ export async function voidContainerFee(planId: string, accountId: string, feeId:
|
||||
|
||||
export async function listApplicableFeeItems() {
|
||||
const response = await http.get<ApiEnvelope<{ records: Record<string, unknown>[] }>, ApiEnvelope<{ records: Record<string, unknown>[] }>>('/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } })
|
||||
return (unwrapApiResponse(response).records || []).map((raw): ApplicableFeeItem => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), defaultPrice: raw.defaultPrice == null ? null : Number(raw.defaultPrice), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
|
||||
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
|
||||
}
|
||||
|
||||
export async function listContainerFeeSupplierOptions(category: SupplierCategory | null): Promise<ContainerFeeSupplierOption[]> {
|
||||
|
||||
@@ -39,90 +39,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('ContainerFeesPanel contract', () => {
|
||||
it.each(['success', 'failure'])('ignores a closed editor request that finishes late: %s', async (outcome) => {
|
||||
const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
|
||||
let resolveOld!: (items: typeof item[]) => void
|
||||
let rejectOld!: (reason: Error) => void
|
||||
vi.mocked(listApplicableFeeItems).mockImplementationOnce(() => new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject }))
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValueOnce([{ ...item, defaultPrice: 80 }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
openCreate(): void
|
||||
editorOpen: boolean
|
||||
form: { amount: number }
|
||||
feeItems: typeof item[]
|
||||
feeItemsLoading: boolean
|
||||
feeItemsError: unknown
|
||||
} }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
state.editorOpen = false; await flushPromises()
|
||||
state.openCreate(); await flushPromises()
|
||||
expect(state.feeItems[0]?.defaultPrice).toBe(80)
|
||||
if (outcome === 'success') resolveOld([item])
|
||||
else rejectOld(new Error('旧窗口请求失败'))
|
||||
await flushPromises()
|
||||
expect(state.feeItemsError).toBeNull()
|
||||
expect(state.feeItemsLoading).toBe(false)
|
||||
const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
|
||||
select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7')
|
||||
await flushPromises()
|
||||
expect(state.form.amount).toBe(80)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not let an old completion clear the current loading state or refill a closed editor', async () => {
|
||||
const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
|
||||
let resolveOld!: (items: typeof item[]) => void
|
||||
let resolveCurrent!: (items: typeof item[]) => void
|
||||
vi.mocked(listApplicableFeeItems)
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve }))
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveCurrent = resolve }))
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
openCreate(): void; editorOpen: boolean; feeItems: unknown[]; feeItemsLoading: boolean; feeItemsError: unknown
|
||||
} }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
state.editorOpen = false
|
||||
state.openCreate(); await flushPromises()
|
||||
resolveOld([item]); await flushPromises()
|
||||
expect(state.feeItems).toEqual([])
|
||||
expect(state.feeItemsLoading).toBe(true)
|
||||
state.editorOpen = false; await flushPromises()
|
||||
resolveCurrent([{ ...item, defaultPrice: 80 }]); await flushPromises()
|
||||
expect(state.feeItems).toEqual([])
|
||||
expect(state.feeItemsLoading).toBe(false)
|
||||
expect(state.feeItemsError).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('prefills the selected default, clears stale prices and retains manual overrides', async () => {
|
||||
const base = { name: '操作费', feeCode: 'OPERATION', supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([
|
||||
{ ...base, id: '7', defaultPrice: 60 }, { ...base, id: '8', defaultPrice: null }, { ...base, id: '9', defaultPrice: 0 },
|
||||
])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
openCreate(): void; save(): Promise<void>
|
||||
resetForm(row: Record<string, unknown>): void
|
||||
form: { feeItemId: string; amount: number }
|
||||
} }).setupState
|
||||
state.resetForm({ feeItemId: '7', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 100 })
|
||||
await flushPromises()
|
||||
expect(state.form.amount).toBe(100)
|
||||
state.openCreate(); await flushPromises()
|
||||
const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((item) => item.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
|
||||
for (const [id, price] of [['7', 60], ['8', 0], ['9', 0]] as const) {
|
||||
select.vm.$emit('update:modelValue', id); select.vm.$emit('change', id)
|
||||
await flushPromises()
|
||||
expect(state.form.amount).toBe(price)
|
||||
}
|
||||
select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7')
|
||||
await flushPromises()
|
||||
state.form.amount = 75
|
||||
await state.save(); await flushPromises()
|
||||
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ feeItemId: '7', amount: 75 }))
|
||||
})
|
||||
|
||||
it('keeps fee controls and table utilities in compact aligned toolbars', () => {
|
||||
expect(panel).toContain('class="fees-header__actions"')
|
||||
expect(panel).toContain('class="customer-context"')
|
||||
@@ -263,7 +179,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
})
|
||||
|
||||
it('keeps receivable usable when supplier permission is missing', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; supplierId: string | null } } }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
@@ -274,7 +190,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
})
|
||||
|
||||
it('isolates a supplier load failure and clears supplier fields for receivable', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockRejectedValue(new Error('suppliers unavailable'))
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; feeItemId: string; supplierId: string | null }; feeItems: unknown[] } }).setupState
|
||||
@@ -287,7 +203,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
})
|
||||
|
||||
it('submits only the selected supplier ID for payable fees', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
|
||||
@@ -299,7 +215,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
})
|
||||
|
||||
it('allows any loaded ACTIVE supplier when the payable fee item has no supplier category', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', defaultPrice: null, supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
|
||||
@@ -311,7 +227,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
})
|
||||
|
||||
it('fails closed when a supplier candidate does not match the fee-item category', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '拖车费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
|
||||
@@ -333,7 +249,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
|
||||
it('clears paid inputs for RECEIVABLE and ON_ACCOUNT and shows backend settlement semantics', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '已付拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-1')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState
|
||||
@@ -343,7 +259,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
})
|
||||
|
||||
it('fails closed without DIRECT_PAID evidence and submits a scoped uploaded voucher', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; selectVoucher(event: Event): Promise<void>; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
|
||||
|
||||
@@ -55,7 +55,6 @@ const voucherUploading = ref(false)
|
||||
const voucherError = ref<ApiErrorDetails | null>(null)
|
||||
const rejection = ref('')
|
||||
let loadSequence = 0
|
||||
let feeItemLoadSequence = 0
|
||||
let supplierLoadSequence = 0
|
||||
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
@@ -123,11 +122,6 @@ function clearPaidFields() {
|
||||
form.paidAt = null; form.paymentMethod = null; form.paymentReferenceNo = null; form.paymentVoucherFileIds = null
|
||||
voucherFiles.value = []; voucherError.value = null
|
||||
}
|
||||
|
||||
// 仅在用户主动选费时带价,保留已有费用回显金额与人工修改。
|
||||
function applyFeeItemDefaultPrice() {
|
||||
form.amount = selectedFeeItem.value?.defaultPrice ?? 0
|
||||
}
|
||||
function settlementLabel(item: ContainerFeeResource) { return item.direction !== 'PAYABLE' ? '—' : item.settlementMode === 'DIRECT_PAID' ? '已付成本' : item.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
|
||||
function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[value || ''] || '—' }
|
||||
function customerLabel(customerId: string) { return customerOptions.value.find((item) => item.value === customerId)?.label || '客户信息缺失' }
|
||||
@@ -159,17 +153,12 @@ function removeVoucher(fileId: string) {
|
||||
}
|
||||
|
||||
async function loadFeeItems() {
|
||||
const sequence = ++feeItemLoadSequence
|
||||
feeItems.value = []; feeItemsError.value = null
|
||||
if (!canViewFeeItems.value) return
|
||||
feeItemsLoading.value = true
|
||||
try {
|
||||
const items = await listApplicableFeeItems()
|
||||
// 只接收当前窗口最新请求,旧响应不得覆盖价格或清除新请求的加载状态。
|
||||
if (sequence === feeItemLoadSequence) feeItems.value = items
|
||||
}
|
||||
catch (cause) { if (sequence === feeItemLoadSequence) feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
|
||||
finally { if (sequence === feeItemLoadSequence) feeItemsLoading.value = false }
|
||||
try { feeItems.value = await listApplicableFeeItems() }
|
||||
catch (cause) { feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
|
||||
finally { feeItemsLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadSuppliers() {
|
||||
@@ -351,12 +340,6 @@ function clearCustomerContext() {
|
||||
}
|
||||
watch(customerOptions, initializeCustomer, { immediate: true })
|
||||
watch(accountId, () => { clearCustomerContext(); void load() })
|
||||
watch(editorOpen, (open) => {
|
||||
if (open) return
|
||||
// 同步失效旧窗口请求,覆盖关闭后立即重开及切换客户的情况。
|
||||
feeItemLoadSequence += 1
|
||||
feeItems.value = []; feeItemsError.value = null; feeItemsLoading.value = false
|
||||
}, { flush: 'sync' })
|
||||
watch(() => form.direction, (direction) => {
|
||||
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
|
||||
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; form.settlementMode = null; clearPaidFields(); suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false }
|
||||
@@ -370,7 +353,7 @@ onMounted(() => void load())
|
||||
<template>
|
||||
<section class="fees-panel admin-density" aria-label="柜次费用维护">
|
||||
<header class="fees-header">
|
||||
<div><h2>{{ title }}</h2></div>
|
||||
<div><h2>{{ title }}</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
||||
<div class="fees-header__actions">
|
||||
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
|
||||
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
|
||||
@@ -418,7 +401,7 @@ onMounted(() => void load())
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form density-form">
|
||||
<template v-if="!target">
|
||||
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.direction === 'PAYABLE'" label="结算方式" required><el-radio-group v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item>
|
||||
<template v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'">
|
||||
@@ -453,7 +436,7 @@ onMounted(() => void load())
|
||||
<style scoped>
|
||||
.fees-panel { min-width: 0; overflow: hidden; padding-top: 0; border-top: 0; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: 0 0 var(--admin-space-5); }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.fees-header p, .allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||||
.customer-context > span { color: var(--cc-muted); font-size: var(--admin-font-body); white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
||||
.fees-table-title { display: flex; align-items: baseline; gap: var(--admin-space-3); }.fees-table-title strong { font-size: var(--admin-font-section); }.fees-table-title span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -166,6 +166,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
|
||||
<div v-if="!embedded" class="detail-page-header__identity">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="mode !== 'preload'">查看已完成装柜明细和操作历史</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
|
||||
@@ -255,6 +256,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.detail-page-header__back { margin-right: 2px; }
|
||||
.detail-page-header__identity { min-width: 0; }
|
||||
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||
.detail-page-header__identity p { margin: 3px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.detail-page-header__actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--admin-space-3, 8px); }
|
||||
.detail-page-header__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-2, 6px) 28px; padding: var(--admin-space-4, 10px) 0 2px; border-top: 1px solid var(--cc-border); }
|
||||
|
||||
@@ -36,40 +36,3 @@
|
||||
| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
|
||||
|
||||
删除和文件预览在三类正式页面中均未定义,不自行补充承载方式;海外采购审核按当前对话确认的承载方式实现。
|
||||
|
||||
## 本土卡车需求字段调整
|
||||
|
||||
依据本轮客户端截图及用户确认,同步平台订单字段;规则唯一见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。
|
||||
|
||||
| 页面 | 操作 | 原型/确认依据 | 承载及布局 | 调整与验收 |
|
||||
|---|---|---|---|---|
|
||||
| 本土卡车 | 新增、编辑 | 上表 `page-overseas-truck-add`、`page-overseas-truck-edit`;本轮用户确认同步后台 | 保持独立页面及现有分区;起运地和目的地两列对应联系人 | 删除旧需求字段,增加车型及两端联系、取送时间;待人工截图确认 |
|
||||
| 本土卡车 | 列表 | 上表 `page-overseas-truck`;本轮确认 | 保持列表、操作第一列及现有承运安排 | 对应展示车型、两端联系及取送时间;待人工截图确认 |
|
||||
|
||||
后台不新增详情路由;池来源仍只读展示承运安排,散板最新范围见下节。
|
||||
|
||||
|
||||
## 本土散板简化下单
|
||||
|
||||
字段规则唯一见[海外仓业务 README](../../../../../docs/海外仓业务/README.md#本土散板简化下单)。依据用户截图 `codex-clipboard-71e44fad-9790-48c0-bf94-fa80ddce1445.png` 和本轮确认,覆盖此前散板新增/编辑字段结论。
|
||||
|
||||
| 页面 | 操作 | 原型证据及布局 | 当前实现与结论 |
|
||||
|---|---|---|---|
|
||||
| 本土散板 | 新增 | 用户截图:独立页面;自送/我司提货;地址与联系两列;货物四列;取送时间两列;底部取消/提交 | 保持独立路由,顶部补客户;货物为商品名、预计托数、重量、体积;显示币种;件数/地址补充/备注收起;移除运输方式;两端四项联系信息和取送时间必填。待人工 1920×1080、100% 截图确认 |
|
||||
| 本土散板 | 编辑 | 现有独立编辑路由;本轮确认的新需求合同 | 同新增字段并回显保存值;只允许待接单非池来源;版本冲突保留内容。待人工截图确认 |
|
||||
| 本土散板 | 列表 | 原 `page-overseas-looseboard` 独立列表与左侧操作 | 展示两端联系信息、商品名、预计托数及取送时间;内部未知安排显示“—”;池来源保持承运摘要。待人工截图确认 |
|
||||
|
||||
本次不增加详情、审核、删除或文件预览操作;不更改现有承运安排只读弹框。
|
||||
|
||||
|
||||
## Admin / Client 统一运输下单表单(2026-10-19)
|
||||
|
||||
依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。
|
||||
|
||||
| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 |
|
||||
| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 |
|
||||
| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 |
|
||||
|
||||
统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。
|
||||
|
||||
@@ -20,15 +20,15 @@ const purchaseSummary = {
|
||||
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
const truckSummary = {
|
||||
id: '9007199254740997', customerId: '10002', orderNo: 'TR-1', vehicleType: 'PALLET_15', origin: 'LA', destination: 'ONT',
|
||||
palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
|
||||
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2',
|
||||
pickupAt: '2026-08-20T09:30:00', deliveryAt: '2026-08-20T15:45:00', status: 'PENDING_REVIEW', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
id: '9007199254740997', customerId: '10002', orderNo: 'TR-1', transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT',
|
||||
containerType: '40HQ', palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
|
||||
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', contactName: 'A', contactInfo: '1',
|
||||
requestedDate: '2026-08-20', status: 'PENDING_REVIEW', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
const looseboardSummary = {
|
||||
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
|
||||
origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
|
||||
budgetAmount: 50, currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0,
|
||||
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
|
||||
origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
|
||||
budgetAmount: 50, currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0,
|
||||
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-01T00:00:00',
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('platform overseas business API adapter', () => {
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail))
|
||||
expect((await listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45')
|
||||
expect((await getUsTruckOrder('TR-1', '10002')).pallets[0]?.warehousePalletId).toBe('9007199254741003')
|
||||
const create = { vehicleType: 'PALLET_15' as const, origin: 'LA', destination: 'ONT', packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-08-20T09:30:00', deliveryAt: '2026-08-20T15:45:00', idempotencyKey: 'HTTP-20260824-MARKER-truck' }
|
||||
const create = { transportType: 'PORT_PICKUP' as const, origin: 'LA', destination: 'ONT', palletCount: 2, packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1', requestedDate: '2026-08-20', idempotencyKey: 'HTTP-20260824-MARKER-truck' }
|
||||
overseas.post.mockResolvedValue(envelope(detail)); await createUsTruckOrder('9007199254741999', create)
|
||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '9007199254741999' } })
|
||||
const { idempotencyKey: _truckKey, ...truckFields } = create
|
||||
@@ -111,7 +111,7 @@ describe('platform overseas business API adapter', () => {
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
|
||||
expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
|
||||
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
|
||||
const create = { productName: '家居用品', estimatedPalletCount: 2, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
|
||||
const create = { transportMode: 'SHARED_TRUCK' as const, palletRequired: 'YES' as const, transportType: 'WAREHOUSE_TO_WAREHOUSE' as const, origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
|
||||
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create)
|
||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } })
|
||||
const { idempotencyKey: _looseKey, ...looseboardFields } = create
|
||||
@@ -122,9 +122,9 @@ describe('platform overseas business API adapter', () => {
|
||||
|
||||
it('propagates a 409 envelope without issuing any fallback write', async () => {
|
||||
const payload: UpdateLocalTransportOrderRequest = {
|
||||
version: 2, vehicleType: 'PALLET_15', origin: 'LA', destination: 'ONT-EDIT',
|
||||
packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2',
|
||||
pickupAt: '2026-08-20T09:30:00', deliveryAt: '2026-08-20T15:45:00',
|
||||
version: 2, transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT-EDIT', palletCount: 2,
|
||||
packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1',
|
||||
requestedDate: '2026-08-20',
|
||||
}
|
||||
overseas.patch.mockResolvedValue({ code: 'B0409', message: 'Order was changed', data: null, requestId: 'REQ-409' })
|
||||
await expect(updateUsTruckOrder('TR-1', '9007199254741999', payload)).rejects.toMatchObject({ code: 'B0409', requestId: 'REQ-409' })
|
||||
@@ -133,11 +133,11 @@ describe('platform overseas business API adapter', () => {
|
||||
expect(overseas.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves unknown pool pallet service and product name in list and detail responses', async () => {
|
||||
const summary = { ...looseboardSummary, palletRequired: null, productName: null }
|
||||
it('preserves unknown pool pallet service and container type in list and detail responses', async () => {
|
||||
const summary = { ...looseboardSummary, palletRequired: null, containerType: null }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
|
||||
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, productName: null })
|
||||
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, productName: null })
|
||||
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, containerType: null })
|
||||
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, containerType: null })
|
||||
expect(overseas.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function updateOverseasPurchase(orderNo: string, customerId: string
|
||||
return procurementSummary(unwrapApiResponse(response).summary)
|
||||
}
|
||||
export async function listUsTruckOrders(query: LocalTransportQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, vehicleType: query.vehicleType || undefined, customerId: query.customerId || undefined } })
|
||||
const response = await http.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), truckSummary)
|
||||
}
|
||||
export async function getUsTruckOrder(orderNo: string, customerId: string) {
|
||||
@@ -131,7 +131,7 @@ export async function updateUsTruckOrder(orderNo: string, customerId: string, pa
|
||||
return truckDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined } })
|
||||
const response = await http.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportMode: query.transportMode || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), looseboardSummary)
|
||||
}
|
||||
export async function getLocalLooseboardOrder(orderNo: string, customerId: string) {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
model: TransportDemandForm
|
||||
kind: TransportDemandKind
|
||||
disabled?: boolean
|
||||
submitting?: boolean
|
||||
canSubmit?: boolean
|
||||
editing?: boolean
|
||||
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
|
||||
const emit = defineEmits<{ submit: []; cancel: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const rules = computed(() => transportDemandRules(props.kind, props.model))
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate() ?? Promise.resolve(false),
|
||||
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" class="transport-demand-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
|
||||
<slot name="customer" />
|
||||
<section class="transport-demand-section">
|
||||
<h3>运输信息</h3>
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
|
||||
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item>
|
||||
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item>
|
||||
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" placeholder="请输入起运地联系电话" /></el-form-item>
|
||||
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" placeholder="请输入目的地联系电话" /></el-form-item>
|
||||
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
<h3>货物信息</h3>
|
||||
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
|
||||
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" placeholder="请输入商品名称(选填)" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" placeholder="请输入预计托数" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
|
||||
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入总重量" /></el-form-item>
|
||||
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" placeholder="请输入总体积" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
<h3>费用与时效</h3>
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
|
||||
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" placeholder="请输入预算总价(选填)" />
|
||||
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
|
||||
</div></el-form-item>
|
||||
<div />
|
||||
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入其他要求(选填)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="transport-demand-actions">
|
||||
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
|
||||
</footer>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: #fff; }
|
||||
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
|
||||
.transport-demand-section h3 { margin: 0 0 16px; color: #27364d; font-size: 14px; font-weight: 600; }
|
||||
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
|
||||
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.transport-demand-wide { grid-column: 1 / -1; }
|
||||
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
|
||||
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
|
||||
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
|
||||
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
|
||||
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
|
||||
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
</style>
|
||||
@@ -55,9 +55,9 @@ const procurementDetail = (): PlatformProcurement => ({
|
||||
customerView: { order: { ...purchase, status: 'WAIT_PURCHASE', plannedWarehouseName: '洛杉矶采购仓', actualWarehouseCode: null, actualWarehouseName: null, receivingAddress: '深圳收件地址', contactName: 'Alice', contactPhone: '13800000000' }, budget: { totalAmount: '250.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [{ key: 'CREATED', title: '客户下单', completedAt: purchase.createdAt }], boxes: [], shipments: [], fees: [], exceptionSummaries: [] },
|
||||
purchaseFacts: {}, physical: { dispositions: [], receipts: [], packingVersion: null, packingStatus: null, cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: purchase.histories, operations: [], actions: [], internalRemark: '采购备注',
|
||||
})
|
||||
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', vehicleType: 'PALLET_15', origin: 'Long Beach', destination: 'Ontario', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', originContactName: 'Bob', originContactPhone: '10010', destinationContactName: 'Alice', destinationContactPhone: '10020', pickupAt: '2026-08-28T09:30:00', deliveryAt: '2026-08-28T15:45:00', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
|
||||
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
|
||||
|
||||
@@ -133,28 +133,28 @@ describe('overseas business mounted pages', () => {
|
||||
expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false)
|
||||
expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('shows declared product and forecast pallets without exposing obsolete container selection', async () => {
|
||||
it('distinguishes unknown pool pallet service from explicit no and yes in actual list cells', async () => {
|
||||
const records = ([null, 'NO', 'YES'] as const).map((palletRequired, index) => ({ ...looseboard, id: String(100 + index), orderNo: `LB-NULL-${index}`, palletRequired, containerType: null, fleetArrangement: { ...arrangement, businessType: 'LOCAL_LOOSEBOARD' as const } }))
|
||||
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records, total: 3, page: 1, size: 20, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('家居用品')
|
||||
expect(wrapper.text()).toContain('预计托数')
|
||||
expect(wrapper.text()).not.toContain('柜型')
|
||||
const rows = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')
|
||||
expect(rows.map(row => row.findAll('td')[3]!.text())).toEqual(['—', '否', '是'])
|
||||
expect(listLocalLooseboardOrders).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('shows unknown pool vehicle type as a dash without inventing a capacity', async () => {
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, vehicleType: null, fleetArrangement: arrangement }], total: 1, page: 1, size: 20, pages: 1 })
|
||||
it('shows an unknown pool container type as a dash without inventing 40HQ', async () => {
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, containerType: null, fleetArrangement: arrangement }], total: 1, page: 1, size: 20, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[2]!.text()).toBe('—')
|
||||
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[7]!.text()).toBe('—')
|
||||
expect(wrapper.find('.el-table__body-wrapper').text()).not.toContain('40HQ')
|
||||
})
|
||||
it('allows standalone demand editing without inventing pallet service or transport arrangements', async () => {
|
||||
it('still rejects an ordinary looseboard edit when its required pallet service is absent', async () => {
|
||||
routing.route.params = { orderId: looseboard.orderNo }; routing.route.query = { customerId: looseboard.customerId }
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, transportType: null })
|
||||
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, fleetArrangement: null })
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('是否打盘')
|
||||
expect(wrapper.text()).not.toContain('运输类型')
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('model').palletRequired).toBeNull()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('保存修改'))!.trigger('click'); await flushPromises()
|
||||
expect(updateLocalLooseboardOrder).toHaveBeenCalledWith(looseboard.orderNo, looseboard.customerId, expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 2, pickupAt: '2026-10-20T09:30:00' }))
|
||||
expect(updateLocalLooseboardOrder).toHaveBeenCalledOnce()
|
||||
expect(updateLocalLooseboardOrder).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('请选择是否打盘')
|
||||
})
|
||||
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
|
||||
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
|
||||
@@ -270,60 +270,17 @@ describe('overseas business mounted pages', () => {
|
||||
expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: null })
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchase-detail', params: { purchaseId: 'OP-100' }, query: { customerId: '10001' } })
|
||||
})
|
||||
it.each(['truck', 'looseboard'] as const)('requires an explicit customer and sends only the unified business payload for %s', async domain => {
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain }); await flushPromises()
|
||||
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
|
||||
expect(model.totalWeightKg).toBe(0); expect(model.totalVolumeCbm).toBe(0)
|
||||
expect(model.budgetAmount).toBeUndefined(); expect(model.currency).toBe('USD')
|
||||
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
|
||||
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual([
|
||||
'客户', ...(domain === 'truck' ? ['车辆类型'] : []), '起运地', '目的地',
|
||||
'起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址',
|
||||
])
|
||||
Object.assign(model, { vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' })
|
||||
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
|
||||
await submit.trigger('click'); await flushPromises()
|
||||
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
|
||||
model.customerId = '10001'
|
||||
await submit.trigger('click'); await flushPromises()
|
||||
const command = domain === 'truck' ? vi.mocked(createUsTruckOrder) : vi.mocked(createLocalLooseboardOrder)
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
const [customer, payload] = command.mock.calls[0]!
|
||||
expect(customer).toBe('10001')
|
||||
expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' }))
|
||||
for (const field of ['customerId', 'tenantId', 'orderNo', 'status', 'transportMode', 'containerType']) expect(payload).not.toHaveProperty(field)
|
||||
})
|
||||
|
||||
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
|
||||
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
|
||||
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) })
|
||||
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
|
||||
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
|
||||
it('requires split contacts and both local times, rejecting reversed times', async () => {
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
|
||||
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
|
||||
Object.assign(model, { customerId: '10001', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, totalWeightKg: 80, totalVolumeCbm: 1.8, deliveryAt: '2026-10-20T11:30:00' })
|
||||
await wrapper.vm.$nextTick()
|
||||
const save = wrapper.findAll('button').find(button => button.text().includes('提交订单'))!
|
||||
await save.trigger('click'); await flushPromises()
|
||||
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
|
||||
model.pickupAt = '2026-10-20T12:30:00'
|
||||
await save.trigger('click'); await flushPromises()
|
||||
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
|
||||
model.deliveryAt = '2026-10-20T15:45:00'
|
||||
await save.trigger('click'); await flushPromises()
|
||||
expect(createLocalLooseboardOrder).toHaveBeenCalledWith('10001', expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, pickupAt: '2026-10-20T12:30:00', deliveryAt: '2026-10-20T15:45:00' }))
|
||||
const payload = vi.mocked(createLocalLooseboardOrder).mock.calls[0]![1]
|
||||
expect(payload).not.toHaveProperty('containerType')
|
||||
expect(payload).not.toHaveProperty('palletRequired')
|
||||
expect(payload).not.toHaveProperty('transportType')
|
||||
})
|
||||
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
|
||||
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '1' : '联系人'); const dates = wrapper.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-20T09:30:00'); dates[1].vm.$emit('update:modelValue', '2026-10-20T15:45:00'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
|
||||
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
|
||||
@@ -4,8 +4,8 @@ import { TransportRouteDialog, routeLocation } from '@/modules/overseas-master-d
|
||||
import '../overseasBusinessDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance } from 'element-plus'
|
||||
import { ArrowLeft, Save } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -14,14 +14,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domain/containerSpecifications'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import TransportDemandForm from '../components/TransportDemandForm.vue'
|
||||
import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
|
||||
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const routeOpen = ref(false)
|
||||
@@ -32,17 +31,40 @@ const editing = computed(() => Boolean(orderId.value)); const isLooseboard = com
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
|
||||
const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate))
|
||||
type FormStatus = LocalTransportStatus | LocalLooseboardStatus
|
||||
interface TransportFormModel extends DemandFormModel {
|
||||
customerId: string; orderNo: string; status: FormStatus; version: number; idempotencyKey: string
|
||||
interface TransportFormModel {
|
||||
customerId: string; orderNo: string; transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | ''
|
||||
transportType: LocalTransportType | ''; origin: string; destination: string; originAddress: string | null; destinationAddress: string | null
|
||||
contactName: string | null; contactInfo: string | null; containerType: string | null; palletCount: number; packageCount: number
|
||||
totalWeightKg: string | null; totalVolumeCbm: string | null; budgetAmount: string | null; currency: string
|
||||
requestedDate: string | null; remark: string | null; status: FormStatus; version: number; idempotencyKey: string
|
||||
}
|
||||
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
const initial = (): TransportFormModel => ({ ...createTransportDemandForm(), customerId: '', orderNo: '', status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
|
||||
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
|
||||
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
|
||||
const fleetArrangement = ref<FleetArrangement | null>(null)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
|
||||
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
|
||||
const rules: FormRules<TransportFormModel> = {
|
||||
transportMode: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
|
||||
palletRequired: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
|
||||
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
palletCount: [{ validator: (_rule, value, callback) => (isLooseboard.value || (Number.isInteger(value) && value >= 1)) ? callback() : callback(new Error('托数至少为 1')), trigger: 'change' }],
|
||||
packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }], totalWeightKg: [requiredDecimal('总重量')], totalVolumeCbm: [requiredDecimal('总体积')],
|
||||
contactName: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系人')), trigger: 'blur' }],
|
||||
contactInfo: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系方式')), trigger: 'blur' }],
|
||||
requestedDate: [{ validator: (_rule, value, callback) => (isLooseboard.value || value) ? callback() : callback(new Error('请选择要求时间')), trigger: 'change' }],
|
||||
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
|
||||
}
|
||||
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
|
||||
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
|
||||
const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW')))
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
|
||||
async function loadCustomers() {
|
||||
if (editing.value) return
|
||||
customersLoading.value = true; customersError.value = ''
|
||||
@@ -59,11 +81,11 @@ async function load() {
|
||||
if (isLooseboard.value) {
|
||||
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version })
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
} else {
|
||||
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
|
||||
fleetArrangement.value = detail.fleetArrangement
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version })
|
||||
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
}
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false }
|
||||
@@ -71,17 +93,17 @@ async function load() {
|
||||
async function save() {
|
||||
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
|
||||
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
|
||||
const failure = transportDemandError(isLooseboard.value ? 'looseboard' : 'truck', form)
|
||||
if (failure) { submitError.value = { message: failure }; return }
|
||||
if (isLooseboard.value && form.palletRequired !== 'YES' && form.palletRequired !== 'NO') { submitError.value = { message: '请选择是否打盘' }; return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
const common = { transportType: form.transportType as LocalTransportType, origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), containerType: optional(form.containerType), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, remark: optional(form.remark) }
|
||||
saving.value = true; submitError.value = null
|
||||
try {
|
||||
if (isLooseboard.value) {
|
||||
const loose = buildTransportDemand('looseboard', form)
|
||||
const loose = { ...common, transportMode: form.transportMode as LooseboardTransportMode, palletRequired: form.palletRequired as PalletRequired }
|
||||
if (editing.value) await updateLocalLooseboardOrder(orderId.value, routeCustomerId.value, { ...loose, version: form.version } satisfies UpdateLocalLooseboardOrderRequest)
|
||||
else await createLocalLooseboardOrder(form.customerId, { ...loose, idempotencyKey: form.idempotencyKey } satisfies CreateLocalLooseboardOrderRequest)
|
||||
} else {
|
||||
const truck = buildTransportDemand('truck', form)
|
||||
const truck = { ...common, contactName: form.contactName!.trim(), contactInfo: form.contactInfo!.trim(), requestedDate: form.requestedDate!, palletCount: form.palletCount }
|
||||
if (editing.value) await updateUsTruckOrder(orderId.value, routeCustomerId.value, { ...truck, version: form.version } satisfies UpdateLocalTransportOrderRequest)
|
||||
else await createUsTruckOrder(form.customerId, { ...truck, idempotencyKey: form.idempotencyKey } satisfies CreateLocalTransportOrderRequest)
|
||||
}
|
||||
@@ -97,18 +119,21 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
|
||||
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
|
||||
<section v-else v-loading="loading" class="form-panel">
|
||||
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
|
||||
<FormSection class="density-form-section" v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
|
||||
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
|
||||
<TransportDemandForm ref="formRef" :model="form" :kind="isLooseboard ? 'looseboard' : 'truck'" :submitting="saving" :disabled="!editableStatus" :editing="editing" @submit="save" @cancel="back">
|
||||
<template v-if="!editing" #customer>
|
||||
<FormSection class="transport-customer-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
|
||||
</template>
|
||||
</TransportDemandForm>
|
||||
<FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection>
|
||||
</section>
|
||||
<FormSection class="density-form-section" v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
|
||||
</el-form><FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
|
||||
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-customer-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
|
||||
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.form-grid.five { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.choice-block { display: grid; grid-template-columns: 1fr 1fr; gap: var(--admin-space-5); }
|
||||
.span-2 { grid-column: 1 / -1; }
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
|
||||
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const routeOpen = ref(false)
|
||||
@@ -40,6 +40,7 @@ const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), st
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
@@ -47,23 +48,23 @@ const fields = computed<QueryField[]>(() => [
|
||||
])
|
||||
const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'vehicleType', label: '车辆类型', minWidth: 110, formatter: (row: LocalTransportOrder) => row.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[row.vehicleType] : '—' },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
|
||||
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90, formatter: (row: LocalTransportOrder) => row.containerType || '—' }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) },
|
||||
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
|
||||
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
|
||||
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : row.palletRequired === 'NO' ? '否' : '—' },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
|
||||
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'budgetAmount', label: '预算总价', width: 115, align: 'right', formatter: (row: LocalLooseboardOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 150 }, { key: 'estimatedPalletCount', prop: 'estimatedPalletCount', label: '预计托数', width: 100 }, { key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.pickupAt) }, { key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const columns = computed(() => {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { FormRules } from 'element-plus'
|
||||
|
||||
export type TransportDemandKind = 'truck' | 'looseboard'
|
||||
export interface TransportDemandForm {
|
||||
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
|
||||
origin: string
|
||||
destination: string
|
||||
originAddress: string
|
||||
destinationAddress: string
|
||||
originContactName: string
|
||||
originContactPhone: string
|
||||
destinationContactName: string
|
||||
destinationContactPhone: string
|
||||
productName: string
|
||||
estimatedPalletCount: number
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount: number | undefined
|
||||
currency: 'USD' | 'CNY'
|
||||
pickupAt: string
|
||||
deliveryAt: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export function createTransportDemandForm(): TransportDemandForm {
|
||||
return {
|
||||
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
|
||||
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
|
||||
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
|
||||
budgetAmount: undefined, currency: 'USD', pickupAt: '', deliveryAt: '', remark: '',
|
||||
}
|
||||
}
|
||||
|
||||
const requiredText = {
|
||||
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
|
||||
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
|
||||
} as const
|
||||
|
||||
export function localMinuteInput(value: unknown) {
|
||||
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
|
||||
}
|
||||
|
||||
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
|
||||
const rules: FormRules<TransportDemandForm> = {}
|
||||
for (const [field, label] of Object.entries(requiredText)) {
|
||||
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
|
||||
}
|
||||
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
|
||||
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue
|
||||
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
|
||||
}
|
||||
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
|
||||
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
|
||||
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
|
||||
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
|
||||
}]
|
||||
return rules
|
||||
}
|
||||
|
||||
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
|
||||
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
|
||||
for (const [field, label] of Object.entries(requiredText)) {
|
||||
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
|
||||
}
|
||||
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
|
||||
if (!form[field]) return `请选择${label}`
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
|
||||
}
|
||||
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
|
||||
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
|
||||
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
|
||||
}
|
||||
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
|
||||
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
|
||||
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function commonPayload(form: TransportDemandForm) {
|
||||
return {
|
||||
origin: form.origin.trim(), destination: form.destination.trim(),
|
||||
originAddress: form.originAddress.trim() || undefined, destinationAddress: form.destinationAddress.trim() || undefined,
|
||||
originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(),
|
||||
destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(),
|
||||
packageCount: form.packageCount, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
|
||||
budgetAmount: form.budgetAmount ?? undefined, currency: form.currency,
|
||||
pickupAt: localMinuteInput(form.pickupAt), deliveryAt: localMinuteInput(form.deliveryAt), remark: form.remark.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type CommonPayload = ReturnType<typeof commonPayload>
|
||||
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' }
|
||||
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
|
||||
|
||||
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
|
||||
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
|
||||
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) {
|
||||
const failure = transportDemandError(kind, form)
|
||||
if (failure) throw new Error(failure)
|
||||
const common = commonPayload(form)
|
||||
return kind === 'truck'
|
||||
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
|
||||
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||
}
|
||||
@@ -28,20 +28,17 @@ export interface OverseasPurchaseEditorDetail {
|
||||
}
|
||||
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
|
||||
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
|
||||
export type TruckVehicleType = 'PALLET_15' | 'PALLET_30'
|
||||
export interface LocalTransportQuery {
|
||||
page?: number; size?: number; orderNo?: string; routeKeyword?: string; status?: LocalTransportStatus | ''
|
||||
vehicleType?: TruckVehicleType | ''; customerId?: string; pickupFrom?: string; pickupTo?: string
|
||||
transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalTransportOrder {
|
||||
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
|
||||
id: string; customerId: string; orderNo: string; vehicleType: TruckVehicleType | null
|
||||
origin: string; destination: string; palletCount: number; packageCount: number
|
||||
id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null
|
||||
origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number
|
||||
receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number
|
||||
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
|
||||
originContactName: string | null; originContactPhone: string | null
|
||||
destinationContactName: string | null; destinationContactPhone: string | null
|
||||
pickupAt: string | null; deliveryAt: string | null; status: LocalTransportStatus
|
||||
contactName: string; contactInfo: string; requestedDate: string; status: LocalTransportStatus
|
||||
version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
export interface LocalTransportPallet {
|
||||
@@ -53,28 +50,31 @@ export interface LocalTransportDetail extends LocalTransportOrder {
|
||||
pallets: LocalTransportPallet[]; histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface CreateLocalTransportOrderRequest {
|
||||
vehicleType: TruckVehicleType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
|
||||
packageCount?: number; totalWeightKg: number; totalVolumeCbm: number
|
||||
budgetAmount?: number | null; currency?: string
|
||||
originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string
|
||||
pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string
|
||||
transportType?: LocalTransportType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
|
||||
containerType?: string | null; palletCount: number; packageCount?: number; totalWeightKg: number; totalVolumeCbm: number
|
||||
budgetAmount?: number | null; currency?: string; contactName: string; contactInfo: string; requestedDate: string
|
||||
remark?: string | null; idempotencyKey: string
|
||||
}
|
||||
export interface UpdateLocalTransportOrderRequest extends Omit<CreateLocalTransportOrderRequest, 'idempotencyKey' | 'origin' | 'destination' | 'totalWeightKg' | 'totalVolumeCbm'> {
|
||||
version: number; origin?: string; destination?: string; totalWeightKg?: number; totalVolumeCbm?: number
|
||||
export interface UpdateLocalTransportOrderRequest {
|
||||
version: number; transportType?: LocalTransportType; origin?: string; originAddress?: string | null
|
||||
destination?: string; destinationAddress?: string | null; containerType?: string | null; palletCount?: number
|
||||
packageCount?: number; totalWeightKg?: number; totalVolumeCbm?: number; budgetAmount?: number | null
|
||||
currency?: string; contactName?: string; contactInfo?: string; requestedDate?: string; remark?: string | null
|
||||
}
|
||||
|
||||
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
|
||||
export type PalletRequired = 'YES' | 'NO'
|
||||
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
|
||||
export interface LocalLooseboardQuery {
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
|
||||
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
|
||||
status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalLooseboardOrder {
|
||||
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
|
||||
id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null
|
||||
transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number
|
||||
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired | null
|
||||
transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number
|
||||
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
|
||||
originContactName: string | null; originContactPhone: string | null; destinationContactName: string | null; destinationContactPhone: string | null; pickupAt: string | null; deliveryAt: string | null; status: LocalLooseboardStatus
|
||||
contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus
|
||||
version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
export interface LocalLooseboardDetail extends LocalLooseboardOrder {
|
||||
@@ -82,11 +82,10 @@ export interface LocalLooseboardDetail extends LocalLooseboardOrder {
|
||||
histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface CreateLocalLooseboardOrderRequest {
|
||||
productName?: string | null; estimatedPalletCount: number
|
||||
transportMode: LooseboardTransportMode; palletRequired: PalletRequired; transportType: LocalTransportType
|
||||
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
|
||||
packageCount: number; totalWeightKg: number; totalVolumeCbm: number
|
||||
budgetAmount?: number | null; currency?: string
|
||||
originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string
|
||||
pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string
|
||||
containerType?: string | null; packageCount: number; totalWeightKg: number; totalVolumeCbm: number
|
||||
budgetAmount?: number | null; currency?: string; contactName?: string | null; contactInfo?: string | null
|
||||
requestedDate?: string | null; remark?: string | null; idempotencyKey: string
|
||||
}
|
||||
export interface UpdateLocalLooseboardOrderRequest extends Omit<CreateLocalLooseboardOrderRequest, 'idempotencyKey'> { version: number }
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 适用应用规则见 `../../../AGENTS.md`;原型与交互矩阵为本目录 `PROTOTYPE_MATRIX.md`,新增司机页面使用其中用户提供的新版原型入口。
|
||||
- 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。
|
||||
- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。
|
||||
- 当前日常作业统一从排车列表进入,侧栏`fleet-trips`为hidden;“出车记录”保留实际发车后的运营查询。原`pages/TripsView.vue`、`pages/TripDetailView.vue`及附加能力代码保留,不能作为新排车的前置步骤。旧详情仍按页签按需读取,资源候选聚焦或搜索时查询,不逐行读取。新整车审核、PDA真实拣货、双端装车确认及实际发车规则以`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`为准;详情只有已确认四页签,不把旧八项附加功能自动迁入或以派送完成自动释放资源。
|
||||
- 用户已于2026-10-02确认首版车次交互:`pages/TripsView.vue`、`pages/TripDetailView.vue` 为独立路由;`TripPlanDialog.vue` 1080px居中计划弹框,`TripAssignmentPanel.vue`、`TripLoadingPanel.vue` 在详情分区。列表一次分页请求,历史/装载/结案检查进入对应页签才读取;候选聚焦或搜索时调用车辆/司机目录,不逐行读取详情。先分配资源、关联排车及匹配卸货站,每张排车单调度审核通过后由PDA拣货、平台确认实测装载、整车理货审核通过后PDA整车发车;平台不提供替代PDA的发车按钮。审核使用独立trip:dispatch-review/trip:loading-review权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。
|
||||
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,下载中心 XLSX 导出使用车牌业务标识并包含容量字段。
|
||||
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
|
||||
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
|
||||
|
||||
@@ -14,7 +14,3 @@
|
||||
- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。
|
||||
- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。
|
||||
- CSV 下载只接受真实 Blob;JSON 错误 Blob 转为带 `requestId/traceId` 的业务错误,不保存为文件。
|
||||
|
||||
2026-10-17:仓储转运上门取货使用 `fleet-transfer-pickup-v1.yaml` 新接口,需部署Lastleg及 `baiyue_lastleg` 结构增量后生效。本轮只操作本机,不据本机验收推定远端已部署;人工页面视觉待验收。
|
||||
|
||||
2026-10-17 资源占用辅助查询:`GET /fleet/trips/{tripNo}/resource-occupancy`,按VEHICLE/DRIVER和本次最多100个ID批量读取;本车资源可保留、其他车次禁选。沿trip:list平台授权;只读、不预留,分配写入口再次锁定复验。前后端需一起更新,无新增数据库结构。
|
||||
|
||||
@@ -89,21 +89,6 @@
|
||||
|
||||
以上仅为代码接入依据,真实界面验收仍待执行。
|
||||
|
||||
## 2026-10-19 出车记录统计与列表优化
|
||||
|
||||
依据:当前对话图1(现状)、图2(原型);用户确认优先在前端优化。沿当前实际发车口径与接口,不恢复原型手填发车事实。
|
||||
|
||||
| 页面 | 操作 | 原型承载/布局 | 当前实现 | 本轮结论 |
|
||||
|---|---|---|---|---|
|
||||
| 出车记录 | 统计与查询 | 图2上方独立四栏饼图,下方记录与筛选 | 图1统计位于列表筛选和表格之间,条形明细内部滚动 | 独立四栏饼图及彩色图例;用户追加确认只改饼图、保留现有日期/目的地统计分组;列表一套日期/关键词/货源/状态查询,同一响应更新统计与记录;币种切换独立展示,顶部均价明确为最近统计日值,不相加或简单平均每日均价;长图例与完整统计按需展开 |
|
||||
| 出车记录 | 列表、导出 | 图2列表独立卡片,操作第一列,导出在右上 | integrated列表、字段设置、XLSX下载任务 | 保留公共连续列表,整理默认列;完整字段仍可通过字段设置显示;导出沿原筛选及权限 |
|
||||
| 出车记录 | 详情、实测编辑、凭证预览 | 图2仅能确认列表编辑入口;当前操作以既有确认矩阵为准 | 详情独立路由;实测820px居中及按需凭证预览 | 不改变承载、读写权限或请求方式 |
|
||||
| 出车记录 | 新增、审核、删除 | 图2有新增记录,未定义审核/删除 | 当前列表无新增/审核/删除 | 不新增业务动作,实际发车事实仍由既有作业链产生 |
|
||||
|
||||
用户追加截图确认统计区文字过多,并要求改善图表质感:使用项目已有 ECharts / vue-echarts 替换手写SVG,统一原生饼图、圆点图例和悬停数值;默认仅保留指标标题、主数值、四个饼图及短图例,日期图例使用月日,完整值在统计详情读取;默认不显示各图说明、重复系列标题、整行成本和额外明细入口。页头“统计详情”统一按需展开统计口径、服务端汇总、成本及完整图例明细,原有数据与权限保留。
|
||||
|
||||
本轮只调整前端;自动检查与人工视觉验收分别记录,修改后1920×1080、100%截图待用户确认。
|
||||
|
||||
## 新版报价与出车记录接入
|
||||
|
||||
| 入口 | 原型依据 | 当前承载 | 核对边界 |
|
||||
@@ -163,17 +148,3 @@
|
||||
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
|
||||
|
||||
2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。
|
||||
|
||||
## 仓储转运上门取货(2026-10-17)
|
||||
|
||||
依据本轮用户确认的仓储转运与车辆排车结合方案。
|
||||
|
||||
| 页面 | 承载 | 业务与验收边界 |
|
||||
|---|---|---|
|
||||
| 车辆排车 | 原列表增加待取货页签,服务端分页 | 同仓选择原转运单;780px居中安排弹框,不新增业务单 |
|
||||
| 车次详情 | 原独立详情内展示逐票取货进度 | 共用车辆司机分配;发车、送仓、运输结案使用确认框;680px居中取货弹框填写实提和凭证 |
|
||||
| 平台尾程详情、客户端转运详情 | 原单详情内嵌取货进度 | 只显示本票车辆司机、实提与到仓及凭证;不替代仓内测量状态 |
|
||||
|
||||
取货用途隐藏出库关联、外接预订、装载和余位发布。请求结果未知保留原键与载荷重试,阻止刷新和改派丢失请求。本轮类型检查、构建、单测及本机HTTP验收分别记录,尚未进行人工页面截图视觉验收。
|
||||
|
||||
2026-10-17 车次资源选择修正:沿原详情分配表单及远程下拉框,每次目录搜索后批量读取当前候选的实际占用;显示空闲、当前车次或占用车次号,别车占用不可选。资格与占用分别显示,保存由原锁定分配复验,不在浏览器从当前分页车次推断空闲。占用查询失败不展示未知候选为可用。单测与本机HTTP分别验证,未做截图视觉验收。
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeTripDetail } from './trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
export interface PickupSource { customerId: string; transferNo: string }
|
||||
export interface PickupCandidate extends PickupSource { lastlegOrderNo: string; warehouseId: string; warehouseCode?: string | null; warehouseName?: string | null; pickupAddress: string; contactName: string; contactPhone: string; packageCount: number; expectedArrivalAt: string | null }
|
||||
export interface PickupEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface PickupTask extends Omit<PickupCandidate, 'expectedArrivalAt'> {
|
||||
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
|
||||
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null; evidence: PickupEvidence[]
|
||||
}
|
||||
export interface PickupPlan { sources: PickupSource[]; plannedDepartureAt: string; expectedDriverEndAt: string; remark?: string; idempotencyKey: string }
|
||||
export interface PickupCommand { recordVersion: number; idempotencyKey: string; reason: string }
|
||||
export interface PickupConfirmation { recordVersion: number; idempotencyKey: string; actualPackages: number; evidenceFileIds: string[]; remark?: string }
|
||||
export const pickupStatusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
|
||||
const base = '/platform/v1/last-leg/fleet/pickups'
|
||||
const tripPath = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}`
|
||||
const taskPath = (tripNo: string, row: PickupSource) => `${tripPath(tripNo)}/tasks/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}`
|
||||
type Page = { records: PickupCandidate[]; total: string | number }
|
||||
type WireDetail = Parameters<typeof normalizeTripDetail>[0]
|
||||
|
||||
/** 待取货列表始终以服务端资格和总数为准。 */
|
||||
export async function listPickupCandidates(page: number, size: number, keyword?: string) {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<Page>, ApiEnvelope<Page>>(base, { params: { page, size, keyword } }))
|
||||
return { records: value.records, total: Number(value.total) }
|
||||
}
|
||||
/** 安排成功返回原有车次详情。 */
|
||||
export async function createPickupTrip(body: PickupPlan): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(base, body)))
|
||||
}
|
||||
/** 车次成员一次读取,避免逐行加载。 */
|
||||
export async function getPickupTasks(tripNo: string): Promise<PickupTask[]> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${tripPath(tripNo)}/tasks`))
|
||||
}
|
||||
/** 运输发车与结案沿取货专用规则执行。 */
|
||||
export async function commandPickupTrip(tripNo: string, action: 'depart' | 'complete', body: PickupCommand): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${tripPath(tripNo)}/${action}`, body)))
|
||||
}
|
||||
/** 取货和送仓逐票确认,保留请求键以支持未知结果重试。 */
|
||||
export async function commandPickupTask(tripNo: string, row: PickupSource, action: 'picked' | 'arrive', body: PickupCommand | PickupConfirmation): Promise<PickupTask[]> {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${taskPath(tripNo, row)}/${action}`, body))
|
||||
}
|
||||
/** 文件上传绑定本票与当前车次。 */
|
||||
export async function uploadPickupEvidence(tripNo: string, row: PickupSource, file: File): Promise<PickupEvidence> {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<PickupEvidence>, ApiEnvelope<PickupEvidence>>(`${taskPath(tripNo, row)}/files`, body))
|
||||
}
|
||||
/** 本票凭证通过授权入口读取,永不使用公共文件直链。 */
|
||||
export async function downloadPickupEvidence(row: PickupSource, fileId: string): Promise<Blob> {
|
||||
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(row.transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId: row.customerId }, responseType: 'blob' })
|
||||
}
|
||||
@@ -19,9 +19,4 @@ it('normalizes quote source without overwriting negotiated price and handles man
|
||||
expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25)
|
||||
expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull()
|
||||
expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } })
|
||||
const received = normalizeBooking({ ...row, status: 'RECEIVED', receipt: {
|
||||
source: { incomingNo: 'WT-1', inboundCode: 'IN-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'READY_FOR_DISPATCH', receiptHash: 'merged', receivedPackages: '1', pallets: [{ warehousePalletId: '9007199254740997', palletNo: 'PALLET-MERGED', packages: '1', weightKg: '12.345', volumeCbm: '0.123' }] },
|
||||
pallets: [{ warehousePalletId: '9007199254740997', palletSpaces: '2', weightKg: '12.345', volumeCbm: '0.123' }], operatorId: 'operator', confirmedAt: '',
|
||||
} })
|
||||
expect(received.receipt?.pallets[0]).toEqual({ warehousePalletId: '9007199254740997', palletSpaces: 2, weightKg: 12.345, volumeCbm: 0.123 })
|
||||
})
|
||||
|
||||
@@ -7,15 +7,13 @@ type N = number | string
|
||||
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
|
||||
export type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
|
||||
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N; weightKg?: N | null; volumeCbm?: N | null }[] }
|
||||
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
|
||||
export type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
|
||||
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
|
||||
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
|
||||
const nullable = (value: N | null) => value == null ? null : number(value)
|
||||
const normalizeReceiptPallet = ({ weightKg, volumeCbm, ...row }: WireReceipt['pallets'][number]) => ({ ...row, palletSpaces: integer(row.palletSpaces),
|
||||
...(weightKg === undefined ? {} : { weightKg: nullable(weightKg) }), ...(volumeCbm === undefined ? {} : { volumeCbm: nullable(volumeCbm) }) })
|
||||
export const normalizeIncoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(normalizeReceiptPallet) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
|
||||
packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
|
||||
export const normalizeBookingResult = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
|
||||
export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, getTripResourceOccupancy, bindTripDispatch, unbindTripDispatch, cancelTrip,
|
||||
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
|
||||
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
|
||||
} from './trips'
|
||||
|
||||
@@ -26,15 +26,6 @@ const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
|
||||
describe('fleet trip API contract', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('batches occupancy queries without rounding long resource identifiers', async () => {
|
||||
const occupancy = [{ resourceId: longId, occupiedTripNo: 'TR-OTHER', available: false }]
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(occupancy))
|
||||
expect(await getTripResourceOccupancy('TR/TEST', 'DRIVER', [longId, '9007199254740997'])).toEqual(occupancy)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/resource-occupancy`, { params: { kind: 'DRIVER', resourceIds: `${longId},9007199254740997` } })
|
||||
expect(await getTripResourceOccupancy('TR/TEST', 'VEHICLE', [])).toEqual([])
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses one paged request and keeps long identifiers as exact strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
|
||||
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
|
||||
|
||||
@@ -52,7 +52,7 @@ const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer
|
||||
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
|
||||
const batchReferences = (values: TripBinding['appointmentBatches']) => values.map(reference => ({ ...reference, sources: reference.sources.map(source => ({ ...source, packageCount: integer(source.packageCount) })) }))
|
||||
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), appointmentBatches: batchReferences(value.appointmentBatches), review: review(value.review) })
|
||||
export const normalizeTripDetail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
|
||||
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
|
||||
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
|
||||
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
|
||||
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
|
||||
@@ -71,19 +71,13 @@ export async function listTrips(query: TripQuery): Promise<PageResult<FleetTrip>
|
||||
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
|
||||
}
|
||||
export async function getTrip(tripNo: string): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
|
||||
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
|
||||
}
|
||||
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
|
||||
}
|
||||
export const createTrip = (body: TripDraft) => postDetail(base, body)
|
||||
export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body)
|
||||
export interface TripResourceOccupancy { resourceId: string; occupiedTripNo: string | null; available: boolean }
|
||||
/** 当前下拉候选批量查询实际占用,长 ID 始终按字符串传递。 */
|
||||
export async function getTripResourceOccupancy(tripNo: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]): Promise<TripResourceOccupancy[]> {
|
||||
if (!resourceIds.length) return []
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<TripResourceOccupancy[]>, ApiEnvelope<TripResourceOccupancy[]>>(`${path(tripNo)}/resource-occupancy`, { params: { kind, resourceIds: resourceIds.join(',') } }))
|
||||
}
|
||||
export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body)
|
||||
export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body)
|
||||
export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body)
|
||||
@@ -92,7 +86,7 @@ export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason
|
||||
export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body)
|
||||
export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body)
|
||||
export async function updateTripPlan(tripNo: string, body: TripPlan): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
|
||||
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
|
||||
}
|
||||
const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) })
|
||||
export async function getTripCapacityPlan(tripNo: string): Promise<TripCapacityPlanning> {
|
||||
@@ -127,5 +121,5 @@ export async function listTripDispatchCandidates(tripNo: string, keyword?: strin
|
||||
}
|
||||
export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
|
||||
export async function prepareTrip(body: TripPreparation): Promise<TripDetail> {
|
||||
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
|
||||
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const bookingStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认
|
||||
|
||||
export interface TripIncomingPallet { warehousePalletId: string; palletNo: string; packages: number; weightKg: number | null; volumeCbm: number | null }
|
||||
export interface TripIncomingEvidence { incomingNo: string; inboundCode: string; warehouseId: string; destinationKey: string; status: string; receiptHash: string; pallets: TripIncomingPallet[]; receivedPackages: number }
|
||||
export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number; weightKg?: number | null; volumeCbm?: number | null }
|
||||
export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number }
|
||||
export interface TripAcceptedReceipt { source: TripIncomingEvidence; pallets: TripPalletSpaces[]; operatorId: string; confirmedAt: string }
|
||||
export interface TripIncomingPreview { tripRecordVersion: number; recordVersion: number; incoming: TripIncomingEvidence | null }
|
||||
export interface TripIncomingStart extends Omit<TripBookingCommand, 'reason'> { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null }
|
||||
|
||||
@@ -26,7 +26,7 @@ const followupActions = computed(() => {
|
||||
{ key: 'confirm', label: '核对并确认', visible: manage && (row.actions.canEdit || row.actions.canConfirm) },
|
||||
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit') },
|
||||
{ key: 'receipt', label: '核对实收与运费', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm') },
|
||||
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:dispatch:create') },
|
||||
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch') },
|
||||
{ key: 'fulfillment', label: '查看收货进度', visible: !!row.incomingNo },
|
||||
{ key: 'dispatch', label: '查看排车详情', visible: !!row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list') },
|
||||
{ key: 'trip', label: '前往车次审核', visible: !!row.dispatchNo && access.can('platform:fleet:trip:list') },
|
||||
|
||||
@@ -13,7 +13,7 @@ function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
const items: RowActionItem[] = [{ key: 'view', label: '查看进度', priority: 10 },
|
||||
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
|
||||
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
|
||||
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:dispatch:create'), disabled: busy },
|
||||
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
|
||||
{ key: 'fulfillment', label: '收货进度', visible: !!row.incomingNo, disabled: busy },
|
||||
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import PickupDemandPanel from './PickupDemandPanel.vue'
|
||||
import { listPickupCandidates, createPickupTrip, type PickupCandidate } from '../api/pickups'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const state = vi.hoisted(() => ({ push: vi.fn(), write: true }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }), onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
|
||||
vi.mock('../api/pickups', () => ({ listPickupCandidates: vi.fn(), createPickupTrip: vi.fn() }))
|
||||
const rows: PickupCandidate[] = [1, 2].map(i => ({ customerId: `C${i}`, transferNo: `WT-${i}`, lastlegOrderNo: `LL-${i}`, warehouseId: '9007199254740999', pickupAddress: `TEST Ontario ${i}`, contactName: '取货验收员', contactPhone: '000', packageCount: 5, expectedArrivalAt: null }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
function mount() {
|
||||
const wrapper = shallowMount(PickupDemandPanel, { global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total'], emits: ['selection-change'], template: '<div><slot name="toolbar-actions"/><slot name="notices"/></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElDatePicker: { name: 'ElDatePicker', props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' },
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); state.write = true; vi.mocked(listPickupCandidates).mockResolvedValue({ records: rows, total: 42 }) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server total and rejects combining different destination warehouses', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(42)
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', [rows[0], { ...rows[1], warehouseId: 'OTHER' }]); await flushPromises()
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(false)
|
||||
expect(createPickupTrip).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps exact source identities and the same create request after an unknown network result', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('selection-change', rows); await flushPromises(); await wrapper.find('button').trigger('click')
|
||||
const dates = wrapper.findAllComponents({ name: 'ElDatePicker' })
|
||||
dates[0]!.vm.$emit('update:modelValue', '2026-10-17T08:00:00Z'); dates[1]!.vm.$emit('update:modelValue', '2026-10-18T08:00:00Z'); await flushPromises()
|
||||
vi.mocked(createPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ trip: { tripNo: 'TR-TEST' } } as TripDetail)
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const request = vi.mocked(createPickupTrip).mock.calls[0]![0]
|
||||
expect(request.sources).toEqual(rows.map(({ customerId, transferNo }) => ({ customerId, transferNo })))
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(vi.mocked(createPickupTrip).mock.calls[1]![0]).toBe(request)
|
||||
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
|
||||
})
|
||||
it('does not show scheduling to read-only users', async () => {
|
||||
state.write = false; const wrapper = mount(); await flushPromises()
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPickupCandidates, createPickupTrip, type PickupCandidate, type PickupPlan } from '../api/pickups'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const rows = ref<PickupCandidate[]>([]), selected = ref<PickupCandidate[]>([]), page = ref(1), total = ref(0), loading = ref(false)
|
||||
const filters = ref<QueryModel>({ keyword: '' }), applied = ref(''), error = ref(''), open = ref(false), busy = ref(false)
|
||||
const departure = ref(''), end = ref(''), remark = ref(''), snapshot = ref<PickupCandidate[]>([]), pending = ref<PickupPlan | null>(null)
|
||||
let sequence = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'selection', type: 'selection', width: 45 }, { key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
|
||||
{ key: 'lastlegOrderNo', prop: 'lastlegOrderNo', label: '尾程单号', minWidth: 200 },
|
||||
{ key: 'warehouseId', prop: 'warehouseId', label: '入仓仓库', minWidth: 165, slotName: 'warehouse' },
|
||||
{ key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 280 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '期望入仓时间', minWidth: 175 },
|
||||
]
|
||||
/** 分页或条件变化时清除旧选择,迟到响应不能覆盖新页。 */
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = ''; selected.value = []
|
||||
try { const value = await listPickupCandidates(page.value, 20, applied.value || undefined); if (current === sequence) { rows.value = value.records; total.value = value.total } }
|
||||
catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '待取货需求加载失败').message } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 查询只采用明确提交的条件。 */
|
||||
function search() { applied.value = String(filters.value.keyword || '').trim(); if (page.value !== 1) page.value = 1; else void load() }
|
||||
/** 安排弹框固定本次选单快照。 */
|
||||
function arrange() {
|
||||
if (!selected.value.length) { ElMessage.warning('请勾选待取货订单'); return }
|
||||
if (new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('同车取货必须送往同一海外仓'); return }
|
||||
snapshot.value = [...selected.value]; pending.value = null; departure.value = ''; end.value = ''; remark.value = ''; error.value = ''; open.value = true
|
||||
}
|
||||
/** 网络结果未知时沿原键原内容重试,业务错误后允许修正输入。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!pending.value && (!departure.value || !end.value)) { ElMessage.warning('请填写取货发车时间和预计任务结束时间'); return }
|
||||
pending.value ||= { sources: snapshot.value.map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date(departure.value).toISOString(), expectedDriverEndAt: new Date(end.value).toISOString(), remark: remark.value.trim(), idempotencyKey: crypto.randomUUID() }
|
||||
busy.value = true
|
||||
try { const value = await createPickupTrip(pending.value); open.value = false; pending.value = null; await router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: value.trip.tripNo } }) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '安排取货未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
/** 未知提交结果必须保留原请求,避免关闭后重复建车次。 */
|
||||
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先重试原安排请求,确认结果后再关闭'); return }; done() }
|
||||
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先确认本次安排结果'); return false }; return true })
|
||||
watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<DataTable v-model:page="page" :page-size="20" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="transferNo" empty-text="暂无已审核的待取货订单" @selection-change="selected = $event">
|
||||
<template #filters><QueryToolbar ariaLabel="待取货需求查询" v-model="filters" :fields="[{ key: 'keyword', type: 'input', label: '关键词', placeholder: '转运单号 / 取货地址', width: 300 }]" @search="search" @reset="search" /></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" :disabled="!selected.length" @click="arrange">安排取货({{ selected.length }})</el-button></template>
|
||||
<template #notices><LoadErrorState v-if="error && !open" title="待取货需求加载失败" :message="error" @retry="load" /></template>
|
||||
<template #warehouse="{ row }">{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
|
||||
<p>已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}</p>
|
||||
<el-form label-position="top" class="pickup-plan-form" :disabled="busy || !!pending">
|
||||
<el-form-item label="计划取货发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="司机预计任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="调度备注"><el-input v-model="remark" maxlength="1000" placeholder="请输入调度备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.pickup-plan-form{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}.pickup-plan-form :deep(.el-date-editor){width:100%}.pickup-plan-form>.el-form-item:last-child{grid-column:1/-1}</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import TripAssignmentPanel from './TripAssignmentPanel.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip, getTripResourceOccupancy } from '../api/trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { DriverDetail } from '../driverTypes'
|
||||
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() }))
|
||||
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() }))
|
||||
vi.mock('../api/trips', () => ({ assignTrip: vi.fn(), getTripResourceOccupancy: vi.fn() }))
|
||||
const vehicle: VehicleDetail = { id: '9007199254740999', plateNumber: 'TEST-FREE', model: '26ft', maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45, fuelConsumptionPer100Km: 20, mileageKm: 0, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: '1', updatedAt: '' }
|
||||
const driver: DriverDetail = { id: '9007199254740998', driverNo: 'DR-TEST', details: { name: '验收司机', phone: '000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-12-31', licenseZone: 'UTC', remark: null, profile: null }, active: true, reviewStatus: 'APPROVED', licenseFileId: '1', recordVersion: 0, updatedBy: '1', updatedAt: '', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-12-31' }, licenseState: 'VALID', warehouseCode: null, warehouseName: null }
|
||||
const trip: TripDetail = { trip: { tripNo: 'TR-CURRENT', status: 'PLANNED', warehouseId: '1', warehouseCode: 'WH', warehouseName: '仓库', vehicleId: null, vehiclePlate: null, driverId: null, driverName: null, driverPhone: null, maxPalletSpaces: null, maxLoadKg: null, maxVolumeCbm: null, plannedDepartureAt: '2026-10-18T01:00:00Z', expectedDriverEndAt: '2026-10-18T09:00:00Z', departedAt: null, completedAt: null, stops: [], remark: null, recordVersion: 1, updatedAt: '', vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }, previousDispatches: [], appointmentHistoryUnavailableReason: null }
|
||||
function mount() {
|
||||
return shallowMount(TripAssignmentPanel, { props: { detail: trip }, global: { stubs: {
|
||||
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElSelect: { name: 'ElSelect', props: ['modelValue'], emits: ['focus', 'update:modelValue'], template: '<div><slot/></div>' },
|
||||
ElOption: { name: 'ElOption', props: ['value', 'label', 'disabled'], template: '<span>{{ label }}</span>' },
|
||||
ElButton: { template: '<button><slot/></button>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers(); vi.clearAllMocks()
|
||||
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicle], page: 1, size: 30, total: 1, pages: 1 })
|
||||
vi.mocked(listDrivers).mockResolvedValue({ records: [driver], page: 1, size: 30, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('disables resources held by another trip and shows the occupying trip before saving', async () => {
|
||||
vi.mocked(getTripResourceOccupancy).mockImplementation(async (_, kind, ids) => ids.map(resourceId => ({ resourceId, occupiedTripNo: kind === 'VEHICLE' ? 'TR-OTHER' : 'TR-CURRENT', available: kind === 'DRIVER' })))
|
||||
const wrapper = mount()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
selects.forEach(select => select.vm.$emit('focus')); await vi.advanceTimersByTimeAsync(250); await flushPromises()
|
||||
const options = wrapper.findAllComponents({ name: 'ElOption' })
|
||||
expect(options[0]!.props()).toMatchObject({ value: vehicle.id, disabled: true })
|
||||
expect(options[0]!.props('label')).toContain('被车次 TR-OTHER 占用')
|
||||
expect(options[1]!.props('disabled')).toBe(false)
|
||||
expect(options[1]!.props('label')).toContain('当前车次')
|
||||
expect(getTripResourceOccupancy).toHaveBeenCalledTimes(2)
|
||||
selects[0]!.vm.$emit('update:modelValue', vehicle.id); selects[1]!.vm.$emit('update:modelValue', driver.id); await flushPromises()
|
||||
await wrapper.find('button').trigger('click'); expect(assignTrip).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not expose directory candidates as free when occupancy lookup fails', async () => {
|
||||
vi.mocked(getTripResourceOccupancy).mockRejectedValue(new Error('查询占用失败'))
|
||||
const wrapper = mount(); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('focus')
|
||||
await vi.advanceTimersByTimeAsync(250); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { listVehicles } from '../api/overseasMasterData'
|
||||
import { listDrivers } from '../api/drivers'
|
||||
import { assignTrip, getTripResourceOccupancy, type TripResourceOccupancy } from '../api/trips'
|
||||
import { assignTrip } from '../api/trips'
|
||||
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
|
||||
import type { VehicleDetail } from '../types'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
@@ -14,64 +14,41 @@ import type { TripDetail } from '../tripTypes'
|
||||
const props = defineProps<{ detail: TripDetail }>()
|
||||
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||||
const vehicleOccupancy = ref<Record<string, TripResourceOccupancy>>({}), driverOccupancy = ref<Record<string, TripResourceOccupancy>>({})
|
||||
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
|
||||
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let vehicleRequest = 0, driverRequest = 0, generation = 0
|
||||
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
|
||||
/** 本车占用可保留,其他车次占用显示具体业务号并禁止选择。 */
|
||||
function occupancyLabel(row: TripResourceOccupancy | undefined): string {
|
||||
if (!row) return ' · 占用待查询'
|
||||
if (!row.available) return ` · 被车次 ${row.occupiedTripNo} 占用`
|
||||
return row.occupiedTripNo ? ' · 当前车次' : ' · 空闲'
|
||||
}
|
||||
watch(() => props.detail.trip, trip => {
|
||||
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
|
||||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||||
vehicleOccupancy.value = {}; driverOccupancy.value = {}
|
||||
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
|
||||
}, { immediate: true })
|
||||
/** 下拉搜索先批量核对占用,失败不把未知状态的资源当成空闲。 */
|
||||
function searchVehicles(keyword = '') {
|
||||
clearTimeout(vehicleTimer); const request = ++vehicleRequest
|
||||
vehicles.value = []; vehicleOccupancy.value = {}; vehicleLoading.value = true
|
||||
vehicleTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' })
|
||||
if (request !== vehicleRequest) return
|
||||
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'VEHICLE', result.records.map(row => row.id))
|
||||
if (request === vehicleRequest) { vehicleOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); vehicles.value = result.records }
|
||||
}
|
||||
vehicleLoading.value = true
|
||||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
|
||||
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
|
||||
finally { if (request === vehicleRequest) vehicleLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
/** 证照合格与资源空闲分别展示,不能用可派车资格代替未占用。 */
|
||||
function searchDrivers(keyword = '') {
|
||||
clearTimeout(driverTimer); const request = ++driverRequest
|
||||
drivers.value = []; driverOccupancy.value = {}; driverLoading.value = true
|
||||
driverTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt })
|
||||
if (request !== driverRequest) return
|
||||
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'DRIVER', result.records.map(row => row.id))
|
||||
if (request === driverRequest) { driverOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); drivers.value = result.records }
|
||||
}
|
||||
driverLoading.value = true
|
||||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
|
||||
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
|
||||
finally { if (request === driverRequest) driverLoading.value = false }
|
||||
}, 250)
|
||||
}
|
||||
/** 候选阶段挡住已知占用,保存仍由后端锁定复验以防同时被其他人分配。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
|
||||
if (vehicleLoading.value || driverLoading.value) { ElMessage.warning('请等待车辆和司机占用查询完成'); return }
|
||||
if (vehicleOccupancy.value[vehicleId.value]?.available === false || driverOccupancy.value[driverId.value]?.available === false) { ElMessage.warning('所选车辆或司机已被其他车次占用,请重新选择'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
|
||||
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
|
||||
if (current === generation) { emit('saved', result); ElMessage.success(props.detail.trip.tripPurpose === 'PICKUP' ? '车辆和司机已分配,可安排取货发车' : '车辆和司机已分配,请重新核对装载') }
|
||||
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
@@ -83,11 +60,11 @@ onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTi
|
||||
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
|
||||
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
|
||||
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :disabled="vehicleOccupancy[vehicle.id]?.available !== true" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位${occupancyLabel(vehicleOccupancy[vehicle.id])}`" />
|
||||
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
|
||||
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :disabled="driverOccupancy[driver.id]?.available !== true || !driver.qualification.canDispatch" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}${occupancyLabel(driverOccupancy[driver.id])}`" />
|
||||
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
|
||||
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
|
||||
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
|
||||
import { ElInput } from 'element-plus/es/components/input/index'
|
||||
@@ -9,12 +9,9 @@ import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const writable = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => writable.value }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
|
||||
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
|
||||
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
|
||||
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
|
||||
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
|
||||
@@ -24,12 +21,12 @@ function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/><slot name="weightKg" :row="row"/><slot name="volumeCbm" :row="row"/></div></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
|
||||
ElButton: { template: '<button><slot/></button>' }, LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.resetAllMocks(); writable.value = true; vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
|
||||
@@ -118,82 +115,3 @@ it('compares real receipt with the forecast, preserves unknown values, and does
|
||||
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires explicit acceptance of a changed received snapshot and submits the current complete pallets and versions', async () => {
|
||||
const previous = { ...booking, status: 'RECEIVED' as const, receipt: { source: { ...preview.incoming!, receiptHash: 'previous-hash' }, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }], operatorId: 'operator', confirmedAt: '2026-10-01T00:00:00Z' } }
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 11, recordVersion: 8, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-hash', pallets: [
|
||||
{ warehousePalletId: '903', palletNo: 'PLT-MERGED', packages: 22, weightKg: 150, volumeCbm: 1.5 },
|
||||
] } })
|
||||
const wrapper = mount('receipt'); await wrapper.setProps({ booking: previous }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('上次核实与当前实收对照')
|
||||
expect(wrapper.text()).not.toContain('无法还原原始预报')
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 12, booking: { ...previous, recordVersion: 9 }, replayed: false })
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenLastCalledWith(expect.stringContaining('50.00 USD'), '确认实收差异', expect.any(Object))
|
||||
expect(api.acceptTripIncoming).toHaveBeenCalledExactlyOnceWith('TR-1', 'FB-1', { tripRecordVersion: 11, recordVersion: 8, idempotencyKey: expect.any(String), receiptHash: 'merged-hash', unitPrice: 50, pallets: [{ warehousePalletId: '903', palletSpaces: 1 }] })
|
||||
})
|
||||
|
||||
it('uses a new key after a completed confirmation while preserving the original key for unknown retries', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 6, recordVersion: 3, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'new-hash' } })
|
||||
await wrapper.setProps({ modelValue: true, booking: { ...booking, status: 'RECEIVED', recordVersion: 3 } }); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
const next = vi.mocked(api.acceptTripIncoming).mock.calls[1]![2]
|
||||
expect(next.idempotencyKey).not.toBe(first.idempotencyKey)
|
||||
expect(next).toMatchObject({ tripRecordVersion: 6, recordVersion: 3, receiptHash: 'new-hash' })
|
||||
})
|
||||
|
||||
it('does not report success when the server rejects the updated receipt capacity', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockRejectedValue(new ApiResponseError('当前车次容量不足,请重新核对', 'A0409', 409, null, 'capacity-request'))
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(ElMessage.success).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toContain('当前车次容量不足')
|
||||
expect((wrapper.vm as unknown as { error: { requestId: string } }).error).toMatchObject({ requestId: 'capacity-request' })
|
||||
})
|
||||
|
||||
it.each(['bound', 'departed', 'permission'] as const)('rejects a receipt write if the context changes to %s after opening', async context => {
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
if (context === 'bound') await wrapper.setProps({ booking: { ...booking, status: 'BOUND', dispatchNo: 'DS-1' } })
|
||||
if (context === 'departed') await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } })
|
||||
if (context === 'permission') {
|
||||
writable.value = false
|
||||
await wrapper.setProps({ booking: { ...booking, recordVersion: 3 } })
|
||||
}
|
||||
await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('requires new measurements for changed pallet composition and keeps them in the exact unknown-result retry', async () => {
|
||||
const merged = { ...preview, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletNo: 'MERGED-1', packages: 22, weightKg: null, volumeCbm: null }] } }
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue(merged)
|
||||
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
|
||||
const wrapper = mount('receipt'); await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } }); await flushPromises()
|
||||
const confirm = () => wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm')
|
||||
confirm(); await flushPromises()
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('重新填写'))
|
||||
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
|
||||
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测重量')!.vm.$emit('update:modelValue', 123.456)
|
||||
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测体积')!.vm.$emit('update:modelValue', 1.234)
|
||||
await flushPromises(); confirm(); await flushPromises()
|
||||
const original = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
|
||||
expect(original).toMatchObject({ receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletSpaces: 1, weightKg: 123.456, volumeCbm: 1.234 }] })
|
||||
confirm(); await flushPromises()
|
||||
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
|
||||
})
|
||||
|
||||
it('does not offer measurement overrides for unchanged source pallets', async () => {
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).some(field => /实测重量|实测体积/.test(field.attributes('aria-label') || ''))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -14,46 +12,36 @@ import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'> & Partial<Pick<FleetTrip, 'status'>>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
|
||||
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
|
||||
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
|
||||
const measurements = reactive({ weightKg: {} as Record<string, number | undefined>, volumeCbm: {} as Record<string, number | undefined> })
|
||||
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
|
||||
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, measurements, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
|
||||
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
|
||||
defineExpose({ hasPending: computed(() => !!pending.value) })
|
||||
let generation = 0
|
||||
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
|
||||
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '确认排入本车', view: '外接来货详情' }
|
||||
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
|
||||
const receiptAllowed = computed(() => props.pool || props.trip.status === 'PLANNED' && !!props.booking
|
||||
&& ['CONFIRMED', 'RECEIVED'].includes(props.booking.status) && !!props.booking.incomingNo && !props.booking.dispatchNo && access.can(permission.tripBookingConfirm))
|
||||
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
|
||||
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const receiptComparison = computed(() => {
|
||||
const incoming = preview.value?.incoming
|
||||
const pallets = incoming?.pallets || []
|
||||
const previous = props.booking?.status === 'RECEIVED' ? props.booking.receipt : null
|
||||
const forecast = props.booking?.status === 'CONFIRMED' || previous ? props.booking?.cargo : null
|
||||
const measured = (row: typeof pallets[number], key: 'weightKg' | 'volumeCbm') => row[key] ?? measurements[key][row.warehousePalletId]
|
||||
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => measured(row, key) != null && Number.isFinite(measured(row, key)))
|
||||
? pallets.reduce((total, row) => total + measured(row, key)!, 0) : null
|
||||
const forecast = props.booking?.status === 'CONFIRMED' ? props.booking.cargo : null
|
||||
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => row[key] != null && Number.isFinite(row[key]))
|
||||
? pallets.reduce((total, row) => total + row[key]!, 0) : null
|
||||
const values = [
|
||||
{ label: '箱数', expected: previous?.source.receivedPackages ?? forecast?.packageCount, actual: incoming?.receivedPackages },
|
||||
{ label: '实托数', expected: previous ? previous.source.pallets.length : forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
|
||||
{ label: '标准托位(待核对)', expected: previous ? previous.pallets.reduce((total, row) => total + row.palletSpaces, 0) : forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
|
||||
{ label: '箱数', expected: forecast?.packageCount, actual: incoming?.receivedPackages },
|
||||
{ label: '实托数', expected: forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
|
||||
{ label: '标准托位(待核对)', expected: forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
|
||||
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
|
||||
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
|
||||
]
|
||||
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
|
||||
})
|
||||
const receiptChanged = computed(() => receiptComparison.value.some(row => row.difference != null && row.difference !== 0)
|
||||
|| unitPrice.value !== props.booking?.cargo.unitPrice
|
||||
|| !!props.booking?.receipt && (props.booking.receipt.source.receiptHash !== preview.value?.incoming?.receiptHash
|
||||
|| props.booking.receipt.pallets.some(row => spaces[row.warehousePalletId] !== row.palletSpaces)))
|
||||
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
|
||||
const incomingStatusLabel = computed(() => {
|
||||
const status = preview.value?.incoming?.status
|
||||
@@ -61,18 +49,15 @@ const incomingStatusLabel = computed(() => {
|
||||
return statuses[status || ''] || '等待读取'
|
||||
})
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'weightKg', singleLine: false, showOverflowTooltip: false } : {}) }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150, ...(props.mode === 'receipt' ? { slotName: 'volumeCbm', singleLine: false, showOverflowTooltip: false } : {}) },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
|
||||
function resetMeasurements() {
|
||||
for (const field of ['weightKg', 'volumeCbm'] as const) Object.keys(measurements[field]).forEach(key => delete measurements[field][key])
|
||||
}
|
||||
async function load() {
|
||||
if (!props.booking) return
|
||||
const current = generation; loading.value = true; error.value = null
|
||||
try {
|
||||
const value = props.pool ? (await getInquiryFulfillment(props.booking.bookingNo)).preview : await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
|
||||
if (current !== generation) return
|
||||
preview.value = value; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
await nextTick(); guard.capture()
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
|
||||
@@ -85,7 +70,7 @@ watch(() => [open.value, target.value], () => {
|
||||
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
|
||||
Object.assign(form, { estimatedArrivalAt: props.arrivalAt || '', deliveryInstructions: props.instructions || props.booking.cargo.remark || '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
|
||||
if (props.initialPreview && (props.mode === 'receipt' || props.mode === 'view')) {
|
||||
preview.value = props.initialPreview; resetMeasurements(); Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
preview.value = props.initialPreview; Object.keys(spaces).forEach(key => delete spaces[key])
|
||||
for (const pallet of props.initialPreview.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
|
||||
void nextTick(() => guard.capture())
|
||||
} else if (props.mode === 'receipt' || props.mode === 'view') void load()
|
||||
@@ -93,7 +78,6 @@ watch(() => [open.value, target.value], () => {
|
||||
}, { immediate: true })
|
||||
async function save() {
|
||||
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
|
||||
if (props.mode === 'receipt' && !receiptAllowed.value) return
|
||||
const current = generation, booking = props.booking, mode = props.mode
|
||||
if (!pending.value) {
|
||||
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
|
||||
@@ -104,19 +88,8 @@ async function save() {
|
||||
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
|
||||
} else {
|
||||
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
|
||||
if (preview.value.incoming.pallets.some(row => ['weightKg', 'volumeCbm'].some(key => {
|
||||
const field = key as 'weightKg' | 'volumeCbm', value = row[field] ?? measurements[field][row.warehousePalletId]
|
||||
return value == null || !Number.isFinite(value) || value <= 0
|
||||
}))) { ElMessage.warning('拆合托后的来源须重新填写该票货物在每托的实测重量和体积,不沿用旧托测量'); return }
|
||||
if (receiptChanged.value) {
|
||||
busy.value = true
|
||||
try { await ElMessageBox.confirm(`当前实托、货量、托位或约定价格存在变化,确认按当前完整实收和 ${amount.value?.toFixed(2)} ${booking.cargo.currency || ''} 核实?容量仍由服务端复核,超容量不会保存。`, '确认实收差异', { customClass: 'admin-density-confirm', confirmButtonText: '确认当前实收与运费', cancelButtonText: '返回核对', type: 'warning' }) }
|
||||
catch { return }
|
||||
finally { busy.value = false }
|
||||
if (current !== generation || !receiptAllowed.value) return
|
||||
}
|
||||
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
|
||||
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]!, ...(row.weightKg == null ? { weightKg: measurements.weightKg[row.warehousePalletId]! } : {}), ...(row.volumeCbm == null ? { volumeCbm: measurements.volumeCbm[row.warehousePalletId]! } : {}) })) } }
|
||||
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]! })) } }
|
||||
}
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
@@ -132,7 +105,7 @@ async function save() {
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && (!receiptAllowed || (!pending && !ready)))" :confirm-loading="busy" @confirm="save">
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (!pending && mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-if="booking" v-loading="loading">
|
||||
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
|
||||
@@ -142,13 +115,12 @@ onBeforeUnmount(() => { generation++ })
|
||||
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
|
||||
</el-form>
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<p v-if="mode === 'receipt' && !receiptAllowed" role="alert">当前车次或预订不能核对实收,请刷新并确认车次仍在计划中、预订未绑定排车且具有核对实收权限。</p>
|
||||
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template><template v-for="field in (['weightKg', 'volumeCbm'] as const)" #[field]="{ row }"><div v-if="row[field] == null" class="receipt-spaces-control"><el-input-number v-model="measurements[field][row.warehousePalletId]" :min="0.001" :max="999999999.999" :precision="3" :disabled="busy || !!pending" :aria-label="`${row.palletNo} ${field === 'weightKg' ? '实测重量' : '实测体积'}`" placeholder="重新实测该票货物" /></div><span v-else>{{ row[field] }}</span></template></DataTable>
|
||||
<p v-if="mode === 'receipt'">托盘组成变化后须填写该票外接货的当前实测重量、体积;未变化托盘只读沿用原收货证据,不填写整托混合货的总量。</p><section v-if="mode === 'receipt'" aria-label="预报与实收对照">
|
||||
<p>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实与当前实收对照' : '预报与实收对照' }};差异须明确确认,容量与费用仍由服务端核对。</p>
|
||||
<p v-if="booking.status !== 'CONFIRMED' && !booking.receipt">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
|
||||
<table class="density-table"><thead><tr><th>项目</th><th>{{ booking.status === 'RECEIVED' && booking.receipt ? '上次核实' : '承运预报' }}</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template></DataTable>
|
||||
<section v-if="mode === 'receipt'" aria-label="预报与实收对照">
|
||||
<p>预报与实收对照;差异仅供核对,容量与费用仍需确认。</p>
|
||||
<p v-if="booking.status !== 'CONFIRMED'">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
|
||||
<table class="density-table"><thead><tr><th>项目</th><th>承运预报</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
|
||||
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ElInput } from 'element-plus/es/components/input/index'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingPanel from './TripBookingPanel.vue'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking } from '../bookingTypes'
|
||||
@@ -148,30 +147,3 @@ it('keeps manual entry available when candidate lookup fails and hides lookup wi
|
||||
const denied = mount(); await flushPromises(); await button(denied, '编辑').trigger('click'); await flushPromises()
|
||||
expect(denied.findAll('button').some(node => node.text() === '查询报价库')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['CONFIRMED', 'RECEIVED'] as const)('opens the existing receipt confirmation for an unbound %s booking', async status => {
|
||||
const current = { ...row, status, incomingNo: 'WT-1', recordVersion: 7 }
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 9, records: [current] })
|
||||
const wrapper = mount(); await flushPromises()
|
||||
await button(wrapper, '核对实收').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(TripBookingFulfillmentDialog).props()).toMatchObject({ modelValue: true, mode: 'receipt', booking: current })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ status: 'BOUND' as const, dispatchNo: 'DS-1', planned: true, permitted: true },
|
||||
{ status: 'RECEIVED' as const, dispatchNo: 'DS-1', planned: true, permitted: true },
|
||||
{ status: 'RECEIVED' as const, dispatchNo: null, planned: false, permitted: true },
|
||||
{ status: 'RECEIVED' as const, dispatchNo: null, planned: true, permitted: false },
|
||||
{ status: 'CANCELLED' as const, dispatchNo: null, planned: true, permitted: true },
|
||||
])('blocks receipt rechecking for $status / bound=$dispatchNo / planned=$planned / permitted=$permitted', async scenario => {
|
||||
access.writable = scenario.permitted
|
||||
const current = { ...row, status: scenario.status, incomingNo: 'WT-1', dispatchNo: scenario.dispatchNo }
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [current] })
|
||||
const wrapper = mount()
|
||||
if (!scenario.planned) await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '核对实收')).toBe(false)
|
||||
const state = wrapper.vm as unknown as { fulfill: (booking: TripBooking, mode: 'receipt') => void }
|
||||
state.fulfill(current, 'receipt'); await flushPromises()
|
||||
expect(wrapper.findComponent(TripBookingFulfillmentDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -18,13 +18,7 @@ const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function canConfirmReceipt(row: TripBooking) {
|
||||
return planned.value && ['CONFIRMED', 'RECEIVED'].includes(row.status) && !!row.incomingNo && !row.dispatchNo && access.can(permission.tripBookingConfirm)
|
||||
}
|
||||
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
if (busy.value || mode === 'receipt' && !canConfirmReceipt(row)) return
|
||||
fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true
|
||||
}
|
||||
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') { fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true }
|
||||
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
|
||||
const readOnly = ref(false)
|
||||
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
|
||||
@@ -151,7 +145,7 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
|
||||
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
|
||||
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="canConfirmReceipt(row as TripBooking)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
|
||||
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
|
||||
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import type { EChartsOption, LegendComponentOption, PieSeriesOption } from 'echarts'
|
||||
import TripOperationsCharts from './TripOperationsCharts.vue'
|
||||
import type { TripOperationsCharts as Charts, TripOperationsSummary } from '../api/tripOperations'
|
||||
|
||||
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option', 'initOptions', 'updateOptions', 'autoresize'], template: '<div class="echarts-pie-stub"/>' } }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
const emptyCharts = (): Charts => ({ physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] })
|
||||
const summary = (): TripOperationsSummary => ({ trips: 20, inTransit: 5, completed: 15, physicalPallets: 90, palletSpaces: 100, ownPackages: 50, externalPackages: 20, externalAgreedAmounts: [{ currency: 'CNY', amount: 390 }, { currency: 'USD', amount: 100 }], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } })
|
||||
function render(charts: Charts, totals = summary()) {
|
||||
const wrapper = mount(TripOperationsCharts, { props: { charts, summary: totals }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot/></button>' },
|
||||
} } })
|
||||
wrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
const option = (wrapper: VueWrapper, selector: string) => wrapper.find(selector).findComponent({ name: 'VChart' }).props('option') as EChartsOption
|
||||
const series = (options: EChartsOption) => (options.series as PieSeriesOption[])[0]!
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
|
||||
it('uses four ECharts pies with compact native legends, accurate server totals and daily rates', () => {
|
||||
const charts = emptyCharts()
|
||||
charts.physicalPallets = [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-02', value: 10 }] }]
|
||||
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: [{ label: 'ONT8', value: 3 }, { label: 'ABE2', value: 8 }] }]
|
||||
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }]
|
||||
charts.externalPalletRates = [{ label: 'CNY', unit: 'CNY/标准托位', points: [{ label: '2026-10-07', value: 30 }, { label: '2026-10-02', value: 90 }] }]
|
||||
const original = structuredClone(charts)
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.findAllComponents({ name: 'VChart' })).toHaveLength(4)
|
||||
for (const chart of wrapper.findAllComponents({ name: 'VChart' })) {
|
||||
expect(series(chart.props('option'))).toMatchObject({ type: 'pie', label: { show: false } })
|
||||
expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
|
||||
expect(chart.props('updateOptions')).toEqual({ notMerge: true })
|
||||
}
|
||||
expect(wrapper.find('svg').exists()).toBe(false)
|
||||
expect(wrapper.find('.series-caption').exists()).toBe(false)
|
||||
expect(wrapper.find('.chart > .chart-note').exists()).toBe(false)
|
||||
expect(wrapper.find('.chart--pallets .chart-metric').text()).toBe('90 托')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('CNY/标准托位 30.00')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').attributes('title')).toContain('最近统计日:2026-10-07')
|
||||
const legend = option(wrapper, '.chart--pallets').legend as LegendComponentOption
|
||||
expect(legend.icon).toBe('circle')
|
||||
expect(typeof legend.formatter === 'function' && legend.formatter('2026-10-02')).toBe('10-02')
|
||||
expect(option(wrapper, '.chart--pallets').tooltip).toMatchObject({ trigger: 'item', confine: true, renderMode: 'richText' })
|
||||
expect(series(option(wrapper, '.chart--rates')).data).toEqual([
|
||||
{ name: '2026-10-02', value: 90, itemStyle: { color: '#3b82f6' } },
|
||||
{ name: '2026-10-07', value: 30, itemStyle: { color: '#14b8a6' } },
|
||||
])
|
||||
expect(charts).toEqual(original)
|
||||
})
|
||||
|
||||
it('switches currency without combining monetary amounts or leaving stale ECharts slices', async () => {
|
||||
const charts = emptyCharts()
|
||||
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }, { label: 'USD', unit: 'USD', points: [{ label: '2026-10-03', value: 100 }] }]
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
|
||||
expect(wrapper.find('.chart--amounts').findAllComponents({ name: 'VChart' })).toHaveLength(1)
|
||||
const usd = wrapper.findAll('.chart--amounts .series-selector button').find(button => button.text() === 'USD')!
|
||||
await usd.trigger('click')
|
||||
expect(usd.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('USD 100.00')
|
||||
expect(series(option(wrapper, '.chart--amounts')).data).toEqual([{ name: '2026-10-03', value: 100, itemStyle: { color: '#3b82f6' } }])
|
||||
await wrapper.setProps({ charts: { ...charts, externalAgreedAmounts: [charts.externalAgreedAmounts[0]!] } })
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
|
||||
})
|
||||
|
||||
it('keeps every destination in its pie and exposes full legend data only on demand', async () => {
|
||||
const charts = emptyCharts()
|
||||
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: Array.from({ length: 7 }, (_, index) => ({ label: `FBA-${index}`, value: index + 1 })) }]
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.find('.chart--destinations .chart-metric').text()).toBe('7 个目的地')
|
||||
const options = option(wrapper, '.chart--destinations')
|
||||
expect(series(options).data).toHaveLength(7)
|
||||
expect((options.legend as LegendComponentOption).data).toHaveLength(5)
|
||||
expect(wrapper.find('.chart-details').exists()).toBe(false)
|
||||
await wrapper.find('.chart-more').trigger('click')
|
||||
expect(wrapper.emitted('show-details')).toHaveLength(1)
|
||||
await wrapper.setProps({ detailsVisible: true })
|
||||
expect(wrapper.find('[aria-label="派送目的地明细"]').findAll('tbody tr')).toHaveLength(7)
|
||||
expect(wrapper.find('[aria-label="派送目的地明细"]').text()).toContain('FBA-0')
|
||||
})
|
||||
|
||||
it('preserves known zeros and does not draw equal positive shares for an empty sum', () => {
|
||||
const charts = emptyCharts()
|
||||
charts.physicalPallets = [{ label: '托盘', unit: '托', points: [{ label: '2026-10-01', value: 0 }, { label: '2026-10-02', value: 2 }] }]
|
||||
charts.externalPalletRates = [{ label: 'USD', unit: 'USD/标准托位', points: [{ label: '2026-10-01', value: 0 }] }]
|
||||
const wrapper = render(charts)
|
||||
expect(series(option(wrapper, '.chart--rates'))).toMatchObject({ stillShowZeroSum: false, showEmptyCircle: true, data: [{ name: '2026-10-01', value: 0 }] })
|
||||
expect(option(wrapper, '.chart--rates').graphic).toMatchObject([{ style: { text: '暂无正值数据' } }])
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('0.00')
|
||||
expect(series(option(wrapper, '.chart--pallets')).data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows missing amounts and rates as unknown and omits empty chart widgets', () => {
|
||||
const wrapper = render(emptyCharts(), { ...summary(), physicalPallets: 0, externalAgreedAmounts: [] })
|
||||
expect(wrapper.findAll('.chart-empty')).toHaveLength(4)
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('—')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toBe('—')
|
||||
expect(wrapper.findComponent({ name: 'VChart' }).exists()).toBe(false)
|
||||
})
|
||||
@@ -1,140 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { AriaComponent, GraphicComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import type { TripOperationsCharts, TripOperationsSeries, TripOperationsSummary } from '../api/tripOperations'
|
||||
|
||||
use([PieChart, LegendComponent, TooltipComponent, GraphicComponent, AriaComponent, CanvasRenderer])
|
||||
const props = withDefaults(defineProps<{ charts: TripOperationsCharts; summary: TripOperationsSummary; detailsVisible?: boolean }>(), { detailsVisible: false })
|
||||
const emit = defineEmits<{ 'show-details': [] }>()
|
||||
const selectedSeries = ref<Record<string, string>>({})
|
||||
const palette = ['#3b82f6', '#14b8a6', '#f59e0b', '#8b5cf6', '#f46565', '#06b6d4', '#ec4899', '#84cc16', '#6366f1', '#f97316']
|
||||
import { computed } from 'vue'
|
||||
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
|
||||
const props = defineProps<{ charts: TripOperationsCharts }>()
|
||||
const sections = computed(() => [
|
||||
{ key: 'pallets', title: '实运托盘', note: '按实际发车日(UTC)分布,物理托盘去重', series: props.charts.physicalPallets, money: false },
|
||||
{ key: 'destinations', title: '派送目的地', note: '按目的地涉及车次分布,同车多站可分别计入', series: props.charts.destinationTrips, money: false },
|
||||
{ key: 'amounts', title: '外接约定金额', note: '按日期分布,各币种独立展示,不代表应收或实收', series: props.charts.externalAgreedAmounts, money: true },
|
||||
{ key: 'rates', title: '外接按托报价均价', note: '按日均价数值对比,顶部为最近统计日均价', series: props.charts.externalPalletRates, money: true },
|
||||
].map(section => {
|
||||
const series = section.series.filter(series => series.points.length).map(series => ({
|
||||
...series,
|
||||
points: [...series.points].sort((a, b) => section.key === 'destinations' ? b.value - a.value || a.label.localeCompare(b.label) : a.label.localeCompare(b.label)),
|
||||
}))
|
||||
return { ...section, series, active: series.find(series => series.label === selectedSeries.value[section.key]) ?? series[0] ?? null }
|
||||
}))
|
||||
const destinationCount = computed(() => new Set(props.charts.destinationTrips.flatMap(series => series.points.map(point => point.label))).size)
|
||||
const hasPoints = computed(() => sections.value.some(section => section.series.length))
|
||||
const number = (value: number, money = false) => value.toLocaleString('zh-CN', { minimumFractionDigits: money ? 2 : 0, maximumFractionDigits: 2 })
|
||||
const color = (index: number) => palette[index % palette.length]!
|
||||
const seriesTitle = (series: TripOperationsSeries) => series.label === series.unit ? series.label : `${series.label}(${series.unit})`
|
||||
const amount = (currency: string) => props.summary.externalAgreedAmounts.find(amount => amount.currency === currency)
|
||||
const legendLabel = (label: string) => /^\d{4}-\d{2}-\d{2}$/.test(label) ? label.slice(5) : label
|
||||
|
||||
function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption {
|
||||
const hasPositive = series.points.some(point => point.value > 0)
|
||||
return {
|
||||
animation: false,
|
||||
aria: { enabled: true, description: `${seriesTitle(series)},完整日期、图例和数值可在统计详情读取` },
|
||||
tooltip: {
|
||||
trigger: 'item', confine: true, renderMode: 'richText',
|
||||
formatter: params => {
|
||||
if (Array.isArray(params)) return ''
|
||||
return `${params.name}\n${typeof params.value === 'number' ? number(params.value, money) : '—'} ${series.unit}`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
type: 'plain', left: 'center', bottom: 4, width: series.points.length > 5 ? '82%' : '96%',
|
||||
icon: 'circle', itemWidth: 9, itemHeight: 9, itemGap: 10, selectedMode: false,
|
||||
textStyle: { color: '#8492a6', fontSize: 12 },
|
||||
data: series.points.slice(0, 5).map(point => point.label), formatter: legendLabel,
|
||||
tooltip: { show: true, confine: true },
|
||||
},
|
||||
series: [{
|
||||
name: seriesTitle(series), type: 'pie', radius: 64, center: ['50%', 72],
|
||||
label: { show: false }, labelLine: { show: false },
|
||||
itemStyle: { borderColor: '#fff', borderWidth: 2 },
|
||||
emphasis: { scale: false, focus: 'self' },
|
||||
stillShowZeroSum: false, showEmptyCircle: true,
|
||||
emptyCircleStyle: { color: '#f3f4f6', borderWidth: 0 },
|
||||
data: series.points.map((point, index) => ({ name: point.label, value: point.value, itemStyle: { color: color(index) } })),
|
||||
}],
|
||||
graphic: hasPositive ? [] : [{ type: 'text', left: 'center', top: 66, style: { text: '暂无正值数据', fill: '#8492a6', fontSize: 12 } }],
|
||||
}
|
||||
}
|
||||
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
|
||||
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
|
||||
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
|
||||
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
|
||||
])
|
||||
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="operations-visualizations">
|
||||
<section class="operations-charts" aria-label="出车统计饼图">
|
||||
<article v-for="section in sections" :key="section.key" class="chart" :class="`chart--${section.key}`" :aria-label="section.title">
|
||||
<h3 :title="section.note">{{ section.title }}</h3>
|
||||
<div class="chart-metric" :title="section.key === 'rates' && section.active ? `最近统计日:${section.active.points[section.active.points.length - 1]!.label};${section.note}` : section.note">
|
||||
<strong v-if="section.key === 'pallets'">{{ number(summary.physicalPallets) }} <small>托</small></strong>
|
||||
<strong v-else-if="section.key === 'destinations'">{{ number(destinationCount) }} <small>个目的地</small></strong>
|
||||
<template v-else-if="section.key === 'amounts'">
|
||||
<strong v-if="section.active && amount(section.active.label)"><small>{{ section.active.label }}</small> {{ number(amount(section.active.label)!.amount, true) }}</strong>
|
||||
<strong v-else>—</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong v-if="section.active"><small>{{ section.active.unit }}</small> {{ number(section.active.points[section.active.points.length - 1]!.value, true) }}</strong>
|
||||
<strong v-else>—</strong>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="section.series.length > 1" class="series-selector" role="group" :aria-label="`${section.title}币种或系列`">
|
||||
<el-button v-for="series in section.series" :key="series.label" size="small" :type="section.active?.label === series.label ? 'primary' : 'default'" :aria-pressed="section.active?.label === series.label" @click="selectedSeries[section.key] = series.label">{{ series.label }}</el-button>
|
||||
</div>
|
||||
<template v-if="section.active">
|
||||
<div class="chart-visualization">
|
||||
<VChart class="pie-chart" :option="pieOption(section.active, section.money)" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: true }" autoresize :aria-label="`${section.title},完整图例和数值见统计详情`" />
|
||||
<el-button v-if="section.active.points.length > 5" class="chart-more" link :aria-label="`查看其余 ${section.active.points.length - 5} 项图例及数值`" @click="emit('show-details')">+{{ section.active.points.length - 5 }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="chart-empty">暂无对应统计</p>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="detailsVisible && hasPoints" class="chart-details" aria-label="完整统计明细">
|
||||
<div class="chart-details-grid">
|
||||
<section v-for="section in sections" :key="section.key" :aria-label="`${section.title}明细`">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p class="chart-note">{{ section.note }}</p>
|
||||
<p v-if="section.key === 'rates'" class="chart-note">各日均价按确认计价托位加权,排除整单报价;不将每日均价相加或简单平均为期间均价。</p>
|
||||
<p v-if="!section.series.length" class="chart-note">暂无对应统计</p>
|
||||
<table v-for="(series, seriesIndex) in section.series" :key="`${series.label}-${seriesIndex}`" class="density-table">
|
||||
<caption>{{ seriesTitle(series) }}</caption>
|
||||
<thead><tr><th scope="col">{{ section.key === 'destinations' ? '目的地' : '日期(UTC)' }}</th><th scope="col">数值</th></tr></thead>
|
||||
<tbody><tr v-for="(point, pointIndex) in series.points" :key="point.label"><td><span class="legend-dot" :style="{ background: color(pointIndex) }" aria-hidden="true" /> {{ point.label }}</td><td>{{ number(point.value, section.money) }}</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
|
||||
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
|
||||
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
|
||||
<div class="chart-values"><template v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><div v-if="series.points.length"><b>{{ series.label }}({{ series.unit }})</b>
|
||||
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
|
||||
</div></template></div>
|
||||
</article></section></template>
|
||||
<style scoped>
|
||||
.operations-visualizations { display: grid; gap: var(--admin-space-3); min-width: 0; }
|
||||
.operations-charts, .chart-details-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-6); }
|
||||
.chart { min-width: 0; padding-right: var(--admin-space-6); border-right: 1px solid var(--cc-border); color: var(--cc-text); text-align: center; }
|
||||
.chart:last-child { padding-right: 0; border-right: 0; }
|
||||
.chart h3, .chart-details h3 { margin: 0 0 var(--admin-space-2); font-size: var(--admin-font-section); }
|
||||
.chart-metric { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: var(--admin-space-1) var(--admin-space-5); margin-bottom: var(--admin-space-1); font-variant-numeric: tabular-nums; }
|
||||
.chart-metric > strong { font-size: calc(var(--admin-font-title) * 1.25); overflow-wrap: anywhere; }
|
||||
.chart-metric small { font-size: var(--admin-font-caption); font-weight: normal; color: var(--cc-muted); }
|
||||
.chart-note, .chart-empty { margin: 0; font-size: var(--admin-font-caption); color: var(--cc-muted); line-height: 1.6; }
|
||||
.chart-empty { display: grid; min-height: 136px; place-content: center; }
|
||||
.series-selector { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--admin-space-2); margin-top: var(--admin-space-2); }
|
||||
.series-selector :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.chart-visualization { position: relative; min-width: 0; }
|
||||
.pie-chart { display: block; width: 100%; height: 190px; margin: var(--admin-space-2) auto 0; }
|
||||
.chart-more { position: absolute; right: 0; bottom: var(--admin-space-1); height: auto; padding: 0; font-size: var(--admin-font-caption); }
|
||||
.legend-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }
|
||||
.chart-details-grid { margin-top: var(--admin-space-5); }
|
||||
.chart-details-grid > section { min-width: 0; }
|
||||
.chart-details table { width: 100%; table-layout: fixed; }
|
||||
.chart-details caption { text-align: left; padding: var(--admin-space-2) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.chart-details td { overflow-wrap: anywhere; }
|
||||
@media (max-width: 1100px) { .operations-charts, .chart-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chart:nth-child(2n) { padding-right: 0; border-right: 0; } }
|
||||
@media (max-width: 720px) { .operations-charts, .chart-details-grid { grid-template-columns: minmax(0, 1fr); } .chart { padding-right: 0; padding-bottom: var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); } .chart:last-child { padding-bottom: 0; border-bottom: 0; } }
|
||||
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
|
||||
.chart { display:grid; align-content:start; gap:var(--admin-space-2); min-width:0; padding:var(--admin-space-3); border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:var(--admin-surface); }
|
||||
.chart > strong { font-size:var(--admin-font-section); }
|
||||
.chart p { margin:0; line-height:1.6; color:var(--cc-muted); font-size:var(--admin-font-caption); }
|
||||
.chart-values { max-height:220px; overflow:auto; }
|
||||
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
|
||||
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
|
||||
</style>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import TripPickupPanel from './TripPickupPanel.vue'
|
||||
import { commandPickupTask, commandPickupTrip, getPickupTasks, type PickupTask } from '../api/pickups'
|
||||
import { getTrip } from '../api/trips'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
|
||||
vi.mock('../api/trips', () => ({ getTrip: vi.fn() }))
|
||||
vi.mock('../api/pickups', () => ({ getPickupTasks: vi.fn(), commandPickupTask: vi.fn(), commandPickupTrip: vi.fn(), uploadPickupEvidence: vi.fn(), downloadPickupEvidence: vi.fn(), pickupStatusLabels: {} }))
|
||||
const row: PickupTask = { customerId: 'C1', transferNo: 'WT-1', status: 'PICKED', evidence: [], lastlegOrderNo: 'LL-1', warehouseId: 'W1', pickupAddress: 'TEST Pickup Ave', contactName: '验收联系人', contactPhone: '0000000000', packageCount: 3, tripNo: 'TR-1', vehiclePlate: 'TEST-V1', driverName: '验收司机', driverPhone: '0000000000', plannedDepartureAt: null, actualPackages: 3, pickedAt: null, arrivedAt: null, remark: null }
|
||||
const detail = (status: string) => ({ trip: { tripNo: 'TR-1', status, recordVersion: 2, vehicleId: 'V1', driverId: 'D1' } }) as TripDetail
|
||||
function mount(status: string) {
|
||||
return shallowMount(TripPickupPanel, { props: { detail: detail(status) }, global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot v-for="row in data" name="actions" :row="row"/></div>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||||
LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '<button @click="$emit(\'retry\')">重试读取</button>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getPickupTasks).mockResolvedValue([row]) })
|
||||
|
||||
it('locks parent actions and retries the exact departure command after an unknown result', async () => {
|
||||
vi.mocked(commandPickupTrip).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
|
||||
const wrapper = mount('PLANNED'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认取货发车')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('locked')?.at(-1)).toEqual([true])
|
||||
const body = vi.mocked(commandPickupTrip).mock.calls[0]![2]
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试原操作')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandPickupTrip).mock.calls[1]![2]).toBe(body)
|
||||
expect(wrapper.emitted('locked')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('retries only reading after arrival committed but the trip refresh failed', async () => {
|
||||
vi.mocked(commandPickupTask).mockResolvedValue([{ ...row, status: 'ARRIVED' }])
|
||||
vi.mocked(getTrip).mockRejectedValueOnce(new Error('refresh lost')).mockResolvedValueOnce(detail('IN_TRANSIT'))
|
||||
const wrapper = mount('IN_TRANSIT'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认送仓')!.trigger('click'); await flushPromises()
|
||||
expect(commandPickupTask).toHaveBeenCalledTimes(1)
|
||||
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||
expect(commandPickupTask).toHaveBeenCalledTimes(1)
|
||||
expect(getTrip).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { getTrip } from '../api/trips'
|
||||
import { getPickupTasks, commandPickupTrip, commandPickupTask, uploadPickupEvidence, downloadPickupEvidence, pickupStatusLabels,
|
||||
type PickupTask, type PickupCommand, type PickupConfirmation, type PickupEvidence } from '../api/pickups'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import type { TripDetail } from '../tripTypes'
|
||||
|
||||
const props = defineProps<{ detail: TripDetail }>(), emit = defineEmits<{ saved: [detail: TripDetail]; locked: [value: boolean] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<PickupTask[]>([]), loading = ref(false), busy = ref(false), error = ref('')
|
||||
const selected = ref<PickupTask | null>(null), open = ref(false), actual = ref(1), remark = ref(''), evidence = ref<PickupEvidence[]>([])
|
||||
const needsTripRefresh = ref(false)
|
||||
const pending = ref<{ action: 'depart' | 'complete' | 'picked' | 'arrive'; row: PickupTask | null; body: PickupCommand | PickupConfirmation } | null>(null)
|
||||
// 命令结果未知时阻止父页面刷新和改派,保留原请求用于幂等重试。
|
||||
watch(() => busy.value || !!pending.value, value => emit('locked', value), { flush: 'sync' })
|
||||
let sequence = 0
|
||||
const canComplete = computed(() => rows.value.length > 0 && rows.value.every(row => row.status === 'ARRIVED'))
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 175, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'transferNo', prop: 'transferNo', label: '仓储转运单', minWidth: 220 },
|
||||
{ key: 'status', label: '取货进度', slotName: 'status', width: 110 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 270 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', width: 110 }, { key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 145 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }, { key: 'actualPackages', label: '实提箱数', slotName: 'actual', width: 100 },
|
||||
{ key: 'evidence', label: '取货凭证', slotName: 'evidence', minWidth: 180 }, { key: 'remark', prop: 'remark', label: '取货备注', minWidth: 180 },
|
||||
]
|
||||
/** 车次变化时丢弃旧请求;同车版本变化保留未确定结果的命令。 */
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = ''
|
||||
try {
|
||||
if (needsTripRefresh.value) {
|
||||
const detail = await getTrip(props.detail.trip.tripNo)
|
||||
if (current !== sequence) return
|
||||
emit('saved', detail); needsTripRefresh.value = false
|
||||
}
|
||||
const value = await getPickupTasks(props.detail.trip.tripNo); if (current === sequence) rows.value = value
|
||||
}
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货任务加载失败').message }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
/** 逐票录入实提,默认箱数仅供编辑,需操作员确认。 */
|
||||
function edit(row: PickupTask) {
|
||||
if (pending.value) return
|
||||
selected.value = row; actual.value = row.packageCount; remark.value = ''; evidence.value = []; open.value = true
|
||||
}
|
||||
/** 凭证上传与确认分开,重试确认不重新上传文件。 */
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]
|
||||
if (!file || !selected.value || busy.value) return
|
||||
busy.value = true; error.value = ''; const current = sequence
|
||||
try { const value = await uploadPickupEvidence(props.detail.trip.tripNo, selected.value, file); if (current === sequence) evidence.value.push(value) }
|
||||
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '取货凭证上传失败').message }
|
||||
finally { if (current === sequence) busy.value = false; input.value = '' }
|
||||
}
|
||||
/** 失败结果未知时固定原请求,成功后只刷新车次版本和成员。 */
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value) return
|
||||
const request = pending.value, current = sequence, number = props.detail.trip.tripNo
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
if (request.action === 'depart' || request.action === 'complete') {
|
||||
const detail = await commandPickupTrip(number, request.action, request.body as PickupCommand)
|
||||
if (current === sequence) { emit('saved', detail); pending.value = null }
|
||||
} else if (request.row) {
|
||||
const tasks = await commandPickupTask(number, request.row, request.action, request.body)
|
||||
if (current !== sequence) return
|
||||
// 业务命令已经成功,后续刷新失败只重试读取,不能再次生成执行命令。
|
||||
rows.value = tasks; pending.value = null; open.value = false; needsTripRefresh.value = true
|
||||
const detail = await getTrip(number); if (current === sequence) { emit('saved', detail); needsTripRefresh.value = false }
|
||||
}
|
||||
} catch (cause) {
|
||||
if (current === sequence) { error.value = getApiErrorDetails(cause, '取货操作未完成').message; if (cause instanceof ApiResponseError) pending.value = null }
|
||||
} finally { if (current === sequence) busy.value = false }
|
||||
}
|
||||
/** 发车、送仓和结案提交前说明本次事实。 */
|
||||
async function command(action: 'depart' | 'arrive' | 'complete', row: PickupTask | null = null) {
|
||||
if (pending.value || busy.value || needsTripRefresh.value) return
|
||||
const messages = { depart: '确认车辆和司机已出发执行本车取货任务?', arrive: '确认该单货物已送达选定海外仓?确认后仓库可开始 PDA 清点。', complete: '确认本车全部取货订单已送仓,结束本次取货运输?' }
|
||||
try { await ElMessageBox.confirm(messages[action], '确认取货运输', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
pending.value = { action, row, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), reason: messages[action] } }
|
||||
await execute()
|
||||
}
|
||||
/** 照片和实提箱数为取货确认的必要信息。 */
|
||||
async function confirmPickup() {
|
||||
if (pending.value) { await execute(); return }
|
||||
if (needsTripRefresh.value) { ElMessage.warning('请先刷新车次后继续'); return }
|
||||
if (!selected.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length) { ElMessage.warning('请填写实提箱数并上传至少一份取货凭证'); return }
|
||||
pending.value = { action: 'picked', row: selected.value, body: { recordVersion: props.detail.trip.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: remark.value.trim() } }
|
||||
await execute()
|
||||
}
|
||||
/** 只下载本票确认的文件,使用完即释放临时地址。 */
|
||||
async function download(row: PickupTask, file: PickupEvidence) {
|
||||
try { const blob = await downloadPickupEvidence(row, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败').message }
|
||||
}
|
||||
/** 原命令结果未知时保持弹框与请求快照。 */
|
||||
function close(done: () => void) { if (busy.value || pending.value) { ElMessage.warning('请先确认原取货操作的结果'); return }; done() }
|
||||
onBeforeRouteLeave(() => { if (busy.value || pending.value) { ElMessage.warning('请先重试并确认原取货操作'); return false }; return true })
|
||||
watch(() => props.detail.trip.tripNo, () => { pending.value = null; open.value = false; rows.value = []; void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本车仓储转运取货任务">
|
||||
<div class="density-actions pickup-actions">
|
||||
<el-button v-if="detail.trip.status === 'PLANNED' && access.can(permission.tripAssign)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !detail.trip.vehicleId || !detail.trip.driverId" @click="command('depart')">确认取货发车</el-button>
|
||||
<el-button v-if="detail.trip.status === 'IN_TRANSIT' && access.can(permission.tripComplete)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !canComplete" @click="command('complete')">取货运输结案</el-button>
|
||||
<el-button :disabled="busy || !!pending" @click="load">刷新取货任务</el-button>
|
||||
<el-button v-if="pending && !open" type="primary" :loading="busy" @click="execute">重试原操作</el-button>
|
||||
</div>
|
||||
<LoadErrorState v-if="error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="transferNo" empty-text="暂无取货成员">
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)">确认送仓</el-button></div></template>
|
||||
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
|
||||
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
|
||||
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
|
||||
<el-form label-position="top" :disabled="busy || !!pending">
|
||||
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" placeholder="请输入实提箱数" /></el-form-item>
|
||||
<el-form-item label="取货凭证" required><div><input type="file" accept="image/png,image/jpeg,application/pdf" :disabled="busy || !!pending || evidence.length >= 10" @change="upload"><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></div></el-form-item>
|
||||
<el-form-item label="取货备注"><el-input v-model="remark" type="textarea" maxlength="1000" placeholder="请输入取货备注(选填)" /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.pickup-actions{margin-bottom:16px}</style>
|
||||
@@ -10,6 +10,3 @@ export { default as TripDispatchSelector } from './components/TripDispatchSelect
|
||||
export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type DispatchCandidate } from './api/trips'
|
||||
|
||||
export type { TripPreviousDispatch } from './tripTypes'
|
||||
export { listVehicles } from './api/overseasMasterData'
|
||||
export { listDrivers } from './api/drivers'
|
||||
export type { DriverDetail } from './driverTypes'
|
||||
|
||||
@@ -89,7 +89,7 @@ watch([page, size], loadInquiries)
|
||||
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
|
||||
@@ -246,31 +246,6 @@ it('highlights only the allowed current step and keeps other actions secondary',
|
||||
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['安排来货'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['all current permissions', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:dispatch:create'], true],
|
||||
['legacy smart permission', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:inbound-order:smart-dispatch'], false],
|
||||
['missing offer manage', ['platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:dispatch:create'], false],
|
||||
['missing booking edit', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:dispatch:create'], false],
|
||||
['missing trip edit', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:overseas-warehouse:dispatch:create'], false],
|
||||
['missing dispatch create', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit'], false],
|
||||
])('keeps table and dialog arrange-dispatch authority aligned: %s', async (_, codes, allowed) => {
|
||||
state.links = new Set(codes)
|
||||
state.manage = codes.includes('platform:fleet:capacity-offer:manage')
|
||||
const row: CapacityInquiry = { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'BIND' } }
|
||||
const table = shallowMount(CapacityInquiryTable, { props: { rows: [row], total: 1, loading: false }, global: { stubs: {
|
||||
DataTable: { props: ['data'], template: '<div><slot name="actions" :row="data[0]" /></div>' },
|
||||
RowActions: { name: 'RowActions', props: ['actions'], template: '<div />' },
|
||||
} } })
|
||||
wrappers.push(table)
|
||||
const actions = table.getComponent({ name: 'RowActions' }).props('actions') as { key: string; visible?: boolean }[]
|
||||
expect(actions.find(action => action.key === 'bind')?.visible).toBe(allowed)
|
||||
const { wrapper: dialog } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
|
||||
await flushPromises()
|
||||
const bind = dialog.findAll('button').find(button => button.text() === '确认排入本车')
|
||||
expect(Boolean(bind)).toBe(allowed)
|
||||
if (bind) { await bind.trigger('click'); expect(dialog.emitted('action')?.at(-1)).toEqual(['bind', row]) }
|
||||
})
|
||||
|
||||
it('shows the selected destination capacity when confirming and never treats unknown capacity as zero', async () => {
|
||||
state.manage = true
|
||||
const knownOffer = { ...offer, capacityKnown: true, destinations: [
|
||||
|
||||
@@ -85,7 +85,7 @@ async function load() {
|
||||
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'TODO' ? 'TODO' : manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
|
||||
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
|
||||
}
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '散板需求读取失败') }
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车接货读取失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
|
||||
@@ -120,12 +120,12 @@ async function recheckEditor() {
|
||||
}
|
||||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="散板需求">
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货">
|
||||
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
<template #toolbar-start><h1>散板需求</h1></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #toolbar-start><h1>拼车接货</h1></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="散板需求页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -142,7 +142,7 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}</h1></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="散板需求页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import FleetQuotesView from './FleetQuotesView.vue'
|
||||
import TripOperationsView from './TripOperationsView.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
|
||||
import * as quotes from '../api/fleetQuotes'
|
||||
import * as reports from '../api/tripOperations'
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) }))
|
||||
@@ -11,10 +9,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() }))
|
||||
vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
const layout = { template: '<div><slot name="metrics"/><slot name="state"/><slot/></div>' }
|
||||
const layout = { template: '<div><slot/></div>' }
|
||||
function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="filters"/><slot name="notices"/></div>' }, ElButton: { template: '<button><slot/></button>' },
|
||||
ElTooltip: true, LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
|
||||
LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
|
||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() })
|
||||
it('quotes read-only view fetches a page once and hides all write/export tools', async () => {
|
||||
vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
@@ -25,42 +23,5 @@ it('quotes read-only view fetches a page once and hides all write/export tools',
|
||||
it('operations uses server totals rather than displayed page size', async () => {
|
||||
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
|
||||
const wrapper = mount(TripOperationsView); await flushPromises()
|
||||
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).not.toContain('导出CSV')
|
||||
expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
|
||||
expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(false)
|
||||
await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
|
||||
expect(wrapper.text()).toContain('实际出车 21 次')
|
||||
expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(true)
|
||||
expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
|
||||
await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
|
||||
expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
|
||||
wrapper.findComponent(TripOperationsCharts).vm.$emit('show-details'); await flushPromises()
|
||||
expect(wrapper.find('#operations-statistics-details').exists()).toBe(true)
|
||||
expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(TripOperationsCharts).props('summary').physicalPallets).toBe(40)
|
||||
expect(wrapper.find('.operations-overview').element.compareDocumentPosition(wrapper.findComponent({ name: 'DataTable' }).element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns').find((column: { key: string }) => column.key === 'finance')).toMatchObject({ defaultVisible: false })
|
||||
})
|
||||
it('operations queries charts and records together and resets with only one request', async () => {
|
||||
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 0, page: 1, size: 20, pages: 0 }, summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0, externalPackages: 0, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
|
||||
const wrapper = mount(TripOperationsView); await flushPromises()
|
||||
const initial = vi.mocked(reports.listTripOperations).mock.calls[0]![0]
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { from: '2026-10-01', to: '2026-10-07', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' })
|
||||
toolbar.vm.$emit('search'); await flushPromises()
|
||||
expect(reports.listTripOperations).toHaveBeenLastCalledWith(expect.objectContaining({ from: '2026-10-01T00:00:00Z', to: '2026-10-08T00:00:00.000Z', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' }))
|
||||
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
|
||||
toolbar.vm.$emit('update:modelValue', { from: '2026-09-01', to: '2026-09-07' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
|
||||
toolbar.vm.$emit('reset'); toolbar.vm.$emit('search'); await flushPromises()
|
||||
expect(reports.listTripOperations).toHaveBeenCalledTimes(3)
|
||||
expect(reports.listTripOperations).toHaveBeenLastCalledWith(initial)
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ keyword: '', sourceType: '', status: '' })
|
||||
})
|
||||
it('operations shows query failures without leaving stale statistics', async () => {
|
||||
vi.mocked(reports.listTripOperations).mockRejectedValue(new Error('统计暂不可用'))
|
||||
const wrapper = mount(TripOperationsView); await flushPromises()
|
||||
expect(wrapper.findComponent(TripOperationsCharts).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toBe('运营记录加载失败')
|
||||
expect(wrapper.text()).not.toContain('实际出车 0 次')
|
||||
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget, TripAppointmentPanel } from '@/modules/appointment-calendar'
|
||||
import { inquiryReturnTarget } from '../inquiryNavigation'
|
||||
@@ -16,7 +15,6 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import TripPickupPanel from '../components/TripPickupPanel.vue'
|
||||
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
|
||||
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
|
||||
import TripBookingPanel from '../components/TripBookingPanel.vue'
|
||||
@@ -34,7 +32,6 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
|
||||
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
|
||||
const offerSettingsOpen = ref(false)
|
||||
const pickupLocked = ref(false)
|
||||
const appointmentsOpen = ref(false)
|
||||
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
|
||||
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
|
||||
@@ -43,7 +40,6 @@ const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new
|
||||
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
|
||||
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
|
||||
let generation = 0, inspectionSequence = 0, historySequence = 0
|
||||
const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP')
|
||||
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
|
||||
const editable = computed(() => planned.value && access.can(permission.tripEdit))
|
||||
const time = (value: string | null | undefined) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
@@ -62,7 +58,7 @@ const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
|
||||
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
|
||||
{ key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }]
|
||||
const actionLabels: Record<string, string> = { PICKUP_PLAN: '安排上门取货', PICKUP_DEPART: '取货发车', PICKUP_PICKED: '确认取货', PICKUP_ARRIVED: '确认送仓', PICKUP_COMPLETE: '取货运输结案', CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
|
||||
const actionLabels: Record<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
|
||||
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
|
||||
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
|
||||
function accept(value: TripDetail) {
|
||||
@@ -71,7 +67,6 @@ function accept(value: TripDetail) {
|
||||
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
|
||||
}
|
||||
async function load() {
|
||||
if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return }
|
||||
if (appointmentsPanel.value && !await appointmentsPanel.value.canLeave()) return
|
||||
const current = ++generation; inspectionSequence++; historySequence++
|
||||
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
|
||||
@@ -83,7 +78,7 @@ async function load() {
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function run(action: string, command: () => Promise<TripDetail>): Promise<boolean> {
|
||||
if (busy.value || pickupLocked.value) return false
|
||||
if (busy.value) return false
|
||||
const current = generation; busy.value = action; operationError.value = null
|
||||
try { const result = await command(); if (current !== generation) return false; accept(result); return true }
|
||||
catch (cause) {
|
||||
@@ -190,28 +185,28 @@ async function loadHistory() {
|
||||
}
|
||||
watch(() => route.params.tripNo, load, { immediate: true })
|
||||
watch(() => route.query.tab, tab => { activeTab.value = tab === 'loading' ? 'loading' : 'overview' })
|
||||
watch(activeTab, tab => { if (tab === 'completion' && !isPickup.value) void inspect(); if (tab === 'history') void loadHistory() })
|
||||
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
|
||||
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
|
||||
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy || pickupLocked" @click="load">刷新车次</el-button><el-button v-if="!isPickup && access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy || pickupLocked" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || pickupLocked || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
|
||||
<div v-loading="loading"><template v-if="detail">
|
||||
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [[isPickup ? '入仓仓库' : '起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
|
||||
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
|
||||
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
|
||||
</FormSection>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
|
||||
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button><InlineHelp title="操作说明"><p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p></InlineHelp></div>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
</div>
|
||||
<FormSection v-show="activeTab === 'overview'" v-if="!isPickup && access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
|
||||
</template>
|
||||
<FormSection v-show="activeTab === 'overview'" v-if="access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
|
||||
<el-button v-if="!appointmentsOpen" @click="appointmentsOpen = true">核对客户预约与后送去向</el-button>
|
||||
<TripAppointmentPanel v-if="appointmentsOpen" ref="appointmentsPanel" :key="detail.trip.tripNo" :dispatches="detail.dispatches" :previous-dispatches="detail.previousDispatches" :history-unavailable-reason="detail.appointmentHistoryUnavailableReason" :can-write="planned && !busy" @updated="refreshLinked" @work="key => router.push({ path: '/appointment-calendar', query: { workItem: key } })" @external="path => router.push(path)" />
|
||||
</FormSection>
|
||||
@@ -221,7 +216,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
|
||||
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
|
||||
<template v-if="activeTab === 'completion'">
|
||||
<FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
|
||||
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
|
||||
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
|
||||
|
||||
@@ -19,82 +19,49 @@ const submitExport = useExportTask()
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
|
||||
const statisticsOpen = ref(false)
|
||||
const today = new Date().toISOString().slice(0, 10), monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
|
||||
const defaultFilters = () => ({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
|
||||
const filters = ref<QueryModel>(defaultFilters())
|
||||
const displayedRange = ref('')
|
||||
const filters = ref<QueryModel>({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
|
||||
let applied: TripOperationsQuery = range(), sequence = 0
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'from', label: '实际发车起日(UTC)', placeholder: '发车起日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车起日(UTC)' } },
|
||||
{ key: 'to', label: '实际发车止日(UTC)', placeholder: '发车止日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车止日(UTC)' } },
|
||||
{ key: 'keyword', label: '关键词', type: 'input', width: 270, placeholder: '车次 / 车牌 / 司机 / 目的仓', props: { 'aria-label': '车次、车牌、司机或目的仓' } },
|
||||
{ key: 'sourceType', label: '货源', placeholder: '全部货源', type: 'select', width: 140, props: { 'aria-label': '货源' }, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
|
||||
{ key: 'status', label: '状态', placeholder: '全部状态', type: 'select', width: 140, props: { 'aria-label': '状态' }, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
|
||||
{ key: 'from', label: '实际发车起日(UTC)', type: 'date', width: 185 }, { key: 'to', label: '实际发车止日(UTC)', type: 'date', width: 185 },
|
||||
{ key: 'keyword', label: '关键词', type: 'input', width: 235, placeholder: '车次 / 发车车牌 / 司机 / 目的仓' },
|
||||
{ key: 'sourceType', label: '货源', type: 'select', width: 145, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
|
||||
{ key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
|
||||
]
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 185, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
|
||||
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
|
||||
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
|
||||
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
|
||||
{ key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
|
||||
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
|
||||
{ key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
|
||||
{ key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
|
||||
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
|
||||
{ key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
|
||||
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
|
||||
function range(): TripOperationsQuery {
|
||||
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
|
||||
return { from: `${from}T00:00:00Z`, to: new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(), keyword: String(filters.value.keyword || '') || undefined, sourceType: (filters.value.sourceType || undefined) as TripOperationsQuery['sourceType'], status: String(filters.value.status || '') || undefined }
|
||||
}
|
||||
async function load() {
|
||||
const current = ++sequence, query = { ...applied, page: page.value, size: size.value }
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const data = await listTripOperations(query)
|
||||
if (current === sequence) {
|
||||
result.value = data
|
||||
displayedRange.value = `${query.from.slice(0, 10)} 至 ${new Date(Date.parse(query.to) - 86400000).toISOString().slice(0, 10)}(UTC)`
|
||||
}
|
||||
} catch (e) {
|
||||
if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') }
|
||||
} finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
|
||||
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
|
||||
function resetFilters() { filters.value = defaultFilters() }
|
||||
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
|
||||
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
|
||||
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
|
||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="出车记录">
|
||||
<template #metrics>
|
||||
<section class="operations-overview" aria-labelledby="operations-statistics-title" :aria-busy="loading">
|
||||
<header class="operations-heading">
|
||||
<h2 id="operations-statistics-title">出车数据统计</h2>
|
||||
<div class="operations-heading-tools">
|
||||
<span class="operations-note">{{ loading ? '正在更新…' : displayedRange }}</span>
|
||||
<el-button v-if="result" link :aria-expanded="statisticsOpen" aria-controls="operations-statistics-details" @click="statisticsOpen = !statisticsOpen">{{ statisticsOpen ? '收起详情' : '统计详情' }}</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<template v-if="result">
|
||||
<TripOperationsCharts :charts="result.charts" :summary="result.summary" :details-visible="statisticsOpen" @show-details="statisticsOpen = true" />
|
||||
<section v-if="statisticsOpen" id="operations-statistics-details" class="operations-detail-body" aria-label="统计汇总与口径">
|
||||
<div class="operations-summary" aria-live="polite">
|
||||
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
|
||||
</div>
|
||||
<p v-if="result.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips ?? '—' }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips ?? '—' }} 趟。</p>
|
||||
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。物理托盘去重;同车多站可分别计入目的地。外接约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
|
||||
</section>
|
||||
</template>
|
||||
<p v-else class="operations-placeholder operations-note" role="status">{{ loading ? '正在加载出车统计…' : '暂无统计数据' }}</p>
|
||||
</section>
|
||||
</template>
|
||||
<template #state>
|
||||
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</template>
|
||||
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-operations-table" v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
|
||||
<template #toolbar-start><h1>出车记录</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="resetFilters" /></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="search" /></template>
|
||||
<template #notices>
|
||||
<div class="operations-overview">
|
||||
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<TripOperationsCharts v-if="result" :charts="result.charts" />
|
||||
<div v-if="result" class="operations-summary" aria-live="polite">
|
||||
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
|
||||
<span>实物托盘 <strong>{{ result.summary.physicalPallets }}</strong> 托</span><span>外接约定 {{ money(result.summary.externalAgreedAmounts) }}</span>
|
||||
</div>
|
||||
<p v-if="result?.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。</p>
|
||||
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></div></template>
|
||||
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
|
||||
@@ -106,11 +73,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
||||
<TripOperationsRecordDialog v-model="recordOpen" :row="selected" :readonly="!access.can('platform:fleet:operations:edit')" @saved="load" />
|
||||
</ListPageLayout></AdminPage></template>
|
||||
<style scoped>
|
||||
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; padding: var(--admin-space-5); background: var(--admin-surface); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
|
||||
.operations-heading, .operations-heading-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--admin-space-3) var(--admin-space-6); }
|
||||
.operations-heading h2 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.operations-detail-body { display: grid; gap: var(--admin-space-3); border-top: 1px solid var(--cc-border); padding-top: var(--admin-space-3); }
|
||||
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; }
|
||||
.operations-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-6); font-variant-numeric: tabular-nums; }
|
||||
.operations-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
|
||||
.operations-placeholder { min-height: var(--admin-empty-height); display: grid; place-content: center; }
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,6 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import TripPlanDialog from '../components/TripPlanDialog.vue'
|
||||
import PickupDemandPanel from '../components/PickupDemandPanel.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listTrips, type TripQuery } from '../api/trips'
|
||||
@@ -18,8 +17,6 @@ import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const workTab = ref('trips'), pickupVisited = ref(false)
|
||||
watch(workTab, value => { if (value === 'pickups') pickupVisited.value = true })
|
||||
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
|
||||
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
|
||||
@@ -27,7 +24,7 @@ const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: Object.entries(tripStatusLabels).map(([value, label]) => ({ value, label })) }]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 215 },
|
||||
{ key: 'purpose', label: '任务类型', slotName: 'purpose', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'vehiclePlate', label: '车牌', minWidth: 130 }, { key: 'driverName', label: '司机', minWidth: 110 },
|
||||
{ key: 'departure', label: '计划发车', slotName: 'departure', minWidth: 175 }, { key: 'end', label: '司机预计结束', slotName: 'end', minWidth: 175 },
|
||||
{ key: 'route', label: '路线', slotName: 'route', minWidth: 240 }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
@@ -49,15 +46,12 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="车次管理">
|
||||
<el-tabs v-model="workTab" class="density-element-tabs"><el-tab-pane label="车次管理" name="trips" /><el-tab-pane label="待取货" name="pickups" /></el-tabs>
|
||||
<PickupDemandPanel v-if="pickupVisited" v-show="workTab === 'pickups'" />
|
||||
<DataTable v-show="workTab === 'trips'" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>车次管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" :loading="loading" @search="search" @reset="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" @click="view(row.tripNo)">详情</el-button></div></template>
|
||||
<template #purpose="{ row }">{{ row.tripPurpose === 'PICKUP' ? '入仓取货' : '出库派送' }}</template>
|
||||
<template #status="{ row }">{{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}</template>
|
||||
<template #departure="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #end="{ row }">{{ time(row.expectedDriverEndAt) }}</template>
|
||||
<template #route="{ row }">{{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}</template>
|
||||
|
||||
@@ -6,13 +6,13 @@ export const overseasMasterDataRoutes = [
|
||||
path: 'overseas-master-data/capacity-offers',
|
||||
name: 'overseas-master-data-capacity-offers',
|
||||
component: () => import('./pages/CapacityOffersView.vue'),
|
||||
meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
meta: { title: '拼车接货', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/capacity-offers/:offerNo',
|
||||
name: 'overseas-master-data-capacity-offer-detail',
|
||||
component: () => import('./pages/CapacityOfferDetailView.vue'),
|
||||
meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
meta: { title: '拼车需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/fleet-quotes',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user