Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99cb25dac4 | ||
|
|
464b72cff9 | ||
|
|
9bfce44988 | ||
|
|
00b1fefecb | ||
|
|
88632e6e4f | ||
|
|
01ecd63991 | ||
|
|
3598310237 | ||
|
|
6b3286fa5a | ||
|
|
f155976f52 | ||
|
|
db32f31cc2 | ||
|
|
489b1bcd16 | ||
|
|
291535e242 | ||
|
|
1e80470078 | ||
|
|
6f7f8e7632 | ||
|
|
03b1e7163e | ||
|
|
5679b156ca | ||
|
|
3879a47673 | ||
|
|
b34626bd4b | ||
|
|
7005228b52 | ||
|
|
836e210b09 | ||
|
|
41a498657c |
@@ -2,13 +2,14 @@
|
||||
|
||||
## 快速读取顺序
|
||||
|
||||
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
|
||||
2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。
|
||||
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
|
||||
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
|
||||
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。
|
||||
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
|
||||
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
|
||||
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
|
||||
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
|
||||
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
|
||||
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
|
||||
|
||||
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
|
||||
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
|
||||
|
||||
## 项目定位与技术基线
|
||||
|
||||
@@ -38,44 +39,46 @@
|
||||
|
||||
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
|
||||
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
|
||||
|
||||
| 业务 | 首个入口 |
|
||||
|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` |
|
||||
| 登录、会话与统一 Token | `modules/auth/AGENTS.md`、`core/auth/` |
|
||||
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
|
||||
| 工作台与指标 | `modules/dashboard/` |
|
||||
| 工作台与指标 | `modules/dashboard/AGENTS.md` |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
|
||||
| 船期日历 | `modules/sailing-calendar/` |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`、`modules/overseas-warehouse/` |
|
||||
| 船期日历 | `modules/sailing-calendar/AGENTS.md` |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/AGENTS.md`、`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版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/AGENTS.md` |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/AGENTS.md` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/AGENTS.md`、`modules/domestic-containers/AGENTS.md` |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/AGENTS.md`、`modules/warehouse-resources/AGENTS.md` |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/AGENTS.md`、`core/access/` |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
|
||||
| 消息中心 | `modules/notifications/` |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` |
|
||||
| 消息中心 | `modules/notifications/AGENTS.md` |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/AGENTS.md`、`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/inbounds/AGENTS.md`、`modules/dimension-config/AGENTS.md` |
|
||||
| 一件代发托盘打印 | `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/fba-pricing/AGENTS.md`、`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/container-loading/AGENTS.md`、`modules/loading-lists/AGENTS.md` |
|
||||
| 合柜报关 / 同柜不可拆组 | `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/ocean-bookings/AGENTS.md` |
|
||||
| 订舱级拖车预约 | `modules/trucking/AGENTS.md` |
|
||||
| 目的港码头拖车与还空预警 | `modules/container-loading/AGENTS.md` |
|
||||
| 历史报关案件、报关回执与国内出仓 | `modules/customs/AGENTS.md`、`modules/outbound-dispatch/AGENTS.md` |
|
||||
| 主数据、下载、退避任务重试 | `modules/master-data/AGENTS.md`、`modules/downloads/AGENTS.md` |
|
||||
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
|
||||
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` |
|
||||
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
| 入库单扣货、解除、凭证与历史 | `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/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
@@ -53,6 +53,11 @@
|
||||
|
||||
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
|
||||
|
||||
## 货柜工作台规则
|
||||
|
||||
- 国内“送柜报关”只消费 `/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/`
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
+3
-2
@@ -5,8 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
|
||||
"preview": "vite preview --port 4173"
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
} from './adminNavigation'
|
||||
|
||||
describe('adminNavigation', () => {
|
||||
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
|
||||
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
|
||||
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
|
||||
})
|
||||
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/owned-business'])
|
||||
const writeOnly = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:fleet:owned-business:edit', 'platform:fleet:owned-business:upload',
|
||||
]))
|
||||
expect(getNavigationLeaves(writeOnly).some(node => node.path === '/overseas-master-data/owned-business')).toBe(false)
|
||||
})
|
||||
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
|
||||
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
|
||||
@@ -31,7 +47,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '出车记录'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
@@ -40,7 +56,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -134,6 +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-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
|
||||
{ key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Admin 预约日历模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/appointment-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,预约规则见 `../../../docs/business-index.md`;工作日历正式规则在**工作区** `docs/海外仓业务/预约工作日历V13变更说明.md`。
|
||||
- 本模块扁平存放页面、面板与 API,无 `pages/`、`components/` 分层:`*.vue` 多为面板,`*Api.ts` 为对应接口,`calendar.ts`/`schedule.ts`/`tripCalendar.ts` 为日历与航段计算,`useWorkDraft.ts`/`useWorkSyncRead.ts` 为草稿与只读同步。
|
||||
- 后端只查 `lastleg-service/`(`inboundplan/`、`appointmentplanning/`);到港事实只读 `firstleg/` 的船节点,纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 预约列表与日历主页面 | `AppointmentCalendarView.vue`;`appointment-calendar` | `api.ts`、`calendar.ts`;`lastleg-service/inboundplan/` |
|
||||
| 工作日历、车次与送仓待办 | `WorkCalendarPanel.vue`、`TripAppointmentPanel.vue` | `workApi.ts`、`tripCalendar.ts`;`lastleg-service/appointmentplanning/` |
|
||||
| 新建/批量预约、多客户共车 | `AppointmentCreatePanel.vue`、`AppointmentBatchForm.vue`、`WorkSharedTripForm.vue` | `workApi.ts`、`batchApi.ts` |
|
||||
| 货物验证与入仓码关系 | `AppointmentCargoPanel.vue`、`AppointmentCargoTabs.vue`、`AppointmentInboundCodes.vue`、`CargoVerificationForm.vue` | `cargoVerificationApi.ts` |
|
||||
| 改期、取消与取消记录 | `AppointmentImpactDialog.vue`、`AppointmentCancellationDialog.vue`、`AppointmentCancellationHistory.vue`、`AppointmentCancellationRecords.vue` | `impactApi.ts` |
|
||||
| 派送安排与分批后送 | `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue` | `workApi.ts`;`lastleg-service/delivery/` |
|
||||
| 收货范围与收货计划 | `ReceivingScope.vue`、`WorkReceivingPanel.vue`、`WorkReceiptPlans.vue` | `receivingApi.ts` |
|
||||
| 外部候选取货与跟进 | `AppointmentExternalCandidates.vue`、`AppointmentFollowupDialog.vue` | `planningApi.ts`、`workApi.ts` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 同仓跨客户共用一张预约,客户归属在货物明细,不为每个客户拆预约;同车多客户按 V13 规则处理。
|
||||
- 到港事实只读 Firstleg 船节点,送仓与排车归 Lastleg;不得用预计到港替代计划到仓,也不得反向写船节点。
|
||||
- 改期、取消、重新预约必须按服务端影响范围与版本提交,取消记录只追加,不改写历史。
|
||||
- 未定日期事项进入待办跟进,不伪造日期;分批后送的批次关系以服务端返回为准。
|
||||
- 面板之间共享草稿状态,`useWorkDraft.ts` 是唯一草稿入口;刷新时用 `useWorkSyncRead.ts` 合并服务端事实并保留本机未提交输入。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/appointment-calendar/<目标测试>`;日历与批次规则变更同时核对 V13 规则文档与 `lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -88,10 +88,10 @@ describe('simplified appointment entry', () => {
|
||||
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')
|
||||
routing.denied.add('platform:overseas-warehouse:dispatch: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('缺少排车读取权限')
|
||||
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -143,4 +143,17 @@ describe('simplified appointment entry', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list')
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] })
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
||||
expect(listTripCalendar).toHaveBeenCalledOnce()
|
||||
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', event); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
@@ -64,12 +65,12 @@ async function load(next = range.value) {
|
||||
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||||
})(),
|
||||
(async () => {
|
||||
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
|
||||
if (!access.can('platform:overseas-warehouse:dispatch: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} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
@@ -180,10 +181,10 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||||
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||||
</section>
|
||||
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label><span>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></span></template><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
|
||||
@@ -43,7 +43,7 @@ async function save() {
|
||||
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
|
||||
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
|
||||
<el-form label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit placeholder="请填写实际取消原因" /></el-form-item>
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -87,7 +87,6 @@ onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
||||
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
|
||||
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
|
||||
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></details>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
|
||||
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
|
||||
|
||||
@@ -134,7 +134,7 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
|
||||
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
|
||||
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="density-dialog-note">预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
|
||||
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
|
||||
</section>
|
||||
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
|
||||
</template>
|
||||
|
||||
@@ -105,10 +105,10 @@ onBeforeUnmount(() => { ++generation })
|
||||
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||||
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
|
||||
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
</el-form>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
|
||||
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
|
||||
|
||||
@@ -58,7 +58,7 @@ onMounted(load)
|
||||
</template>
|
||||
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="实际处理结果"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录已联系、核对或改约的实际结果;此处不会自动改约" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写后续处理事项" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="canWrite && detail.impact.status === 'OPEN'">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -53,7 +54,7 @@ onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="货物核实资料">
|
||||
<h3>核实货物资料</h3><p>未约成也可保存;只用于预约核实,保留原订单资料。</p>
|
||||
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
||||
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取货物资料…</p>
|
||||
<template v-if="context">
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-20 排车入口收敛
|
||||
|
||||
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
|
||||
|
||||
自动验证:相关3文件42项、Admin全量453文件4086项、类型检查与生产构建通过;后端最终常规Maven流程完成生产/测试源码编译,`FleetTripCalendarServiceTest`及真实H2查询的`FleetTripCalendarMapperTest`共9项通过,覆盖真实排车号与内部车次号不同、独立旧车次/取消主单/取货排除、跨日期和查询上限。Gateway路由合同5项通过。均经共享`local-check.ps1`执行;早先其他自有业务在途编译错误及失效生成物尝试不作为通过证据,以最终常规Maven回归为准。接口路径复用、无网关配置或数据库结构变更,匹配发布单元为Admin与Lastleg。
|
||||
|
||||
## 2026-10-19 四类日历恢复(当前结论)
|
||||
|
||||
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
@@ -86,13 +87,13 @@ async function save() {
|
||||
</script>
|
||||
<template>
|
||||
<form class="density-form work-form" @submit.prevent="save">
|
||||
<h3>{{ action.label }}</h3>
|
||||
<h3>{{ action.label }}<InlineHelp v-if="command === 'SAVE_FOLLOWUP'" title="跟进日期说明"><p>跟进日期不代替作业日期;未完成事项仍保留在待办中。</p></InlineHelp><InlineHelp v-if="command === 'RESOLVE_REVIEW'" title="核对结果说明"><p>原安排需要改期时,请先处理原安排,再记录核对结果。</p></InlineHelp></h3>
|
||||
<p v-if="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
|
||||
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
|
||||
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
|
||||
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label><p>跟进日期不代替作业日期,未完成事项仍保留在待办中。</p></template>
|
||||
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label><p>若原安排需要改期,请先处理原安排,再记录核对结果。</p></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label></template>
|
||||
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<input v-model="form.date" type="date"></label><div class="time-fields"><label>开始时段(可留空)<input v-model="form.startTime" type="time"></label><label>结束日期(可留空)<input v-model="form.endDate" type="date"></label><label>结束时段(可留空)<input v-model="form.endTime" type="time"></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
|
||||
|
||||
@@ -15,9 +15,10 @@ beforeEach(() => { vi.clearAllMocks(); state.canRead = true; vi.mocked(getContai
|
||||
it('opens a real plan in the exact customer scope while leaving missing plans unavailable', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getContainerPlanProgress).toHaveBeenCalledExactlyOnceWith('9007199254740993')
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
const plans = wrapper.findAll('button').filter(item => item.text() === '查看原接仓记录')
|
||||
await plans[0]!.trigger('click')
|
||||
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/inbound-orders/IP-001?customerId=9007199254740999']])
|
||||
expect(wrapper.findAll('button')[1]!.attributes('disabled')).toBeDefined()
|
||||
expect(plans[1]!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('internal-plan'); expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
})
|
||||
it('does not read the container source without its original permission', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -42,7 +43,7 @@ async function load() { if (!readable.value) return; loading.value = true; error
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划</h3><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
|
||||
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划<InlineHelp title="接仓计划说明"><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p></InlineHelp></h3><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="本柜入仓计划读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
|
||||
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
@@ -274,7 +275,7 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
|
||||
<el-button :disabled="locked || cargoLoading" @click="loadCargo">重新读取最新资料</el-button>
|
||||
</template>
|
||||
<template v-if="mode === 'plan'">
|
||||
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划</h4>
|
||||
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划<InlineHelp title="客户计划说明"><p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p></InlineHelp></h4>
|
||||
<p v-for="item in customerItems" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount ?? '箱数待核实' }}</p>
|
||||
<p>{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划,其他客户的确认保持不变。</p>
|
||||
<p v-if="!planEligible(customer)" role="status">本客户资格已变化,暂不能保存普通计划,请返回范围概览核对。</p>
|
||||
@@ -284,7 +285,7 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
|
||||
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
|
||||
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
|
||||
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
|
||||
<p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p>
|
||||
|
||||
</template>
|
||||
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<input v-model="adjustment.laterDate" type="date"></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<input v-model="adjustment.followup" type="date"></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
|
||||
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
@@ -35,3 +35,46 @@ 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.each([false, true])('refreshes the original add-cargo gate after a rejection and preserves its message when GET fails: %s', async readFails => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '选择同仓未安排货物').trigger('click')
|
||||
if (readFails) vi.mocked(getDispatch).mockRejectedValueOnce(new Error('read unavailable'))
|
||||
else vi.mocked(getDispatch).mockResolvedValueOnce(detail('DS-60', false))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源已变化,请重新核对', code: 'A0409' })
|
||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('来源已变化,请重新核对')
|
||||
expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id)
|
||||
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
|
||||
recovery.pending.value!.attempted = true
|
||||
const completed: CommandResult = {
|
||||
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
|
||||
occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 },
|
||||
replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] },
|
||||
}
|
||||
recovery.pending.value!.result = completed
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
await button(wrapper, '刷新加货资料').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源版本需重新核对', code: 'A0409' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源版本需重新核对')
|
||||
dialog.vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('来源版本需重新核对')
|
||||
expect(wrapper.emitted('updated')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 rejectionError = 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))
|
||||
@@ -29,11 +30,12 @@ async function load() {
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) open.value = true }
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
|
||||
function recover() {
|
||||
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
|
||||
}
|
||||
function saved() { open.value = false; emit('updated'); void load() }
|
||||
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
|
||||
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
|
||||
watch(() => props.dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
</script>
|
||||
@@ -42,10 +44,11 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
<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>
|
||||
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
|
||||
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
||||
<p v-if="recovery.pending.value">原排车请求尚未核对,不能另发加货命令。<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">核对原加货请求</el-button></p>
|
||||
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</el-button></p>
|
||||
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" />
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { projectContainerSchedule } from './schedule'
|
||||
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
|
||||
|
||||
vi.mock('@/shared/api/http', async original => ({
|
||||
...await original<typeof import('@/shared/api/http')>(),
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
}))
|
||||
|
||||
const range = { from: '2026-09-28', to: '2026-11-02' }
|
||||
const bookingId = '9007199254740993'
|
||||
// 使用真实接口的结构:Long 数量序列化为字符串,空时间字段不输出。
|
||||
const wire = {
|
||||
sourceContainerBookingId: bookingId, containerNo: 'CALENDAR-CONT',
|
||||
warehouseName: '接仓仓库', warehouseCode: 'US-W', version: 0, planCount: '0', canEdit: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'OK', data: {
|
||||
containers: [{ containerBookingId: bookingId, cabinetNo: 'D-CALENDAR', containerNo: 'CALENDAR-CONT',
|
||||
vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null,
|
||||
arrivalAt: '2026-10-20T23:00:00', arrivalType: 'ESTIMATED' }],
|
||||
emptyReturns: [], emptyReturnsReadable: true,
|
||||
} })
|
||||
})
|
||||
|
||||
async function readSchedule(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'OK', data: [{ ...wire, ...overrides }] })
|
||||
const sources = await getCalendarSources(range)
|
||||
const warehouses = await getWarehouseArrivals(range, [bookingId])
|
||||
return { warehouses, event: projectContainerSchedule(sources, warehouses, range, true).find(row => row.type === 'warehouse') }
|
||||
}
|
||||
|
||||
describe('warehouse calendar response to schedule', () => {
|
||||
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
|
||||
const { warehouses, event } = await readSchedule({ planCount })
|
||||
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
|
||||
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
|
||||
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
|
||||
expect(event?.fields).toContainEqual({ label: '实际到仓', value: '—' })
|
||||
})
|
||||
|
||||
it('keeps the authoritative frozen plan instead of recalculating from arrival', async () => {
|
||||
const { warehouses, event } = await readSchedule({ planCount: '2', plannedAt: '2026-10-25T09:30:00', canEdit: false })
|
||||
expect(warehouses[0]?.planCount).toBe(2)
|
||||
expect(event).toMatchObject({ date: '2026-10-25', time: '09:30', canEdit: false, subtitle: '已有入库计划,日期已冻结' })
|
||||
})
|
||||
|
||||
it('keeps a manually maintained time when there is no inbound plan', async () => {
|
||||
const { event } = await readSchedule({ version: 1, plannedAt: '2026-10-24T08:00:00' })
|
||||
expect(event).toMatchObject({ date: '2026-10-24', time: '08:00', subtitle: '人工维护' })
|
||||
})
|
||||
|
||||
it('does not calculate a default when existing plans have no unified time', async () => {
|
||||
const { event } = await readSchedule({ planCount: '2', canEdit: false })
|
||||
expect(event?.date).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps conflicting authoritative versions unresolved even with zero plans', async () => {
|
||||
const { event } = await readSchedule({ version: -1, canEdit: false })
|
||||
expect(event).toMatchObject({ date: null, canEdit: false, subtitle: '柜内计划时间不一致,待核实' })
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'invalid', '-1', '1.5', '9007199254740992', -1, Infinity])(
|
||||
'rejects invalid or unsafe plan count %j rather than treating it as no plans', async planCount => {
|
||||
await expect(readSchedule({ planCount })).rejects.toThrow('入库计划数量无效')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -31,15 +31,35 @@ export interface WarehouseArrival {
|
||||
canEdit: boolean
|
||||
}
|
||||
export interface ScheduleRange { from: string; to: string }
|
||||
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
|
||||
planCount: string | number
|
||||
plannedAt?: string | null
|
||||
actualAt?: string | null
|
||||
}
|
||||
|
||||
/** 在接口边界统一长整型数量,未知数量不能被当成零触发默认日期。 */
|
||||
function normalizeWarehouseArrival(row: WarehouseArrivalWire): WarehouseArrival {
|
||||
const count = row.planCount
|
||||
if (typeof count !== 'number' && (typeof count !== 'string' || !/^(0|[1-9]\d*)$/.test(count))) {
|
||||
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
}
|
||||
const planCount = Number(count)
|
||||
if (!Number.isSafeInteger(planCount) || planCount < 0) {
|
||||
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
}
|
||||
return { ...row, planCount, plannedAt: row.plannedAt ?? null, actualAt: row.actualAt ?? null }
|
||||
}
|
||||
|
||||
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[]>>(
|
||||
const rows = unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrivalWire[]>, ApiEnvelope<WarehouseArrivalWire[]>>(
|
||||
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
|
||||
))
|
||||
return rows.map(normalizeWarehouseArrival)
|
||||
}
|
||||
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
|
||||
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
|
||||
|
||||
@@ -3,16 +3,24 @@ import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalend
|
||||
import { http } from '@/shared/api/http'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
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: [] }
|
||||
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo, 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('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => {
|
||||
const row = trip('DS/REAL', '2026-10-10T12:00:00Z')
|
||||
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
|
||||
expect(result.events[0]).toMatchObject({ key: 'dispatch-DS/REAL', title: 'DS/REAL 排车', sourcePath: '/overseas-warehouse/dispatches/DS%2FREAL' })
|
||||
const missing = projectTripCalendar({ records: [{ ...row, dispatchNo: '' }], unresolvedRecords: [] }, range)
|
||||
expect(missing.events).toEqual([])
|
||||
expect(missing.unresolved[0]!.incompleteReasons).toContain('DISPATCH_REFERENCE_UNKNOWN')
|
||||
})
|
||||
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 shared = trip('DS-SHARED', '2026-10-10T12:00:00Z')
|
||||
const cancelled = { ...trip('DS-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 排车' })
|
||||
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'DS-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'}
|
||||
@@ -25,23 +33,23 @@ describe('location-based trip calendar', () => {
|
||||
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
|
||||
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
|
||||
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
|
||||
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
|
||||
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['dispatch-NY-start','2026-10-01','02:30'],['dispatch-LA-end','2026-10-31','23:30']])
|
||||
})
|
||||
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
|
||||
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
|
||||
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
})
|
||||
it('uses actual local departure through DST changes while retaining the original plan for comparison', () => {
|
||||
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
const row={...trip('DS-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('DS-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
expect(events.map(event=>event.time)).toEqual(['00:00','01:30'])
|
||||
expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'})
|
||||
expect(events[0]).toMatchObject({date:'2026-11-02',title:'DS-DST 实际发车'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
|
||||
})
|
||||
it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => {
|
||||
const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
|
||||
const executed = {...trip('DS-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
|
||||
expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
|
||||
const november = {from:'2026-11-01',to:'2026-12-01'}
|
||||
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0]
|
||||
@@ -50,9 +58,9 @@ describe('location-based trip calendar', () => {
|
||||
expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time})
|
||||
})
|
||||
it('does not fall back to the plan when an actual departure cannot be resolved', () => {
|
||||
const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
|
||||
const invalid = {...trip('DS-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
|
||||
const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
|
||||
expect(result.events).toEqual([])
|
||||
expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID'])
|
||||
expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['DS-INVALID'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
|
||||
export interface TripCalendarEntry {
|
||||
sourceType: 'TRIP_PLAN'
|
||||
tripNo: string
|
||||
sourceType: 'DISPATCH'
|
||||
dispatchNo: string
|
||||
status: string
|
||||
originWarehouseId: string | null
|
||||
originWarehouseCode: string | null
|
||||
@@ -23,6 +23,7 @@ export const tripIssueLabels: Record<string, string> = {
|
||||
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
|
||||
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
|
||||
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
|
||||
DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失',
|
||||
}
|
||||
export function tripIssues(row: TripCalendarEntry) {
|
||||
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
|
||||
@@ -56,7 +57,13 @@ function localStamp(value: string | null, zone: string | null) {
|
||||
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
|
||||
const events: ScheduleEvent[] = [], 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()) {
|
||||
const seen = new Set<string>()
|
||||
for (const row of result.records) {
|
||||
if (!row.dispatchNo?.trim()) {
|
||||
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'DISPATCH_REFERENCE_UNKNOWN'] }); continue
|
||||
}
|
||||
if (seen.has(row.dispatchNo)) continue
|
||||
seen.add(row.dispatchNo)
|
||||
if (row.status === 'CANCELLED') continue
|
||||
// Executed work stays on its actual local date even if the plan changes later.
|
||||
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
|
||||
@@ -66,11 +73,11 @@ 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/客户仓的车辆安排',
|
||||
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
|
||||
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
||||
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
|
||||
fields: [
|
||||
{ label: '车次状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '运输状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
|
||||
{ label: '地点时区', value: row.originTimezone },
|
||||
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Admin 操作日志与审计模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/audit/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 本模块**没有独立页面**:审计日志是客户详情里的内嵌面板(`components/CustomerAuditLogsPanel.vue`),查询接口在 `api/platformAudit.ts`。
|
||||
- 后端查 `audit-service/`(`PlatformAuditLogController`、`AuditLogQueryService`);若是“操作人没被记录”,还要查采集侧 `common-audit/`(`BusinessAuditAspect.java`、`AuditEvent.java`)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户详情内的操作日志面板、分页与详情 | `components/CustomerAuditLogsPanel.vue`(props:`customerId`、`customerName`) | `api/platformAudit.ts` 的 `listPlatformAuditLogs`、`getPlatformAuditLog`;`audit-service/` |
|
||||
| 日志字段展示、时间与文案映射 | `presentation.ts` | 服务端返回结构 |
|
||||
| 日志列表/详情的对外类型 | `index.ts` | `audit-service` DTO |
|
||||
| 操作人缺失、字段为空 | 面板空态与列定义 | 先查 `common-audit/` 是否采集该字段,再查 `audit-service/` 摄取 DTO 与表结构 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 列表不返回变更前后快照,详情才返回已脱敏 JSON;不要在列表里展开完整 payload。
|
||||
- 客户账号不得访问平台审计查询;面板只在平台侧客户详情内使用。
|
||||
- 审计字段缺失属于“采集侧漏采”或“查询侧未映射”,两者分属 `common-audit/` 与 `audit-service/`,不要只改前端展示。
|
||||
- 审计链路故障不得阻塞核心交易,前端也不得据此推断业务失败。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/audit/<目标测试>`;字段或查询变化同时核对 `audit-service` 的查询接口与表结构。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -197,8 +197,8 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</section>
|
||||
<el-alert v-if="!detail.success" type="error" :closable="false" show-icon :title="detail.errorMessage || '操作失败,请查看诊断信息'" />
|
||||
<div class="audit-snapshots">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><p class="density-dialog-note">操作执行前保留的业务数据</p><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><p class="density-dialog-note">操作执行后返回或保存的业务数据</p><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 登录模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/auth/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 模块只有一个页面 `pages/LoginView.vue`;认证状态、Token 刷新与路由守卫在 `../../core/auth/` 与 `../../shared/api/AGENTS.md`,本模块不复制这些逻辑。
|
||||
- 后端只查 `auth/`(登录、刷新、退出、改密)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 平台员工登录页、表单校验与错误提示 | `pages/LoginView.vue` | `../../core/auth/auth.ts` → `../../shared/api/http.ts`;`auth/` |
|
||||
| 登录态与路由跳转 | 页面不持有状态 | `../../core/auth/auth.ts`、`../../core/access/` |
|
||||
| 401 刷新与重放 | 页面不处理 | `../../shared/api/http.ts`、`../../shared/api/AGENTS.md` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 页面不读写 Token、不拼 `Authorization`、不自行持久化密码;令牌与刷新编排只在 `src/core/auth/` 与 `src/shared/api/http.ts`。
|
||||
- 认证失败提示按服务端返回的 `code` 与文案展示,不把 401/403 混为同一种提示。
|
||||
- 登录成功后跳转由路由守卫决定,页面不硬编码默认落地页。
|
||||
- 记住账号类能力只保存登录标识,不持久化密码或 Token。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/auth/<目标测试>`;登录或守卫变化同时核对 `src/core/auth/` 与 `src/shared/api/http.ts`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Admin 货柜模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/container-loading/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,页面/接口细则见 `../../../docs/business-index.md`。
|
||||
- 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;接口、状态、金额或权限变化时再读详细索引命中行。
|
||||
- 后端只查直接消费方:`firstleg/`(`loading/`、`customs/container/`、`terminaldrayage/`、`oceanbooking/`)、`lastleg-service/`(柜级到仓与尾程)、`file/`、`print/`。纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 国内装柜列表、模拟柜、订舱关联 | `pages/ContainersView.vue`、`pages/DomesticContainersView.vue`;`container-loading/domestic` | `api/platformContainerLoading.ts`;`components/ContainerSimulationCreate.vue`、`components/OceanBookingLinkDialog.vue`;`firstleg/loading/` |
|
||||
| 送柜报关详情、装柜清单页签、报关、出仓 | `pages/DomesticContainerDetailView.vue`、共享 `pages/ContainerDetailView.vue`;`container-loading/domestic/:containerId` | `components/ContainerOverviewPanel.vue`、`components/ContainerCustomsPanel.vue`;`api/platformContainerCustoms.ts`;`firstleg/customs/container/`、`file/` |
|
||||
| 智能装柜工作台、柜次容量与分配 | `pages/SmartLoadingWorkbenchView.vue`;`container-loading/domestic/:containerId/smart-loading` | `api/platformContainerLoading.ts`;`../unified-orders/workflows/components/SmartLoadingWorkbench.vue`;`firstleg/service/SmartLoadingService.java` |
|
||||
| 全局智能装柜弹框、入仓码候选与箱数 | `pages/GlobalSmartLoadingView.vue`(内嵌,无独立路由)、`components/SmartLoadingDialog.vue`、`components/SmartLoadingCandidateInbounds.vue` | `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`smartLoadingPlanGroups.ts`;后端 `/inbound-candidates`、`/containers`、`/inbound-assignments` |
|
||||
| 海外清关提柜工作台、船节点、清关与 DO/PU | `pages/OverseasContainersView.vue`;`container-loading/overseas` | `api/platformContainerLoading.ts`;`firstleg/` 船节点与 `customs/`;`components/ContainerOverviewPanel.vue` |
|
||||
| 目的港码头拖车、车队与还空 | `pages/OverseasContainerDetailView.vue`(薄包装)、共享 `pages/ContainerDetailView.vue`(实际内容);`container-loading/overseas/:containerId` | `api/terminalDrayages.ts` 由共享详情调用;`firstleg/terminaldrayage/`(`TerminalDrayageService.java`、`DirectDeliveryService.java`) |
|
||||
| 整柜预计/实际到仓 | 列表与详情“到仓”列 | `api/containerArrivalTimes.ts`;`lastleg-service/` 柜级订单 |
|
||||
| 装柜照片与证据的展示/读取(上传在 PDA 与 Firstleg) | `components/ContainerOverviewPanel.vue` | 证据读取走 `file/`;生产与上传见 `firstleg/loading/service/CabinetLoadingEvidenceService.java` 与国内 PDA |
|
||||
| 海外柜费用 | `components/ContainerLastLegFeesPanel.vue` | `api/overseasContainerFees.ts`;`finance/`、`firstleg/` 费用来源 |
|
||||
|
||||
`pages/ContainerWorkbenchView.vue` 是工作台外壳,按权限在“国内装柜”“清关提柜”间切换;`pages/ContainersView.vue` 与 `ContainerDetailView.vue` 为共享实现,改两份前先确认是否同源。
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,写资格只取服务端 `allowedActions`;不用浏览器筛选、本地推导或柜内明细数量代替。
|
||||
- 创建柜次时自动生成唯一预装计划;订舱关联允许真实集装箱号为空,但**确认预装和创建 PDA 任务前必须补齐**,历史上已建缺号任务可在预装详情首次补录。
|
||||
- 还空截止、超时天数只展示服务端按权威卸柜完成时间加 72 小时计算的结果,前端不重算、不用拖车状态当阻断门禁。
|
||||
- 国内“送柜报关”只消费 `/containers/domestic`,海外“清关提柜”只消费 `/containers/overseas`;两端统一用 `containerBookingId`,禁止混用两套列表接口。
|
||||
- 智能装柜候选状态只作筛选展示,按钮资格以服务端 `canAssign` 为准;计划箱数只消费 `totalPackageCount/remainingPackageCount`,不从候选页或本地规格重算。
|
||||
- 前端不计算托数、不逐票建计划、不预占托盘;柜级转换必须原样回传服务端 `snapshotVersion/snapshotHash`,冲突后重新预览。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/container-loading/<目标测试>`;API 或类型变化同时运行 `pnpm typecheck`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`;离线检查不代替真实 HTTP 与人工截图验收。
|
||||
@@ -298,7 +298,7 @@ onMounted(load)
|
||||
<section class="container-customs surface-panel admin-density" aria-label="整柜报关">
|
||||
<OperationErrorToast :details="actionError" />
|
||||
<header class="customs-header">
|
||||
<div><h2>整柜报关</h2><p>资料、回执与完税文件按柜管理。</p></div>
|
||||
<div><h2>整柜报关</h2></div>
|
||||
<div v-if="customs" class="customs-actions">
|
||||
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
|
||||
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
|
||||
@@ -338,7 +338,7 @@ onMounted(load)
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
|
||||
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>查看申报批次及海关处理结果。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
|
||||
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
|
||||
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
|
||||
<div v-else class="declaration-batch-list">
|
||||
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('尾程付款凭证', () => {
|
||||
|
||||
it('新增连续表切换付款字段和全屏后保留输入,保存仍使用真实集装箱号', async () => {
|
||||
const wrapper = await setup(false, true)
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
|
||||
await flushPromises()
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增尾程费用'))!
|
||||
@@ -86,7 +86,7 @@ describe('尾程付款凭证', () => {
|
||||
|
||||
it('只有柜次号仍不能维护费用,保留补录真实集装箱号门禁', async () => {
|
||||
const wrapper = await setup(false, true, null)
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '新增费用')!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('尚未补录真实集装箱号')
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
@@ -103,7 +103,7 @@ describe('尾程付款凭证', () => {
|
||||
|
||||
it('显示上传文件名,允许再次上传替换和移除', async () => {
|
||||
const wrapper = await setup()
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
|
||||
state.form.settlementMode = 'DIRECT_PAID'
|
||||
await flushPromises()
|
||||
@@ -121,7 +121,7 @@ describe('尾程付款凭证', () => {
|
||||
|
||||
it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => {
|
||||
const wrapper = await setup()
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
|
||||
state.form.settlementMode = 'DIRECT_PAID'
|
||||
await flushPromises()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -165,7 +166,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="last-leg-fees admin-density">
|
||||
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
|
||||
<header class="last-leg-fees__header"><div><h2>{{ title }}<InlineHelp title="费用统计说明"><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></InlineHelp></h2></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
|
||||
<el-alert v-if="canView && !canEdit && !canReview" type="info" :closable="false" title="当前账号仅有海外段费用查看权限。" />
|
||||
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
|
||||
@@ -191,7 +192,7 @@ onMounted(load)
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE'"><td :colspan="form.settlementMode === 'DIRECT_PAID' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item></td><td v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label-position="left" label-width="110px" label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td :colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
|
||||
@@ -27,14 +27,12 @@ describe('ContainerSimulationCreate opted-in form', () => {
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).not.toHaveBeenCalled()
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(fields).toHaveLength(4)
|
||||
expect(fields).toHaveLength(3)
|
||||
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
|
||||
await flushPromises()
|
||||
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
|
||||
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
|
||||
await flushPromises()
|
||||
const specification = fields.find(field => field.props('label') === '统一规格')!
|
||||
expect(specification.find('input').exists()).toBe(!dense)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Container, Plus } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
@@ -9,7 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||
import type { ContainerSimulationCreateRequest } from '../types'
|
||||
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
|
||||
@@ -95,14 +95,54 @@ async function saveSimulation() {
|
||||
|
||||
<template>
|
||||
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
||||
<el-alert title="模拟柜用于先排货,创建后自动生成柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
|
||||
<section :class="{ 'density-dialog-section': dense }" data-tone="work"> <el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" :label-position="dense ? 'left' : 'top'" :label-width="dense ? '120px' : undefined" class="simulation-form" :class="{ 'density-form': dense }"><template v-if="dense"><table :class="{ 'density-table': dense, 'density-table--editable': dense }" aria-label="模拟柜资料"><tbody><tr><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item></td><td><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="统一规格"><span>{{ `${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg` }}</span><small :class="{ 'density-dialog-note': dense }">{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></td></tr></tbody></table></template><template v-else><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><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 label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></template></el-form></section>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="simulationOpen"
|
||||
class="simulation-create-dialog"
|
||||
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense }"
|
||||
title="新增模拟柜"
|
||||
width="min(800px, calc(100vw - 32px))"
|
||||
confirm-text="创建模拟柜"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="simulationSaving"
|
||||
:confirm-disabled="warehouseLoading || simulationChannelsLoading"
|
||||
@confirm="saveSimulation"
|
||||
>
|
||||
<template #header>
|
||||
<div class="simulation-heading"><span class="simulation-heading__icon"><Container :size="20" aria-hidden="true" /></span><span>新增模拟柜</span></div>
|
||||
</template>
|
||||
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
|
||||
<div class="simulation-fields">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId">
|
||||
<el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse">
|
||||
<el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelGroupCode">
|
||||
<el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道">
|
||||
<el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType" class="simulation-type">
|
||||
<el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))">
|
||||
<el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
|
||||
.simulation-heading { display: flex; align-items: center; gap: 10px; font-size: var(--admin-font-title, 16px); }
|
||||
.simulation-heading__icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.simulation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; row-gap: 8px; }
|
||||
.simulation-fields > * { min-width: 0; }
|
||||
.simulation-form :deep(.el-select) { width: 100%; }
|
||||
.simulation-type { grid-column: 1 / -1; }
|
||||
.simulation-type :deep(.el-select) { max-width: calc(50% - 12px); }
|
||||
@media (max-width: 600px) {
|
||||
.simulation-fields { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.simulation-type :deep(.el-select) { max-width: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
await flushPromises()
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('已确认离仓')
|
||||
expect(wrapper.text()).toContain('整柜已确认离仓;')
|
||||
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
|
||||
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
@@ -706,13 +707,13 @@ onMounted(async () => {
|
||||
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
|
||||
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
||||
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
||||
<template v-else-if="outboundDetail">
|
||||
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
|
||||
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
|
||||
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">{{ customsDepartureState === 'DEPARTED' ? '整柜已确认离仓;展开可查看去重后的完整门禁。' : '全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。' }}</p>
|
||||
|
||||
|
||||
<div class="gate-list">
|
||||
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
|
||||
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
|
||||
@@ -747,24 +748,24 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<p class="density-dialog-note">按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p>
|
||||
|
||||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></section>
|
||||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></td></tr></tbody></table></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
|
||||
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
|
||||
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><table class="density-table density-table--editable container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><table class="density-table density-table--editable container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
|
||||
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('ContainersView', () => {
|
||||
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
|
||||
expect(source).toContain('CONTAINER_SPECIFICATIONS')
|
||||
expect(simulationSource).toContain('selectSimulationContainerType')
|
||||
expect(simulationSource).toContain('创建后自动生成柜次号')
|
||||
expect(simulationSource).not.toContain('创建后自动生成柜次号')
|
||||
expect(source).toContain("{{ form.cabinetNo || '—' }}")
|
||||
expect(source).not.toContain('v-model="form.cabinetNo"')
|
||||
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
|
||||
|
||||
@@ -785,7 +785,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form">
|
||||
<table class="density-table density-table--editable container-editor-table" aria-label="货柜基础资料"><tbody>
|
||||
<tr><td><el-form-item label="真实集装箱号"><span>{{ form.containerNo || '待补录' }}</span></el-form-item></td><td><el-form-item label="柜次号"><span>{{ form.cabinetNo || '—' }}</span></el-form-item></td><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item></td><td><el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" /></el-form-item></td><td><el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item></td><td><el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td><td><el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="货柜容量(m³)"><span>{{ form.capacityCbm == null ? '—' : `${form.capacityCbm} m³` }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="重量上限(kg)"><span>{{ form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg` }}</span></el-form-item></td><td><el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item></td></tr>
|
||||
@@ -821,7 +821,6 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
|
||||
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
|
||||
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
|
||||
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 客户模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/customers/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,客户与权限语义见 `../../../docs/business-index.md`、`docs/平台客户层级与权限契约.md`。
|
||||
- 客户主档、成员与角色归 `../iam/`,账号与登录标识归 `backend auth/`,资质文件归 `file/`;本模块只做客户视图、审核入口与客户级计费规则。
|
||||
- 后端只查 `iam/`(客户主档与审核)、`auth/`(登录标识)、`file/`(资质)、`firstleg/customerstatistics/`(运营统计)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户列表、筛选与统计 | `pages/CustomersView.vue`;`customers` | `../iam/api/platformIam.ts`;`firstleg/customerstatistics/` |
|
||||
| 企业审核入口 | `pages/CustomersView.vue`;`customers/reviews` 模式 | `iam/` 审核接口;资质 `file/` |
|
||||
| 客户详情、计费规则与运营指标 | `pages/CustomerDetailView.vue`、`components/CustomerBillingRulesPanel.vue`、`components/CustomerOperatingMetricCard.vue`;`customers/:tenantId` | `iam/`、`firstleg/` |
|
||||
| 海外仓阶梯费用 | `pages/OverseasStorageFeesView.vue`、`CustomerOverseasStorageFeePanel.vue`;`customers/overseas-storage-fees` | `overseasStorageFee.ts`、`overseasStorageFeeTiers.ts`;`lastleg-service/` |
|
||||
| 客户箱规与计费主数据 | `components/CustomerBillingRulesPanel.vue` | `firstleg/` 箱规与计价主数据 |
|
||||
| 详情浮层标题与密度 | `customerDialogTitle.ts`、`customerDensity.css` | 本地实现,按公共密度样式约束 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 公司联系电话只作企业联系信息,不修改管理员/成员登录手机号,也不要求跨企业唯一;编辑客户资料不提交账号手机号变更。
|
||||
- 审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换。
|
||||
- 客户列表属 `*:list` 权限,已含详情只读;写操作使用独立权限,前端守卫不替代服务端鉴权。
|
||||
- 平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。
|
||||
- 长整型客户 ID 按字符串透传,不用内部 ID 作业务编号展示。
|
||||
- 海外仓阶梯费用按客户与仓库维度提交版本,金额与生效结果只认服务端返回。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/customers/<目标测试>`;审核必填或费用阶梯变更同时核对 `iam`/`lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
@@ -398,7 +399,7 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
<div v-else-if="initializationReady" v-loading="loading" class="loading-state" />
|
||||
|
||||
<section v-if="initializationReady && canChargeView" class="rule-card charge-rule-card">
|
||||
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算</h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div><p class="section-hint">勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></div></header>
|
||||
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算<InlineHelp title="默认规则说明"><p>勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></InlineHelp></h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div></div></header>
|
||||
<el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
|
||||
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
|
||||
<el-table-column label="内径尺寸(长×宽×高 cm)" min-width="240"><template #default="{ row }">{{ row.dimension ? formatDimension(row.dimension.innerLengthCm, row.dimension.innerWidthCm, row.dimension.innerHeightCm) : '—' }}</template></el-table-column>
|
||||
@@ -414,8 +415,8 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="editorVisible" :title="customerDialogTitle('编辑箱规尺寸 / 计算规则', { tenantCode: customerCode, tenantName: customerName })" fullscreenable width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving || chargeSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||
<el-form ref="editorForm" :model="form" label-position="top" class="density-form density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<h3 class="density-dialog-section__title">按箱规尺寸计算</h3>
|
||||
<p class="density-dialog-note">计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
||||
<h3 class="density-dialog-section__title">按箱规尺寸计算<InlineHelp title="箱规计算公式"><p>计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p></InlineHelp></h3>
|
||||
|
||||
<div class="editor-table-wrap">
|
||||
<table class="density-table density-table--editable editor-table" aria-label="箱规尺寸规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
||||
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" placeholder="请输入边长加值" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr>
|
||||
@@ -423,7 +424,7 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
</div>
|
||||
<div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div>
|
||||
</section>
|
||||
<section v-if="canChargeView" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">箱规按权重系数计算</h3><p class="density-dialog-note">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="density-table density-table--editable charge-rule-editor" aria-label="权重系数规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th scope="row">{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
|
||||
<section v-if="canChargeView" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">箱规按权重系数计算<InlineHelp title="系数匹配规则"><p>长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p></InlineHelp></h3><div class="charge-rule-editor-wrap"><table class="density-table density-table--editable charge-rule-editor" aria-label="权重系数规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th scope="row">{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
|
||||
</el-form>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
|
||||
</AppDialog>
|
||||
|
||||
@@ -99,7 +99,9 @@ describe('CustomerDetailView', () => {
|
||||
expect(document.body.textContent).toContain('企业管理员')
|
||||
expect(document.body.textContent).toContain('公司联系电话')
|
||||
expect(document.body.textContent).toContain('登录手机号')
|
||||
expect(document.body.textContent).toContain('修改会影响登录')
|
||||
expect(document.querySelector('.user-panel')?.textContent).not.toContain('修改会影响登录')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="查看账号信息说明"]')!.focus()
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('修改会影响登录'))
|
||||
expect(document.querySelector('.contact-panel .info-grid--three')).not.toBeNull()
|
||||
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
|
||||
expect(document.body.textContent).toContain('已替换')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -253,7 +254,7 @@ onBeforeUnmount(() => {
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<div class="basic-layout">
|
||||
<section class="detail-panel company-panel">
|
||||
<div class="panel-title"><div><h2>企业基本信息</h2><p>工商档案与合作配置</p></div><Building2 :size="19" /></div>
|
||||
<div class="panel-title"><div><h2>企业基本信息</h2></div><Building2 :size="19" /></div>
|
||||
<dl class="info-grid density-fields">
|
||||
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
|
||||
<div><dt>客户昵称</dt><dd>{{ displayText(customer.customerNickname) }}</dd></div>
|
||||
@@ -275,7 +276,7 @@ onBeforeUnmount(() => {
|
||||
<div v-else class="section-empty"><FileCheck2 :size="22" /><strong>暂无资质文件</strong><span>客户尚未上传企业资质</span></div>
|
||||
</section>
|
||||
<section class="detail-panel contact-panel">
|
||||
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
|
||||
<div class="panel-title"><div><h2>联系与合作信息</h2></div><UserRound :size="19" /></div>
|
||||
<dl class="info-grid info-grid--three density-fields">
|
||||
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
|
||||
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
|
||||
@@ -285,7 +286,7 @@ onBeforeUnmount(() => {
|
||||
</dl>
|
||||
</section>
|
||||
<section class="detail-panel user-panel">
|
||||
<div class="panel-title"><div><h2>企业用户</h2><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></div><UsersRound :size="19" /></div>
|
||||
<div class="panel-title"><div><h2>企业用户<InlineHelp title="账号信息说明"><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></InlineHelp></h2></div><UsersRound :size="19" /></div>
|
||||
<div v-if="enterpriseMembers.length" class="user-table-wrap">
|
||||
<table><thead><tr><th>账号</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th><th>状态</th></tr></thead>
|
||||
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
|
||||
|
||||
@@ -268,7 +268,9 @@ describe('CustomersView', () => {
|
||||
'货代资质证书选填',
|
||||
'银行开户许可证选填',
|
||||
])
|
||||
expect(document.body.textContent).toContain('营业执照和18位统一社会信用代码为必填业务项')
|
||||
expect(document.querySelector('.qualification-section')?.textContent).not.toContain('营业执照和18位统一社会信用代码为必填业务项')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="查看资质提交规则"]')!.focus()
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('营业执照和18位统一社会信用代码为必填业务项'))
|
||||
})
|
||||
|
||||
it('creates a customer with a business license and no legal-person ID', async () => {
|
||||
@@ -320,6 +322,8 @@ describe('CustomersView', () => {
|
||||
tableButtonByText('编辑')?.click()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[aria-label="查看企业联系信息说明"]').trigger('focus')
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('不会修改企业管理员登录手机号'))
|
||||
const phone = wrapper.get('input[placeholder="请输入公司联系电话"]')
|
||||
expect(phone.attributes('disabled')).toBeUndefined()
|
||||
await phone.setValue('0755-12345678')
|
||||
@@ -331,7 +335,6 @@ describe('CustomersView', () => {
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(document.body.textContent).toContain('客户资料已保存')
|
||||
expect(document.body.textContent).not.toContain('尚未提交审核')
|
||||
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
|
||||
expect(customersSource).not.toContain('联系电话(登录用户名)')
|
||||
expect(customersSource.indexOf('if (!failures.length) editorOpen.value = false')).toBeLessThan(customersSource.indexOf('await loadCustomers()'))
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
@@ -1086,7 +1087,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="144px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
|
||||
@@ -1094,7 +1095,7 @@ onMounted(() => { void loadCustomers() })
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">联系与商务</h3><p class="density-dialog-note">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p>
|
||||
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td></tr>
|
||||
@@ -1103,7 +1104,7 @@ onMounted(() => { void loadCustomers() })
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section qualification-section" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料</h3><p class="density-dialog-note">营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<section class="density-dialog-section qualification-section" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||
@@ -1121,7 +1122,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploading || (draft.uploaded && !draft.file)" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
|
||||
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin 历史报关模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/customs/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/CustomsCasesView.vue`、`api/platformCustoms.ts`、`types.ts`、`status.ts`:承载历史报关案件、清单、代理提交与报关行回执。
|
||||
- 后端归 `firstleg/customs/`(`ContainerCustomsService.java` 等),文件归 `file/`。
|
||||
- **不要与货柜详情里的“送柜报关”面板混淆**:整柜报关资料在 `../container-loading/components/ContainerCustomsPanel.vue`,本模块是历史案件入口。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 报关案件列表、状态与筛选 | `pages/CustomsCasesView.vue` | `api/platformCustoms.ts`;`firstleg/customs/` |
|
||||
| 报关清单与代理提交 | 案件详情与提交动作 | `api/platformCustoms.ts` |
|
||||
| 报关行回执与完成文件 | 详情附件区 | `file/` 的 `LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` |
|
||||
| 状态与中文文案 | `status.ts`、`types.ts` | 服务端状态机 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 报关源文件、回执与完成文件都是客户作用域,`businessId` 固定为装柜计划 ID;下载走鉴权入口。
|
||||
- 回执与完成文件必须满足整柜完成与可见性条件,前端不自行放宽或绕过。
|
||||
- 提交给报关行后案件进入只读确认流程;打回、补资料等状态以服务端为准,不在前端改状态。
|
||||
- 与货柜页签共用同一后端能力时,只改一处展示,不要复制业务规则。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/customs/<目标测试>`;文件或状态变化同时核对 `firstleg/customs/` 与 `file/` 的报关合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -38,5 +38,5 @@ async function submit() { if (!detail.value || !submitForm.customsAgentId || !cu
|
||||
watch([page, pageSize], () => void load()); onMounted(() => { void load(); void loadCustomers(); void loadCustomsAgents() })
|
||||
</script>
|
||||
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" size="small" type="primary" plain @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-alert v-if="customsAgentsError" type="warning" :closable="false" :title="customsAgentsError"><el-button link type="warning" :loading="customsAgentsLoading" @click="loadCustomsAgents">重新加载</el-button></el-alert><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理" required><el-select :model-value="submitForm.customsAgentId" filterable clearable placeholder="请选择报关代理" :loading="customsAgentsLoading" no-data-text="暂无可用报关供应商" style="width:100%" @update:model-value="selectCustomsAgent(String($event || ''))"><el-option v-for="supplier in customsAgents" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></el-form-item></el-col></el-row><el-button type="primary" :disabled="!submitForm.customsAgentId" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>提交批次</h3><el-empty v-if="!detail.submissionBatches.length" description="暂无提交批次" :image-size="56" /><el-timeline v-else><el-timeline-item v-for="batch in detail.submissionBatches" :key="batch.id" :timestamp="batch.createdAt">报关代理:{{ batch.customsAgentName || '服务端未返回名称快照' }}</el-timeline-item></el-timeline><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-alert v-if="customsAgentsError" type="warning" :closable="false" :title="customsAgentsError"><el-button link type="warning" :loading="customsAgentsLoading" @click="loadCustomsAgents">重新加载</el-button></el-alert><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理" required><el-select :model-value="submitForm.customsAgentId" filterable clearable placeholder="请选择报关代理" :loading="customsAgentsLoading" no-data-text="暂无可用报关供应商" style="width:100%" @update:model-value="selectCustomsAgent(String($event || ''))"><el-option v-for="supplier in customsAgents" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" /></el-form-item></el-col></el-row><el-button type="primary" :disabled="!submitForm.customsAgentId" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>提交批次</h3><el-empty v-if="!detail.submissionBatches.length" description="暂无提交批次" :image-size="56" /><el-timeline v-else><el-timeline-item v-for="batch in detail.submissionBatches" :key="batch.id" :timestamp="batch.createdAt">报关代理:{{ batch.customsAgentName || '服务端未返回名称快照' }}</el-timeline-item></el-timeline><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}:global(.el-dialog:has(.submit-form,.el-descriptions)){width:min(900px,calc(100vw - 28px))!important;max-height:90vh;overflow:hidden}:global(.el-dialog:has(.submit-form,.el-descriptions) .el-dialog__body){max-height:calc(90vh - 120px);overflow-y:auto}</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Admin 工作台模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/dashboard/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/DashboardView.vue` 与 `api/dashboard.ts`;指标口径与实时/缓存边界见工作区 `docs/首页数据看板真实化与统计策略规划.md`。
|
||||
- 后端查 `firstleg/` 的 `firstleg/dashboard/PlatformDashboardMetricsController.java`;自营经营总览是另一个入口(`../self-operated/`),不要混。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 首页指标卡片与趋势 | `pages/DashboardView.vue`;根路径默认页 | `api/dashboard.ts`;`firstleg/dashboard/` |
|
||||
| 指标筛选与刷新 | 页面顶部筛选 | `api/dashboard.ts`;服务端统计口径 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 指标只展示服务端结果,前端不二次汇总或补算同比/环比。
|
||||
- 缓存与实时口径按统计策略文档执行,不在页面自行加轮询或本地缓存。
|
||||
- 自营经营总览(领星数据)不进入本模块,走 `../self-operated/AGENTS.md`。
|
||||
- 指标下钻跳转要带明确业务筛选条件,不用指标名称反推业务状态。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/dashboard/<目标测试>`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 尺寸配置模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/dimension-config/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 本模块**没有页面**,只有 `api.ts`、`types.ts`、`index.ts`:它是客户四档箱规与尺寸系数的数据层,由 `../inbounds/`(测量)和 `../customers/`(客户计费规则)消费。
|
||||
- 后端只查 `firstleg/` 的 `CustomerDimensionConfigService.java`、`CustomerDimensionWeightingService.java`。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户箱规配置与四档尺寸 | `api.ts`、`types.ts` | `firstleg/service/CustomerDimensionConfigService.java` |
|
||||
| 尺寸系数与默认权重算法 | `api.ts` | `firstleg/service/CustomerDimensionWeightingService.java` |
|
||||
| 模块对外导出 | `index.ts` | 供 `../inbounds/`、`../customers/` 引用 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 客户固定四档箱规与默认算法(`ADD/MULTIPLY`)的正式规则见详细索引同名节;本模块只提供数据入口,不在调用方重写算法或补默认档位。
|
||||
- 新建客户时的箱规与权重系数初始化由 IAM 建档流程触发(`iam/` 的初始化服务 + Firstleg 内部接口),本模块只提供查询与保存入口。
|
||||
- 尺寸单位固定为厘米,体积由服务端计算;页面与调用方不自行换算。
|
||||
- 改类型或字段时同步检查 `../inbounds/`、`../customers/` 两个消费方与 Firstleg 合同测试。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/dimension-config/<目标测试>`;字段变化同时核对两个消费方。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 国内整柜模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/domestic-containers/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 页面:`pages/DomesticContainersView.vue`(整柜业务列表)与 `pages/DomesticContainerInventoryDetailView.vue`(库存详情);接口 `api/domesticContainers.ts`。
|
||||
- 后端归 `warehouse/domesticcontainer/`(`PlatformDomesticContainerController.java`、`DomesticContainerService.java`)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 整柜入库、出库与库存列表 | `pages/DomesticContainersView.vue`;`domestic-containers` | `api/domesticContainers.ts`;`warehouse/domesticcontainer/` |
|
||||
| 整柜库存快照与不可变交易记录 | `pages/DomesticContainerInventoryDetailView.vue`;`domestic-containers/inventories/:inventoryId` | `api/domesticContainers.ts` |
|
||||
| 汇总样式 | `pages/domesticContainersSummary.css` | 本地样式,按公共密度规范接入 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 库存数量与交易记录只读展示服务端快照,不做前端加减或重算。
|
||||
- 出入库交易是追加式不可变记录,不提供编辑或删除入口。
|
||||
- 与货柜履约(`../container-loading/`)的柜次是不同概念:本模块是国内仓整柜库存业务,不承担装柜与订舱。
|
||||
- 权限统一 `platform:domestic-container:list`,写操作按独立权限。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/domestic-containers/<目标测试>`;字段变化同时核对 `domestic-container-v1.yaml`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 下载中心模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/downloads/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/DownloadCenterView.vue`、`useExportTask.ts`、`api/platformExportTasks.ts`;所有业务导出都走这一套。
|
||||
- 后端归 `file/`(`PlatformExportTaskController`、`ExportTaskService`、`ExportStateService`、`ExportDefinitionRegistry`),实际取数由各业务服务的 Provider 分批提供。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 导出任务列表、下载与失败重试 | `pages/DownloadCenterView.vue` | `api/platformExportTasks.ts`;`file/` |
|
||||
| 业务页面创建导出任务 | `useExportTask.ts`(供其他模块调用) | `api/platformExportTasks.ts` |
|
||||
| 自动退避与重试状态 | 任务行 `attemptCount/maxAttempts/nextRetryAt` | `file/` `ExportStateService` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 业务页面只创建异步任务,不直接拼装文件,也不直连 Provider。
|
||||
- 重试只允许 `FAILED`,或 `PENDING` 且已有失败尝试、正在等待自动退避的任务;`RUNNING`、初次等待、已成功或已过期任务拒绝重试。
|
||||
- 导出类型必须在 `ExportDefinitionRegistry` 登记;新增类型同步检查业务 Provider、权限、页面标签与重试映射。
|
||||
- 下载链接走鉴权接口,不缓存为公共地址;导出文件不进入浏览器本地持久化。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/downloads/<目标测试>`;导出类型变化同时核对 `file/` 的 Provider 契约与业务服务 Provider。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin FBA 价格簿模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/fba-pricing/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 子域:`carrier-rates/`(客户专属价格簿、柜级阶梯运费、上传与确认)、`customer-rules/`(客户计费规则);各自有页面与 API。
|
||||
- 后端归 `firstleg/`(`FbaPricingService.java`、`PlatformPriceBookService.java`、`firstleg/pricing/ContainerTierAmountAllocator.java`);计费消费方见 `../inbounds/`。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户专属价格簿列表、上传与确认 | `carrier-rates/pages/CarrierRatesView.vue` | `carrier-rates/api/platformPriceBooks.ts`;`firstleg/PlatformPriceBookService` |
|
||||
| 价格版本详情、仓库映射与只读价格行 | `carrier-rates/pages/CarrierRateDetailView.vue` | 同上 |
|
||||
| 客户计费规则 | `customer-rules/api.ts` | `firstleg/` 箱规与计价主数据 |
|
||||
| 对外导出 | `public.ts` | — |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 价格簿页面必须显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不混版本。
|
||||
- 计价优先客户专属 ACTIVE 版本,无专属才回退公共 ACTIVE;柜级阶梯金额与分摊只展示服务端结果。
|
||||
- 调整计费量只能在服务端允许区间内,并提交当前费用版本、原因与二次确认。
|
||||
- 上传后按 `versionId` 进入独立详情页,价格行只读;确认动作不可逆,需二次确认。
|
||||
- 私人仓尾程派送费使用稳定代码,只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/fba-pricing/<目标测试>`;价格字段变化同时核对 `fba-pricing-v1.yaml` 与 `../inbounds/` 计费消费。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
|
||||
@@ -473,7 +474,7 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section v-if="isPending && canEdit" class="mapping-section">
|
||||
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射</h2><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
|
||||
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
|
||||
<el-alert
|
||||
v-if="nonMappingBlockingIssues.length"
|
||||
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
|
||||
@@ -512,7 +513,7 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验</h2><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></div></header>
|
||||
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
|
||||
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
|
||||
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
|
||||
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
|
||||
@@ -523,7 +524,7 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="detail-section channel-preview">
|
||||
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2><p>显示上传 Excel 的解析结果,确认页不提供在线改价。</p></div></header>
|
||||
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2></div></header>
|
||||
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
|
||||
<template v-else>
|
||||
<el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { FileSpreadsheet, FileUp } from '@lucide/vue'
|
||||
@@ -397,10 +398,10 @@ onMounted(() => {
|
||||
@confirm="submitUpload"
|
||||
>
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert title="上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。" type="info" :closable="false" show-icon />
|
||||
|
||||
<el-form class="density-form" label-position="left" label-width="90px" :disabled="uploading">
|
||||
<table class="density-table density-table--editable" aria-label="上传价格表资料"><tbody>
|
||||
<tr><td><el-form-item label="报价范围"><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="报价范围"><template #label><span>报价范围<InlineHelp title="报价上传规则"><p>上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。</p></InlineHelp></span></template><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="价格表文件">
|
||||
<input ref="uploadInput" class="visually-hidden-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria-label="选择 XLSX 船期价格表" :disabled="uploading" @change="selectUploadFile" />
|
||||
<div class="upload-file-control"><el-button :disabled="uploading" @click="uploadInput?.click()"><FileSpreadsheet :size="16" />选择 XLSX 文件</el-button><span class="upload-file-name">{{ uploadFile?.name || '尚未选择文件' }}</span></div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 费用项目模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/fee-items/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有一个页面 `pages/FeeItemsView.vue`(路由 `master-data/fee-items`,挂在系统主数据下)和一个接口文件 `api/platformFeeItems.ts`;弹框实现与页面同目录。
|
||||
- 后端只查 `firstleg/controller/PlatformFeeItemController.java` 与 `openapi/fee-item-v1.yaml`;客户专属价格簿归 `../fba-pricing/`,两者不是同一概念。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 费用项目列表、启停与排序 | `pages/FeeItemsView.vue`;`master-data/fee-items` | `api/platformFeeItems.ts`;`firstleg/controller/PlatformFeeItemController.java` |
|
||||
| 新增/编辑费用项目弹框 | `pages/FeeItemsView.vue` 内弹框 | `api/platformFeeItems.ts` 的 `SavePlatformFeeItemRequest`;当前表单不提交 `feeCode` |
|
||||
| 默认价格(CNY,可空)与类别/贸易范围 | 列表与新增/编辑弹框 | `api/platformFeeItems.ts`;带价与校验规则见详细索引 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 服务端支持修改 `feeCode`,省略时保留原编码;当前 Admin 表单未暴露该字段,不因本指引扩展界面。编码格式、长度与唯一性以服务端契约和校验为准。
|
||||
- `defaultPrice` 只是带入初值:仅当用户主动选择该项目时带入(入库同时带单价与金额、柜次带金额),**均允许人工修改**;为空表示不自动带价,`0` 表示明确的零元。修改或清空默认价不追改已有费用;打开已有费用、切换供应商或保存业务费用时不重新套用默认价,也不改变审核、发布与结算流程。
|
||||
- 停用费用项目不回溯已出账费用;历史账单继续显示原项目名称快照。
|
||||
- 供应商类别仅对需要供应商的费用类型有效,留空表示任意启用供应商,不由前端补默认值。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/fee-items/<目标测试>`;字段或权限变化同时运行 `pnpm typecheck`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 头程订单接口模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/first-leg-orders/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 本模块**没有页面**,只有 `index.ts` 与 `api/platformFirstLegOrders.ts`,提供旧版头程订单列表与客户订单统计接口的封装;新页面统一在 `../unified-orders/`。
|
||||
- 后端查 `order/`(`/platform/v1/orders/first-leg`、客户订单统计)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 旧版头程订单分页查询 | `api/platformFirstLegOrders.ts` 的 `listPlatformFirstLegOrders` | `order/` |
|
||||
| 客户维度订单统计 | `api/platformFirstLegOrders.ts` 的 `getPlatformTenantOrderStatistics` | `order/` |
|
||||
| 对外导出 | `index.ts` | — |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 新增订单页面与交互一律进 `../unified-orders/`,不要在本模块长页面。
|
||||
- 本模块只做请求封装,不承载业务规则、状态判断或权限逻辑。
|
||||
- 客户统计的客户范围由服务端认证上下文决定,前端只传客户 ID。
|
||||
- 若确认无调用方,按当前无兼容原则直接删除,不保留别名或兼容分支。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/first-leg-orders/<目标测试>`;改签名前用 `rg` 确认全部调用方。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 平台用户与权限模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/iam/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,权限语义见 `../../../docs/平台客户层级与权限契约.md`。
|
||||
- 本模块是**平台侧**用户、角色与权限资源维护入口;客户侧成员与角色归 `backend iam/`,账号与登录标识归 `backend auth/`,前端守卫在 `../core/access/`。
|
||||
- 后端只查 `iam/`(平台 RBAC、菜单资源、客户与成员)与 `auth/`(账号与会话撤销)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 平台用户列表、PDA 人员与海外仓绑定 | `pages/InternalUsersView.vue`;`system/users` | `api/platformIam.ts`;`iam/`、`auth/` |
|
||||
| 角色与角色成员 | `pages/RolesView.vue`;`system/roles` | `api/platformIam.ts`;`iam/` |
|
||||
| 权限资源与菜单 | `pages/PermissionsView.vue`、`utils/permissionTree.ts`;`system/permissions` | `api/platformIam.ts`;`iam/` 菜单资源 |
|
||||
| 客户作用域选择器 | `components/PlatformTenantSelector.vue` | `iam/` 客户主档 |
|
||||
| 权限判定与展示 | `utils/platformPermission.ts`、`../core/access/` | 服务端权限快照;前端不替代鉴权 |
|
||||
| 模块密度样式 | `iamDensity.css` | 按公共密度规范接入 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓;其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库,长整型 ID 按字符串透传。
|
||||
- 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓。
|
||||
- 列表权限 `*:list` 同时授权列表与详情只读;`create`、`edit`、`delete`、`status`、`approve` 等保持独立,不保留旧 `*:view`。
|
||||
- 供应商固定使用 `platform:supplier:list`;`platform:supplier:view` 不得重新启用以兼容旧调用。
|
||||
- 菜单、路由与权限资源三处必须同步:改权限码时同时核对 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL 与菜单资源。
|
||||
- 前端守卫只改善体验,服务端必须重复校验租户、角色与数据范围。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/iam/<目标测试>`;权限码或菜单资源变更同时核对 `iam` 菜单 SQL 与权限契约测试。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 入库模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/inbounds/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,字段与计费细则见 `../../../docs/business-index.md`。
|
||||
- 模块按子域分目录:`measurement/`(测量)、`review/`(测量审核)、`weight/`(称重与 OCR)、`fee/`(费用与人工定价)、`core/`(入库单公共)。先定子域再进文件。
|
||||
- 后端入口为 `firstleg/`(测量、审核、计价、扣货、称重库位)与 `warehouse/`(库位);仅涉及展示、文案且不涉及接口、权限、状态、金额或数据归属时,默认不读后端;涉及上述边界时沿实际 API 进入对应服务。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/子域入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 订单测量列表与工作区 | `pages/InboundMeasurementsView.vue`、`components/InboundWorkflowWorkspace.vue`;`inbounds/measurements` | `measurement/api.ts`;`firstleg/service/InboundMeasurementService.java` |
|
||||
| 测量弹框、测量面板、尺寸策略与证据图 | `measurement/InboundMeasurementDialog.vue`(组合壳)、`measurement/InboundMeasurementPanel.vue`(数据与操作)、`measurement/components/MeasurementEvidenceThumbnail.vue` | 实际请求在 `measurement/InboundMeasurementPanel.vue` → `measurement/api.ts` → 平台链路 `firstleg/controller/PlatformInboundMeasurementController.java` → `firstleg/service/InboundMeasurementService.java`、`MeasurementSubmissionService.java`;设备上报是另一条链路(`MojiaMeasurementOpenApiService`),不要混;弹框只组合面板与 OCR 面板,不直接调接口 |
|
||||
| 测量数据审核 | `pages/MeasurementReviewsView.vue`、`review/MeasurementReviewPanel.vue`;`inbounds/measurement-reviews` | `review/api.ts`;`firstleg/service/MeasurementReviewService.java`、`firstleg/service/InitialInboundPricingService.java` |
|
||||
| 称重图片、OCR 与库位槽状态 | `weight/components/WeightOcrPanel.vue`、`weight/components/WeightImageThumbnail.vue` | `weight/api/weightOcr.ts`;`firstleg/service/MeasurementLocationOccupationService.java`、`firstleg/service/MeasurementSubmissionService.java` |
|
||||
| 入库单列表、摘要与历史 | `pages/InboundOrdersView.vue`、`core/InboundSummaryPanel.vue`、`core/InboundHistoryPanel.vue`;`inbounds` | `core/api.ts`、`core/types.ts`;`firstleg/service/InboundOrderQueryService.java` |
|
||||
| FBA 人工定价(无匹配价格) | `fee/InboundFeePanel.vue` | `fee/api.ts` 的 `/manual-quote`;`firstleg/service/FbaPricingService.java` |
|
||||
| 一件代发托盘打印 | `core/DropshipPalletPrintPanel.vue` | `core/api.ts`;`print/`;仅一件代发订单展示 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 客户四档箱规与默认算法(`ADD/MULTIPLY`)的规则正文在详细索引维护;本模块只按 `../dimension-config/` 消费,保存后以外箱尺寸、体积重和计费基数只认服务端返回值,页面不独立重算。
|
||||
- 费用确认的实际箱数、毛重和体积取值口径以详细索引“入库、测量”节(`actualMeasurementRows`)为准,本文件不复制该规则。
|
||||
- 称重卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或推导整票预留引导;删除图片须提示该槽位仍归本票保留,不按图片下标推导托盘或库位。
|
||||
- 人工定价仅 FBA、已有实际测量且价格未命中时开放,客户已确认费用禁止改价;`MANUAL_QUOTE` 不伪造价格簿关联。
|
||||
- 扣货与解除、凭证下载走 `../unified-orders/` 与 `../inbounds/` 的正式入口,本模块不另建凭证链路。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/inbounds/<目标测试>`;计费或库位相关变更同时核对 `firstleg` 对应合同测试。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -19,7 +19,7 @@ import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { firstLegBusinessTypeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||
import { listPlatformInbounds } from '../core/api'
|
||||
import type { InboundOrderSummary } from '../core/types'
|
||||
import type { InboundOrderSummary, InboundPageResult } from '../core/types'
|
||||
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
|
||||
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
|
||||
import { approveMeasurement, getMeasurementReviewDetail } from '../review/api'
|
||||
@@ -33,7 +33,12 @@ type FilterModel = QueryModel & {
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
|
||||
const props = defineProps<{
|
||||
mode: InboundTaskMode
|
||||
title?: string
|
||||
taskLoader?: (pagination: { page: number; size: number }) => Promise<InboundPageResult>
|
||||
}>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
|
||||
@@ -142,6 +147,7 @@ function dateParam(value: string | undefined, end = false) {
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
if (props.taskLoader) return
|
||||
const serial = ++optionsRequestSerial
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
@@ -168,7 +174,7 @@ async function loadTasks() {
|
||||
loadError.value = null
|
||||
const applied = appliedFilters.value
|
||||
try {
|
||||
const response = await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
|
||||
const response = props.taskLoader ? await props.taskLoader({ page: page.value, size: pageSize.value }) : await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
warehouseId: applied.warehouseId || undefined,
|
||||
@@ -218,6 +224,7 @@ function openDetail(row: InboundOrderSummary) {
|
||||
}
|
||||
|
||||
async function handleTaskChanged() {
|
||||
emit('changed')
|
||||
const target = selectedInbound.value
|
||||
if (!isReview.value) {
|
||||
reviewDialogOpen.value = false
|
||||
@@ -297,6 +304,7 @@ async function batchApproveMeasurements() {
|
||||
}
|
||||
}
|
||||
await loadTasks()
|
||||
if (successCount) emit('changed')
|
||||
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
|
||||
const pricingReason = [...new Set(pricingWarnings.filter(Boolean))].slice(0, 3).join(';')
|
||||
if (!failureCount && !pricingWarnings.length) ElMessage.success(`已批量审核通过 ${successCount} 条测量数据,费用初算已生成`)
|
||||
@@ -325,6 +333,12 @@ function clearSelectedInbound() {
|
||||
reviewNavigationLoading.value = false
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (page.value === 1) return loadTasks()
|
||||
page.value = 1
|
||||
}
|
||||
defineExpose({ reload })
|
||||
|
||||
watch([page, pageSize], () => { void loadTasks() })
|
||||
onMounted(() => { void loadOptions(); void loadTasks() })
|
||||
onActivated(() => {
|
||||
@@ -360,6 +374,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
>
|
||||
<template #toolbar-start><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></template>
|
||||
<template #filters>
|
||||
<slot name="filters" :loading="loading">
|
||||
<slot name="status-tabs" />
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
@@ -370,6 +385,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
@search="applyFilters"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<template #notices><template v-if="customerLoadError || loadError">
|
||||
<div class="state-stack">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Printer, RefreshCw } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -88,9 +89,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1 })
|
||||
<section class="dropship-print-panel admin-density" aria-labelledby="dropship-print-title">
|
||||
<header class="print-heading">
|
||||
<div>
|
||||
<h3 id="dropship-print-title">一件代发托盘打印</h3>
|
||||
<p>测量审核生成托盘码后,在电脑端打印并贴到空托盘,再交由 PDA 扫码组托。</p>
|
||||
<p>箱数与 SKU 种类数按整托统计,包含合托的其他入仓码;相同 SKU 编码只计一种,不代表商品件数。</p>
|
||||
<h3 id="dropship-print-title">一件代发托盘打印<InlineHelp title="托盘打印说明"><p>测量审核生成托盘码后,在电脑端打印并贴到空托盘,再交由 PDA 扫码组托。</p><p>箱数与 SKU 种类数按整托统计,包含合托的其他入仓码;相同 SKU 编码只计一种,不代表商品件数。</p></InlineHelp></h3>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="print-actions">
|
||||
<el-button :disabled="loading || Boolean(printing)" @click="loadPallets"><RefreshCw :size="15" />刷新</el-button>
|
||||
|
||||
@@ -438,9 +438,9 @@ defineExpose({ reload: loadFee })
|
||||
</header>
|
||||
|
||||
<AppDialog v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial">
|
||||
<el-alert title="重量和体积各选择一项;默认选择实际测量数据。" type="info" :closable="false" show-icon />
|
||||
|
||||
<div class="trial-source-grid">
|
||||
<section><h4>计费重量(单选)</h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
|
||||
<section><h4>计费重量(单选)<InlineHelp title="试算取值说明"><p>重量和体积各选择一项;默认选择实际测量数据。</p></InlineHelp></h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
|
||||
<section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
@@ -571,10 +571,10 @@ defineExpose({ reload: loadFee })
|
||||
:confirm-loading="adjusting"
|
||||
@confirm="submitAdjustment"
|
||||
>
|
||||
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的费用版本;小计和总额由服务端按计费量、单价和附加费重算。" />
|
||||
<el-alert type="warning" :closable="false" show-icon title="调整后将重新计算费用。" />
|
||||
<el-alert class="adjustment-alert" type="info" :closable="false" show-icon>
|
||||
<template #title>计费量允许在系统原始基准值 ±{{ formatNumber(snapshot?.tolerancePercent, 2) }}% 内调整</template>
|
||||
<p>系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。</p>
|
||||
<p>系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}。</p>
|
||||
</el-alert>
|
||||
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
|
||||
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
|
||||
@@ -595,9 +595,9 @@ defineExpose({ reload: loadFee })
|
||||
<span class="adjustment-unit">{{ snapshot?.currency }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="adjustment-total"><span>当前服务端计算金额</span><strong>{{ formatMoney(snapshot?.totalAmount, snapshot?.currency) }}</strong><small>提交后以服务端返回的新版本金额为准</small></div>
|
||||
<div class="adjustment-total"><span>当前金额</span><strong>{{ formatMoney(snapshot?.totalAmount, snapshot?.currency) }}</strong></div>
|
||||
<el-form-item label="调整原因" prop="reason" required>
|
||||
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说明价格变更、补充费用或修正原因,记录将进入审计历史" />
|
||||
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入调整原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -20,3 +20,5 @@ export type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './fee
|
||||
export { downloadWeightImage } from './weight/api/weightOcr'
|
||||
export type { InboundMeasurementDetail } from './measurement/types'
|
||||
export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types'
|
||||
|
||||
export type { InboundOrderSummary, InboundPageResult } from './core/types'
|
||||
|
||||
@@ -885,7 +885,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog class="admin-density-dialog" v-model="manualEntryOpen" :title="detail?.inboundCode ? `手动录入箱规 · ${detail.inboundCode}` : '手动录入箱规'" width="min(680px, 92vw)" destroy-on-close append-to-body>
|
||||
<el-dialog class="admin-density-dialog" v-model="manualEntryOpen" :title="detail?.inboundCode ? `手动录入箱规 · ${detail.inboundCode}` : '手动录入箱规'" width="min(1000px, 92vw)" destroy-on-close append-to-body>
|
||||
<el-alert v-if="manualEntryErrors.length" type="error" :closable="false" show-icon class="manual-entry-alert" title="请修正以下问题">
|
||||
<ul><li v-for="error in manualEntryErrors" :key="error">{{ error }}</li></ul>
|
||||
</el-alert>
|
||||
@@ -906,8 +906,8 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<th scope="row"><label for="manual-time">测量时间 *</label></th><td><el-date-picker id="manual-time" v-model="manualForm.measuredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm:ss" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="manual-machine">设备/来源编号</label></th><td><el-input id="manual-machine" v-model="manualForm.machineNo" maxlength="64" placeholder="请输入设备编号(选填)" /></td>
|
||||
<th scope="row"><label for="manual-shipment">Shipment ID</label></th><td><el-input id="manual-shipment" v-model="manualForm.scannedShipmentId" maxlength="64" placeholder="请输入扫描运单号(选填)" /></td>
|
||||
<th scope="row"><label for="manual-machine">设备/来源编号</label></th><td><el-input id="manual-machine" v-model="manualForm.machineNo" maxlength="64" /></td>
|
||||
<th scope="row"><label for="manual-shipment">Shipment ID</label></th><td><el-input id="manual-shipment" v-model="manualForm.scannedShipmentId" maxlength="64" /></td>
|
||||
</tr>
|
||||
<tr><th scope="row">当前箱测量图片 *</th><td colspan="3">
|
||||
<el-upload class="manual-entry-upload" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectManualImage" :on-remove="() => { manualEntryImage = null }">
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
const toolbar = wrapper.get('.review-toolbar')
|
||||
expect(toolbar.text()).toContain('待审核')
|
||||
expect(toolbar.text()).toContain('修改重量')
|
||||
expect(toolbar.text()).toContain('通过并进入待分柜')
|
||||
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
|
||||
expect(toolbar.text()).toContain('刷新')
|
||||
expect(toolbar.text()).toContain('客户:柏悦客户')
|
||||
expect(toolbar.text()).toContain('渠道:海运')
|
||||
@@ -99,10 +99,10 @@ describe('MeasurementReviewPanel', () => {
|
||||
const warning = vi.spyOn(ElMessage, 'warning')
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType: 'ONE_CLICK_DROPSHIP' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('通过并进入待分柜')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待一件代发组托'))!.trigger('click')
|
||||
expect(wrapper.text()).not.toContain('通过并进入')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过并进入待一件代发组托' }))
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过' }))
|
||||
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托'))
|
||||
expect(approveMeasurement).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
@@ -209,7 +209,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
|
||||
await wrapper.get('.mock-weight-change').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(2)
|
||||
@@ -227,7 +227,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
|
||||
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
|
||||
expect(wrapper.vm.approvalButtonLabel).toBe('通过并进入待分柜')
|
||||
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
|
||||
wrapper.vm.enableWeightCorrection()
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
|
||||
@@ -245,7 +245,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const approveButton = wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!
|
||||
const approveButton = wrapper.findAll('button').find((button) => button.text() === '通过')!
|
||||
await approveButton.trigger('click')
|
||||
await approveButton.trigger('click')
|
||||
|
||||
@@ -263,12 +263,12 @@ describe('MeasurementReviewPanel', () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
expect(wrapper.find('.review-panel').exists()).toBe(true)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('通过并进入待分柜'))).toBe(true)
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '通过')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks approval while the order detail navigation is loading', async () => {
|
||||
@@ -279,7 +279,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
|
||||
|
||||
expect(approveMeasurement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ const canReview = computed(() => access.can('firstleg:inbound:measurement-review
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
|
||||
const approvalTargetLabel = computed(() => props.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '待一件代发组托' : '待分柜')
|
||||
const approvalButtonLabel = computed(() => `通过并进入${approvalTargetLabel.value}`)
|
||||
const approvalButtonLabel = computed(() => '通过')
|
||||
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
|
||||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||||
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
|
||||
|
||||
@@ -656,7 +656,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
:show-confirm="Boolean(previewResult && previewCanConfirm && (previewResult.recognitionStatus !== 'CONFIRMED' || reviewCorrectionEnabled))"
|
||||
:confirm-text="previewConfirmText"
|
||||
confirm-type="success"
|
||||
width="min(920px, calc(100vw - 28px))"
|
||||
width="min(1080px, calc(100vw - 28px))"
|
||||
fullscreenable
|
||||
@confirm="previewResult && confirmResult(previewResult, true)"
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ function hasMeaningfulPlaceholder(tag: string): boolean {
|
||||
}
|
||||
|
||||
describe('正式业务模块输入提示合同', () => {
|
||||
it('所有可编辑 el-input 与 el-input-number 都提供 placeholder', () => {
|
||||
it('查询条件的可编辑输入框保留 placeholder', () => {
|
||||
const violations: string[] = []
|
||||
|
||||
for (const file of vueFiles(modulesRoot)) {
|
||||
@@ -37,6 +37,7 @@ describe('正式业务模块输入提示合同', () => {
|
||||
|
||||
for (const match of source.matchAll(openingTag)) {
|
||||
const tag = match[0]
|
||||
if (!/\s(?:v-model(?::[\w-]+)?(?:\.[\w-]+)*|:model-value)="[^"]*(?:filter|query|search|keyword)/i.test(tag)) continue
|
||||
if (isPermanentlyReadOnly(tag)) continue
|
||||
if (hasMeaningfulPlaceholder(tag)) continue
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Admin 装柜清单模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/loading-lists/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,页面/接口细则见 `../../../docs/business-index.md`。
|
||||
- 预装清单与正式装柜清单是两条链路:`loading-plans` 为预装,`loading-lists` 为装柜清单;改前先确认当前页面属于哪条。
|
||||
- 后端只查 `firstleg/`(`LoadingListService`、`split/` 预装、`loading/`)与 `print/`(托盘标、单据);纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 预装清单列表 | `pages/PreloadPlansView.vue`(列表主体在 `components/LoadingListsView.vue`);`loading-plans` | 请求在 `components/LoadingListsView.vue` → `api/platformLoadingLists.ts`;`firstleg/split/` |
|
||||
| 预装清单详情、生命周期、真实柜号补录 | `pages/PreloadPlanDetailView.vue`;`loading-plans/:planId` | `/loading-plans/{planId}/lifecycle`;`firstleg/service/LoadingListService.java` |
|
||||
| 装柜清单列表 | `pages/ConfirmedLoadingListsView.vue`、`components/LoadingListsView.vue`;`loading-lists` | `api/platformLoadingLists.ts` |
|
||||
| 装柜清单详情、摘要、明细、历史与单据打印 | `pages/ConfirmedLoadingListDetailView.vue`、`components/LoadingListDetailDrawer.vue`;`loading-lists/:planId` | 单据打印走 `printLoadingList.ts`(详情页带 `?print=1` 时触发);列表数据 `api/platformLoadingLists.ts`;模板归 `print/` |
|
||||
| 柜级公共费用创建、审核、分摊与作废 | `components/ContainerFeesPanel.vue` | `api/containerFees.ts`;`firstleg/` 柜费来源、`finance/` |
|
||||
| 散货托盘码打印 | `components/PalletLabelPrintDialog.vue` | `api/platformLoadingLists.ts` 的 `/pallet-labels/prepare`、`/pallet-labels/print`;`print/` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 预装详情进入后先调 `/loading-plans/{planId}/lifecycle`,再按返回状态决定可操作项;不自行推断生命周期阶段。
|
||||
- 托盘码格式为“真实柜号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
|
||||
- 确认预装和创建 PDA 任务前必须补齐真实集装箱号;历史上已建缺号任务可在预装详情首次补录,保存后恢复 PDA 可进入状态。
|
||||
- 柜级公共费用只按服务端柜内客户/订单事实分摊,创建、审核、驳回、调整、作废均整组原子,金额必须守恒;待确认时禁止调整或重复作废。
|
||||
- 独立清单详情与货柜详情页签共用同一套清单 API 与详情组件;改展示或打印时同步检查 `container-loading` 的装柜清单页签。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/loading-lists/<目标测试>`;`container-loading` 页签相关变更同时跑该模块测试。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -420,7 +420,7 @@ onMounted(() => void load())
|
||||
<tr v-if="!target && form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证" class="voucher-field"><div class="voucher-control"><label class="voucher-picker" :class="{ 'is-disabled': voucherUploading }"><FileUp :size="15" /><span>{{ voucherUploading ? '上传并校验中' : '选择凭证' }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="voucherUploading" @change="selectVoucher" /></label><span class="voucher-hint">支持 PDF、JPG、PNG,最多 20 个</span></div><el-alert v-if="voucherError" type="error" :closable="true" show-icon @close="voucherError = null"><template #title>{{ voucherError.message }}</template><ApiErrorMeta :details="voucherError" /></el-alert><div v-if="voucherFiles.length" class="voucher-list"><span v-for="file in voucherFiles" :key="file.fileId"><span :title="file.fileName">{{ file.fileName }}</span><button type="button" :aria-label="`移除${file.fileName}`" @click="removeVoucher(file.fileId)"><X :size="14" /></button></span></div></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入金额" /></el-form-item></td></tr>
|
||||
<tr v-if="target"><td><el-form-item label="备注" label-position="left" label-width="110px"><span>{{ target.remark || '—' }}</span></el-form-item></td><td><el-form-item label="费用归属" label-position="left" label-width="110px"><span>{{ (target.allocationOrderNo || '—') + ' / ' + (target.allocationInboundCode || '无入仓码') }}</span></el-form-item></td></tr>
|
||||
<tr v-if="!target"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
<tr v-if="!target"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
<el-form-item label-position="top" v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
|
||||
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
|
||||
|
||||
@@ -223,10 +223,10 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<template #taxRefundFlag="{ row }">{{ row.taxRefundFlag === true ? '是' : row.taxRefundFlag === false ? '否' : '—' }}</template>
|
||||
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
|
||||
<template #dimensionalWeight="{ row }">{{ formatNumber(row.dimensionalWeightKg) }}</template>
|
||||
<template #customerRemark="{ row }"><div class="remark-cell"><span>{{ row.customerRemark || '—' }}</span><small>来源:客户下单</small></div></template>
|
||||
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
|
||||
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
|
||||
<template #customerServiceRemark="{ row }"><div class="remark-cell"><span>{{ row.customerServiceRemark || '—' }}</span><small>{{ mode === 'preload' ? '由客服维护' : '装柜完成时冻结' }}</small></div></template>
|
||||
<template #customerRemark="{ row }"><div class="remark-cell"><span>{{ row.customerRemark || '—' }}</span></div></template>
|
||||
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span></div></template>
|
||||
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span></div></template>
|
||||
<template #customerServiceRemark="{ row }"><div class="remark-cell"><span>{{ row.customerServiceRemark || '—' }}</span></div></template>
|
||||
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
@@ -185,8 +185,8 @@ describe('LoadingListsView', () => {
|
||||
})
|
||||
|
||||
it('keeps customer remarks read-only while exposing three editable collaboration remarks', () => {
|
||||
expect(detailSource).toContain('来源:客户下单')
|
||||
expect(detailSource).toContain('来源:订单内部备注')
|
||||
expect(detailSource).toContain("row.customerRemark || '—'")
|
||||
expect(detailSource).toContain("row.internalRemark || '—'")
|
||||
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
|
||||
expect(detailSource).toContain("emit('editRemarks', row as LoadingItem)")
|
||||
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
||||
|
||||
@@ -308,14 +308,14 @@ watch([planId, warehouseId], () => {
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog" :title="preloadDialogTitle('编辑备注', remarkTarget?.inboundCode || remarkTarget?.orderNo)" width="min(800px, calc(100vw - 28px))" fullscreenable confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
|
||||
<el-alert type="info" :closable="false" show-icon title="客户备注来自客户下单且不可修改;其余备注可维护" />
|
||||
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<table class="density-table density-table--editable preload-editor-table" aria-label="预装明细备注"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="客户备注(只读)"><span class="customer-remark-text">{{ remarkTarget?.customerRemark || '—' }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="客服备注"><el-input v-model="remarkForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="客服备注"><el-input v-model="remarkForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 主数据模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/master-data/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 子域分目录:`counterparties/`(往来单位)、`express-companies/`(快递公司),各自有页面与 API。
|
||||
- 后端都归 `warehouse/`(`PlatformPartnerController`、`PlatformExpressCompanyController`、`PartnerImportService`);供应商不在此处,见 `../suppliers/`。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 往来单位列表、导入与启停 | `counterparties/pages/PartnersView.vue` | `counterparties/api/platformPartners.ts`;`warehouse/` |
|
||||
| 快递公司列表与维护 | `express-companies/pages/ExpressCompaniesView.vue` | `express-companies/api/platformExpressCompanies.ts`;`warehouse/` |
|
||||
| 导入模板与批量写入 | 往来单位导入入口 | `warehouse/PartnerImportService.java` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 往来单位与快递公司是平台公共主数据(`tenant_id=0`),不绑定客户。
|
||||
- 导入先全量校验再原子写入,逐行失败原因回填;不信任模板隐藏列。
|
||||
- 供应商(含 TRUCKING 国内/海外车队范围)归 `../suppliers/`,不要在本模块扩展供应商类别。
|
||||
- 停用往来单位不影响历史单据的名称快照。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/master-data/<目标测试>`;导入规则变化同时核对 `warehouse/` 的导入合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
@@ -379,8 +380,8 @@ onMounted(() => {
|
||||
<el-form-item label="客服电话"><el-input v-model.trim="form.servicePhone" maxlength="32" placeholder="例如 95338" /></el-form-item>
|
||||
<el-form-item label="官方网站" prop="website"><el-input v-model.trim="form.website" maxlength="255" placeholder="https://www.example.com" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="轨迹查询地址模板"><el-input v-model.trim="form.trackingUrlTemplate" maxlength="500" placeholder="https://example.com/track/{trackingNo}" /></el-form-item>
|
||||
<p class="form-help">可使用 <code>{trackingNo}</code> 作为快递单号占位符;一期不包含轨迹订阅和自动回传。</p>
|
||||
<el-form-item label="轨迹查询地址模板"><template #label><span>轨迹查询地址模板<InlineHelp title="轨迹地址模板说明"><p>可使用 <code>{trackingNo}</code> 作为快递单号占位符。</p></InlineHelp></span></template><el-input v-model.trim="form.trackingUrlTemplate" maxlength="500" placeholder="https://example.com/track/{trackingNo}" /></el-form-item>
|
||||
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="备注"><el-form-item label="内部说明"><el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用范围、服务限制或其他说明" /></el-form-item></FormSection>
|
||||
</el-form>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin 消息中心模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/notifications/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 消息中心只在顶部入口,不进入左侧菜单;模板维护在系统设置下,两者共用同一套通知契约。
|
||||
- 后端只查 `iam/` 的 `notification/` 子域;不读其他服务。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 消息收件箱与已读 | `pages/PlatformNotificationsView.vue`;`notifications` | `api/notificationCenter.ts`;`iam/` notification |
|
||||
| 消息管理(发送与记录) | `pages/MessageManagementView.vue`、`components/MessageComposerDialog.vue`;`notifications/messages` | `api/notificationCenter.ts`;`iam/` notification |
|
||||
| 消息模板维护 | `pages/MessageTemplatesView.vue`、`components/TemplateEditorDialog.vue`;`notifications/templates` | `api/notificationCenter.ts`;`iam/` notification |
|
||||
| 展示文案与类型映射 | `presentation.ts`、`types.ts` | 服务端模板变量 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 消息只展示服务端返回的业务文案与业务编号,不把内部数据库 ID 作为标题或链接参数回退。
|
||||
- 模板变量必须来自服务端模板定义,前端不硬编码业务字段名或补默认值。
|
||||
- 发送类操作需要确认、处理中与失败反馈;失败保留用户输入,不静默清空草稿。
|
||||
- 未读计数与已读状态以服务端为准,不在本地累加或乐观改写后不回读。
|
||||
- 顶部入口不擅自扩展到左侧菜单,也不为单条消息新增独立路由页。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/notifications/<目标测试>`;模板变量或发送字段变更同时核对 `iam` 通知契约。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 订舱模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/ocean-bookings/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,订舱与船期细则见 `../../../docs/business-index.md`。
|
||||
- 订舱(本模块)与预装/装柜清单(`../loading-lists/`)、船节点(`../port-schedules/`)是三条链路;本模块只管订舱资料、放舱状态与订舱级船期。
|
||||
- 后端只查 `firstleg/` 的 `oceanbooking/`、`portschedule/` 与 `print/`(订舱单据)、`file/`(附件)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 订舱列表、放舱状态与关联候选 | `pages/OceanBookingsView.vue`;`ocean-bookings` | `api/platformOceanBookings.ts`;`firstleg/oceanbooking/` |
|
||||
| 新增订舱(登记线下已确认订舱) | `pages/OceanBookingDetailView.vue`;`ocean-bookings/new` | `api/platformOceanBookings.ts` |
|
||||
| 订舱详情、资料维护与版本 | `pages/OceanBookingDetailView.vue`;`ocean-bookings/:bookingId` | `api/platformOceanBookings.ts` |
|
||||
| 订舱资料面板与字段边界 | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
|
||||
| 订舱资料打印 | `components/OceanBookingPrintDialog.vue` | `print/` 单据模板 |
|
||||
| 柜型需求与数量换算 | `containerRequirements.ts` | `containerCount` 为柜型需求 `quantity` 总和 |
|
||||
| 订舱代理候选 | `bookingAgents.ts` | 公共供应商列表,仅 `ACTIVE` 且类别/范围匹配 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 订舱草稿按当前版本维护资料;放舱后资料与表单只读,不允许绕过版本直接改字段。
|
||||
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。
|
||||
- 关联候选只消费服务端 `/link-candidates` 分页结果,不在浏览器重排或本地拼接候选。
|
||||
- 订舱是拖车预约的唯一归属:拖车由 `../trucking/` 创建,本模块不直接建拖车单。
|
||||
- 出仓前船期只展示订舱级计划,实际节点归 `../port-schedules/` 与海外回写,本模块不写实际时间。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/ocean-bookings/<目标测试>`;资料字段或打印变更同时核对 `print/` 模板与 `file/` 附件契约。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
|
||||
import { FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadOceanBookingDocument, invalidateOceanBookingDocument, uploadOceanBookingDocument } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDocument, OceanBookingDocumentType, OceanBookingStatus } from '../types'
|
||||
@@ -109,13 +110,13 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
|
||||
<template>
|
||||
<section class="surface-panel booking-documents admin-density" aria-label="订舱资料">
|
||||
<div class="booking-documents__heading"><div><h2>订舱资料</h2><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></div><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
|
||||
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
|
||||
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
|
||||
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
|
||||
<div class="booking-document-types">
|
||||
<article v-for="type in types" :key="type.value" class="booking-document-card">
|
||||
<div class="booking-document-card__heading"><div><h3>{{ type.label }}</h3><p>{{ type.description }}</p><small class="booking-document-limit">任意格式,单文件不超过 50MB</small></div><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
|
||||
<el-empty v-if="!documentsOfType(type.value).length" description="暂无有效文件" :image-size="48" />
|
||||
<div class="booking-document-card__heading"><h3>{{ type.label }}<InlineHelp :title="`${type.label}说明`"><p>{{ type.description }}</p><p>任意格式,单文件不超过 50MB</p></InlineHelp></h3><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
|
||||
<p v-if="!documentsOfType(type.value).length" class="booking-document-empty" role="status">暂无有效文件</p>
|
||||
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || '—' }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions density-actions"><el-button class="density-action" size="small" plain :loading="busyDocumentId === item.id" @click="download(item)">下载</el-button><el-button data-tone="danger" class="density-action" v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)">作废</el-button></div></li></ul>
|
||||
</article>
|
||||
</div>
|
||||
@@ -124,6 +125,8 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-documents{padding:var(--admin-space-5)}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:var(--admin-space-5)}.booking-documents__heading{margin-bottom:var(--admin-space-5)}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:var(--admin-font-section)}.booking-documents__heading p,.booking-document-card__heading p{margin:var(--admin-space-1) 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-limit{display:block;margin-top:5px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-types{display:grid;grid-template-columns:1fr;gap:0}.booking-document-card{min-width:0;border:0;border-top:1px solid var(--cc-border);border-radius:0;padding:var(--admin-space-3) 0}.booking-document-card h3{font-size:var(--admin-font-section)}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:var(--admin-space-2);min-height:var(--admin-control-height);padding:0 var(--admin-space-5);border:1px solid var(--cc-primary);border-radius:var(--admin-radius);color:var(--cc-primary);font-size:var(--admin-font-caption);cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:var(--admin-space-3);margin:var(--admin-space-5) 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;border-radius:0;background:transparent;border-bottom:1px solid var(--cc-border);padding:var(--admin-space-2) 0}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{white-space:normal;overflow-wrap:anywhere}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-actions{flex:0 0 auto}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:var(--admin-space-5);padding:7px var(--admin-space-4);border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr;gap:0}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
|
||||
.booking-documents{padding:var(--admin-space-5)}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:var(--admin-space-5)}.booking-documents__heading{margin-bottom:var(--admin-space-5)}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:var(--admin-font-section)}.booking-document-types{display:grid;grid-template-columns:1fr;gap:0}.booking-document-card{min-width:0;border:0;border-top:1px solid var(--cc-border);border-radius:0;padding:var(--admin-space-3) 0}.booking-document-card h3{font-size:var(--admin-font-section)}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:var(--admin-space-2);min-height:var(--admin-control-height);padding:0 var(--admin-space-5);border:1px solid var(--cc-primary);border-radius:var(--admin-radius);color:var(--cc-primary);font-size:var(--admin-font-caption);cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:var(--admin-space-3);margin:var(--admin-space-5) 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;border-radius:0;background:transparent;border-bottom:1px solid var(--cc-border);padding:var(--admin-space-2) 0}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{white-space:normal;overflow-wrap:anywhere}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-actions{flex:0 0 auto}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:var(--admin-space-5);padding:7px var(--admin-space-4);border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr;gap:0}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
|
||||
.booking-document-history li span{min-width:0;overflow-wrap:anywhere}
|
||||
.booking-documents__heading h2,.booking-document-card__heading h3{display:flex;align-items:center;gap:var(--admin-space-1)}
|
||||
.booking-document-empty{margin:var(--admin-space-3) 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}
|
||||
</style>
|
||||
|
||||
@@ -377,7 +377,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '先保存订舱草稿,资料完整后再确认放舱'">
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : ''">
|
||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
@@ -387,7 +387,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<template v-else>
|
||||
<section class="surface-panel">
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2><p v-if="detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail">已放舱资料只读。</p></div>
|
||||
<h2>订舱信息</h2>
|
||||
<div class="actions">
|
||||
<el-tooltip :content="canPrint ? '按当前已保存资料打印出口货物托运单或提单确认件' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
@@ -446,7 +446,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="formWritable" class="container-actions"><el-button class="add-container density-action" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement">新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
|
||||
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Admin 国内出仓模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/outbound-dispatch/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/OutboundDispatchesView.vue`、`api/platformOutboundDispatch.ts`、`statusLabels.ts`:维护国内出仓状态(确认出仓 / 撤销出仓)。
|
||||
- 后端归 `firstleg/outbound/dispatch/`(`OutboundDispatchService.java`)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 国内出仓列表、状态与筛选 | `pages/OutboundDispatchesView.vue` | `api/platformOutboundDispatch.ts`;`firstleg/outbound/dispatch/` |
|
||||
| 确认国内出仓 | 行操作 `confirm-dispatch` | `changeOutboundDispatch`,提交 `inboundId`、`containerBookingId`、`expectedVersion`、`remark` |
|
||||
| 撤销国内出仓 | 行操作 `reverse-dispatch` | 同上,需二次确认 |
|
||||
| 状态中文文案 | `statusLabels.ts` | 服务端状态机 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 确认出仓前系统校验全部装柜门禁;前端不得逐入仓确认,也不得把拖车状态当阻断门禁。
|
||||
- 提交必须携带当前 `version`,冲突时重新读取,不覆盖他人操作。
|
||||
- 出仓以真实货柜为原子边界;成功后由服务端同事务推进技术行并幂等创建船节点查询,前端不重复追加实际船节点。
|
||||
- 与货柜详情里的“确认离仓”是同一能力的两个入口,改规则时同步检查 `../container-loading/`。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/outbound-dispatch/<目标测试>`;状态或门禁变化同时核对 `firstleg/` 出仓合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,34 @@
|
||||
# Admin 海外业务模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/overseas-business/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,采购与本土业务细则见 `../../../docs/business-index.md`。
|
||||
- 三条业务线共用同一模块:`purchases`(海外采购)、`trucks`(本土卡车)、`looseboards`(本土散板);`trucks` 与 `looseboards` 复用同一组页面,按路由区分模式。
|
||||
- 后端只查 `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/`、`financefee/`;纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 海外采购订单列表与库存变化 | `pages/PurchaseOrdersView.vue`;`overseas-business/purchases` | `api/platformOverseasBusiness.ts`;`lastleg-service/procurement/` |
|
||||
| 采购订单详情(商品、库存、订单、快递、进销存) | `pages/PurchaseOrderDetailView.vue`;`overseas-business/purchases/:purchaseId` | `api/platformOverseasBusiness.ts` |
|
||||
| 采购订单编辑(采购商品、库存、快递) | `pages/PurchaseOrderEditView.vue`;`overseas-business/purchases/:purchaseId/edit` | `api/platformOverseasBusiness.ts`(`getOverseasPurchaseEditor`、`updateOverseasPurchase`) |
|
||||
| 采购订单分区字段:管控、财务、节点、实物、运输 | `components/ProcurementControlFields.vue`、`ProcurementFinanceFields.vue`、`ProcurementNodeActions.vue`、`ProcurementPhysicalFields.vue`、`ProcurementTransportFields.vue` | `api/procurementCommands.ts`;节点动作与费用入池归 `lastleg-service/financefee/` |
|
||||
| 采购订单分区字段:费用(纯展示,无请求) | `components/ProcurementChargeFields.vue` | 只接收 props,数据由详情页 `PurchaseOrderDetailView.vue` 提供 |
|
||||
| 本土卡车订单列表 | `pages/TransportOrdersView.vue`;`overseas-business/trucks` | `api/platformOverseasBusiness.ts`;`lastleg-service/trucking/` |
|
||||
| 本土卡车新增/编辑 | `pages/TransportOrderFormView.vue`;`overseas-business/trucks/new`、`overseas-business/trucks/:orderId/edit` | `api/platformOverseasBusiness.ts`;`components/TransportDemandForm.vue` |
|
||||
| 本土散板订单列表与新增/编辑 | `pages/TransportOrdersView.vue`、`pages/TransportOrderFormView.vue`;`overseas-business/looseboards`、`.../new`、`.../:orderId/edit` | `api/platformOverseasBusiness.ts`;`lastleg-service/looseboard/` |
|
||||
| 车次安排摘要 | `components/FleetArrangementSummary.vue` | 无直接请求;数据由 `fleetArrangement.ts` 与父页面传入 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 采购样品履约是一条独立链路:下单、包装、面单、交寄、国内核费、寄客户;改节点或费用前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`,不套用普通头程或尾程流程。
|
||||
- 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。
|
||||
- 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。
|
||||
- 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。
|
||||
- 同一路由组件的两种模式(卡车/散板)变更时,两侧都要回归,避免只改一侧。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/overseas-business/<目标测试>`;采购命令或费用变更同时核对 `lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -6,14 +6,16 @@
|
||||
|
||||
- 海外采购:客户候选、采购仓候选、列表、详情操作流水及 `PATCH` 三态编辑;缺失字段不修改,显式 `null` 清空。
|
||||
- 采购审核 `review`:采购详情页只对待审核订单和独立审核权限开放,提交订单版本、通过/拒绝决定、稳定幂等键和可选备注。
|
||||
- 本土卡车:平台列表、详情、创建和仅待审核状态允许的普通编辑。
|
||||
- 本土卡车:平台列表、独立详情页、创建和仅待审核状态允许的普通编辑;详情页使用 `truck:status` 权限接入订单审核、累计实收、超托审核、托盘分配、待派车、已派车、发运、完成及取消。全部命令提交当前版本和稳定幂等键,结果不明时保留原请求供重试;失败保留表单与错误关联信息。
|
||||
- 本土卡车拼车来源订单保持承运安排只读,状态随既有来货、排车和签收事实推进;不展示独立单审核或状态命令。
|
||||
- 本土散板:独立稳定子域的列表、详情数据承载、创建和仅待接单状态允许的编辑;详情只用于既有编辑页预填,不新增详情路由。
|
||||
- canonical 权限统一为 `platform:overseas-business:*`;长整型 ID 与 decimal 字段在适配层归一化。
|
||||
|
||||
明确不接入:
|
||||
|
||||
- 采购出库不提供独立 `/outbounds` 端点;采购编辑通过出库明细维护出库量并派生待出库、部分出库、全部出库状态,不直接操作海外仓 PDA 可用库存。
|
||||
- 卡车状态推进、收托盘和散板状态推进:后端存在,但当前原型没有对应操作承载方式。
|
||||
- 散板独立单状态推进:后端存在,但当前原型没有对应操作承载方式。
|
||||
- 独立卡车履约记录使用现有卡车接口,不自动生成 WMS 库存、海外仓排车单、出库单或派送单;派车、发运和完成按实际发生情况由平台确认。
|
||||
- 海外出库、海外派送和 Merchant API。
|
||||
|
||||
剩余验收项仅为真实 Gateway/Token 环境 HTTP 验收及 `1920×1080`、浏览器缩放 `100%` 的人工页面操作和截图确认。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
|
||||
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 |
|
||||
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_REVIEW` 可真实提交 |
|
||||
| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
|
||||
| 本土卡车 | 详情、审核与后续履约 | 原型未定义;2026-10-20 用户明确要求补齐流程并加审核 | 独立详情页 + 居中操作确认弹框 | 列表待审核单显示审核入口,其他状态可查看详情;独立单使用既有具名命令,按状态与 truck:status 权限展示审核、实收、超托审核、分配、待派车、派车、发运、完成与取消。展示资料、托盘和审核操作历史;拼车来源沿承运安排只读。待人工 1920×1080、100% 截图确认 |
|
||||
| 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
|
||||
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;使用独立稳定子域真实提交并显式选择客户 |
|
||||
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_ACCEPTANCE` 可真实提交 |
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder,
|
||||
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
|
||||
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
|
||||
updateUsTruckOrder,
|
||||
updateUsTruckOrder, executeUsTruckCommand,
|
||||
} from './platformOverseasBusiness'
|
||||
import type { UpdateLocalTransportOrderRequest } from '../types'
|
||||
import { readPendingProcurement } from '../procurementPending'
|
||||
import type { TruckOperation } from '../truckWorkflow'
|
||||
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
||||
const purchaseSummary = {
|
||||
@@ -92,7 +93,7 @@ describe('platform overseas business API adapter', () => {
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { version: 4, remark: '内部' }, { params: { customerId: '10001' } })
|
||||
})
|
||||
|
||||
it('supports platform truck list/detail/create/update without status endpoints', async () => {
|
||||
it('supports platform truck list/detail/create/update', async () => {
|
||||
const detail = { summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }
|
||||
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')
|
||||
@@ -106,6 +107,32 @@ describe('platform overseas business API adapter', () => {
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', update, { params: { customerId: '9007199254741999' } })
|
||||
})
|
||||
|
||||
it.each<TruckOperation>([
|
||||
{ command: 'review', payload: { version: 2, idempotencyKey: 'review-key', decision: 'APPROVE' } },
|
||||
{ command: 'review', payload: { version: 2, idempotencyKey: 'reject-key', decision: 'REJECT', remark: '资料不全' } },
|
||||
{ command: 'receive-pallets', payload: { version: 2, idempotencyKey: 'receive-key', actualPalletCount: 3, packageCount: 10 } },
|
||||
{ command: 'review-pallet-overage', payload: { version: 2, idempotencyKey: 'overage-key', decision: 'APPROVE' } },
|
||||
{ command: 'allocate-pallets', payload: { version: 2, idempotencyKey: 'allocate-key', palletIds: ['9007199254741001'] } },
|
||||
...(['prepare-dispatch', 'dispatch', 'start-transit', 'complete'] as const).map(command => ({ command, payload: { version: 2, idempotencyKey: command } })),
|
||||
{ command: 'cancel', payload: { version: 2, idempotencyKey: 'cancel-key', status: 'CANCELLED', remark: '客户取消' } },
|
||||
])('sends the scoped named truck command $command with version and unchanged replay key', async operation => {
|
||||
const response = envelope({ summary: { ...truckSummary, version: '3', palletCount: '2', packageCount: '10', receivedPalletCount: '1', allocatedPalletCount: '0' }, pallets: [{ id: '9007199254741001', warehousePalletId: null }], histories: [] })
|
||||
overseas.post.mockResolvedValue(response); overseas.patch.mockResolvedValue(response)
|
||||
const result = await executeUsTruckCommand('TR/1', '9007199254741999', operation)
|
||||
const mutation = operation.command === 'cancel' ? overseas.patch : overseas.post
|
||||
expect(mutation).toHaveBeenCalledWith(`/platform/v1/last-leg/us-truck-orders/TR%2F1/${operation.command === 'cancel' ? 'status' : operation.command}`, operation.payload, { params: { customerId: '9007199254741999' } })
|
||||
expect(result).toMatchObject({ version: 3, palletCount: 2, receivedPalletCount: 1, allocatedPalletCount: 0 })
|
||||
expect(result.pallets[0].id).toBe('9007199254741001')
|
||||
expect(operation.command === 'cancel' ? overseas.post : overseas.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates a truck command version conflict without fallback status writes', async () => {
|
||||
overseas.post.mockResolvedValue({ code: 'A0409', message: '版本冲突', data: null, requestId: 'TRUCK-409' })
|
||||
await expect(executeUsTruckCommand('TR-1', '10002', { command: 'complete', payload: { version: 2, idempotencyKey: 'original' } })).rejects.toMatchObject({ code: 'A0409', requestId: 'TRUCK-409' })
|
||||
expect(overseas.post).toHaveBeenCalledTimes(1)
|
||||
expect(overseas.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports looseboard list/detail/create and PUT update', async () => {
|
||||
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import type { TruckOperation } from '../truckWorkflow'
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
import type {
|
||||
@@ -32,15 +33,15 @@ interface LooseboardDetailPayload {
|
||||
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
|
||||
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
|
||||
}
|
||||
function procurementInteger(value: unknown): number {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('采购数据中的数量或版本无效,请刷新后重试')
|
||||
function procurementInteger(value: unknown, business = '采购'): number {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`${business}数据中的数量或版本无效,请刷新后重试`)
|
||||
return Number(value)
|
||||
}
|
||||
function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary {
|
||||
return { ...value, version: procurementInteger(value.version), purchaseQuantity: procurementInteger(value.purchaseQuantity), id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
|
||||
}
|
||||
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
return { ...value, id: String(value.id), customerId: String(value.customerId), version: procurementInteger(value.version, '卡车'), palletCount: procurementInteger(value.palletCount, '卡车'), packageCount: procurementInteger(value.packageCount, '卡车'), receivedPalletCount: procurementInteger(value.receivedPalletCount, '卡车'), allocatedPalletCount: procurementInteger(value.allocatedPalletCount, '卡车'), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
}
|
||||
function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
|
||||
return { ...truckSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, pallets: (value.pallets || []).map((item) => ({ ...item, id: String(item.id), warehousePalletId: item.warehousePalletId == null ? null : String(item.warehousePalletId) })), histories: value.histories || [] }
|
||||
@@ -130,6 +131,14 @@ export async function updateUsTruckOrder(orderNo: string, customerId: string, pa
|
||||
const response = await http.patch<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return truckDetail(unwrapApiResponse(response))
|
||||
}
|
||||
/** 正向履约使用具名命令,通用状态接口仅用于取消。 */
|
||||
export async function executeUsTruckCommand(orderNo: string, customerId: string, operation: TruckOperation) {
|
||||
const path = `${truckBasePath}/${encodeURIComponent(orderNo)}/${operation.command === 'cancel' ? 'status' : operation.command}`
|
||||
const response = operation.command === 'cancel'
|
||||
? await http.patch<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(path, operation.payload, { params: { customerId } })
|
||||
: await http.post<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(path, operation.payload, { params: { customerId } })
|
||||
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 } })
|
||||
return normalizePage(unwrapApiResponse(response), looseboardSummary)
|
||||
|
||||
@@ -42,7 +42,7 @@ function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = ch
|
||||
</template>
|
||||
<template v-else-if="command === 'fees/additional'">
|
||||
<el-alert title="登记独立新发生的正额费用,使用该业务日期的汇率。与原费用金额更正分开处理;已完成订单保留原履约状态。" type="info" :closable="false" />
|
||||
<el-form-item label="独立费用名称" required><el-input v-model="form.additional.name" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item>
|
||||
<el-form-item label="独立费用名称" required><el-input v-model="form.additional.name" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="新增费用原币金额" required><el-input v-model="form.additional.amount" placeholder="填写正额金额,最多两位小数" /></el-form-item>
|
||||
<el-form-item label="新增费用币种" required><el-select v-model="form.additional.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item>
|
||||
<el-form-item label="新增费用业务日期" required><el-date-picker v-model="form.additional.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
|
||||
@@ -232,7 +232,7 @@ async function upload(event: Event) {
|
||||
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" placeholder="填写本次操作的事实、依据或原因" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
|
||||
<template #footer><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></template>
|
||||
</AppDialog>
|
||||
|
||||
@@ -25,7 +25,7 @@ const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货'
|
||||
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
|
||||
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
|
||||
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次合格件数" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次破损件数" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次多到件数" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
|
||||
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
|
||||
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
|
||||
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
|
||||
<template v-if="command === 'packing'">
|
||||
|
||||
@@ -20,8 +20,8 @@ function selectCarton(id: string) { const box = props.detail.transport.domesticC
|
||||
<template v-if="command === 'labels'">
|
||||
<el-form-item label="面单运输段" required><el-radio-group v-model="form.leg" @change="changeLeg"><el-radio value="OVERSEAS" :disabled="!!detail.purchaseFacts.overseas_shipped_at">国际段</el-radio><el-radio value="DOMESTIC" :disabled="!detail.purchaseFacts.domestic_received_at">寄客户段</el-radio></el-radio-group></el-form-item>
|
||||
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : '由实际海外收货仓主档提供,保存时核对。' }}</p>
|
||||
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" placeholder="填写快递收件人" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" placeholder="填写真实快递联系电话" /></el-form-item></div>
|
||||
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" placeholder="填写完整快递收件地址" /></el-form-item>
|
||||
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" /></el-form-item></div>
|
||||
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" /></el-form-item>
|
||||
<el-form-item v-if="changedReceiver" label="客户确认收件信息变更凭证" required><el-select v-model="form.addressVoucherFileId" placeholder="选择本单已上传的客户确认凭证"><el-option v-for="file in files.filter(item => item.category === 'EXCEPTION')" :key="file.fileId" :label="file.fileName" :value="file.fileId" /></el-select></el-form-item>
|
||||
<section v-for="row in form.labels" :key="row.cartonId" class="label-row">
|
||||
<strong>{{ cargo.find(box => box.cartonId === row.cartonId)?.cartonNo || '未关联箱' }}</strong>
|
||||
|
||||
@@ -31,36 +31,36 @@ defineExpose({
|
||||
</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>
|
||||
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></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>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></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" /></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" /></el-form-item>
|
||||
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></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" /></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-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" />
|
||||
<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>
|
||||
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="transport-demand-actions">
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { executeUsTruckCommand } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import { availableTruckCommands, truckCommandHints, truckCommandLabels, type TruckCommand, type TruckOperation } from '../truckWorkflow'
|
||||
import type { LocalTransportDetail } from '../types'
|
||||
|
||||
const props = defineProps<{ detail: LocalTransportDetail }>()
|
||||
const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const commands = computed(() => access.can(overseasBusinessPermissions.truckStatus) ? availableTruckCommands(props.detail) : [])
|
||||
const open = ref(false), saving = ref(false)
|
||||
const active = ref<TruckCommand | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
|
||||
// 响应丢失时保留原命令、版本和幂等键,重试不得变更请求内容。
|
||||
const pending = ref<TruckOperation | null>(null)
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
|
||||
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
|
||||
const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) })
|
||||
watch(saving, value => emit('busy', value))
|
||||
|
||||
watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version], () => {
|
||||
pending.value = null; open.value = false; active.value = null; error.value = null
|
||||
})
|
||||
|
||||
function start(command: TruckCommand) {
|
||||
if (saving.value || !commands.value.includes(command)) return
|
||||
if (pending.value) {
|
||||
active.value = pending.value.command; open.value = true
|
||||
return
|
||||
}
|
||||
active.value = command; error.value = null; validationError.value = ''
|
||||
Object.assign(form, { decision: 'APPROVE', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' })
|
||||
open.value = true; captureBaseline()
|
||||
}
|
||||
|
||||
function buildOperation(): TruckOperation | null {
|
||||
if (!active.value) return null
|
||||
const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined }
|
||||
if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) {
|
||||
validationError.value = '请填写拒绝或取消原因'; return null
|
||||
}
|
||||
switch (active.value) {
|
||||
case 'review': case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } }
|
||||
case 'receive-pallets':
|
||||
if (!Number.isSafeInteger(form.actualPalletCount) || form.actualPalletCount < Math.max(1, props.detail.receivedPalletCount)) {
|
||||
validationError.value = '累计实收托数须为正整数,且不能少于已实收托数'; return null
|
||||
}
|
||||
if (form.packageCount != null && (!Number.isSafeInteger(form.packageCount) || form.packageCount < 0)) {
|
||||
validationError.value = '实收件数须为非负整数'; return null
|
||||
}
|
||||
return { command: active.value, payload: { ...base, actualPalletCount: form.actualPalletCount, ...(form.packageCount == null ? {} : { packageCount: form.packageCount }) } }
|
||||
case 'allocate-pallets':
|
||||
if (!form.palletIds.length || form.palletIds.length > 500 || form.palletIds.some(id => !selectablePallets.value.some(pallet => pallet.id === id))) {
|
||||
validationError.value = '请选择本单已实收且未分配的托盘,每次最多分配 500 托'; return null
|
||||
}
|
||||
return { command: active.value, payload: { ...base, palletIds: [...form.palletIds] } }
|
||||
case 'cancel': return { command: active.value, payload: { ...base, status: 'CANCELLED' } }
|
||||
default: return { command: active.value, payload: base }
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || !active.value || !commands.value.includes(active.value)) return
|
||||
validationError.value = ''; error.value = null
|
||||
const operation = pending.value ?? buildOperation()
|
||||
if (!operation) return
|
||||
pending.value = operation; saving.value = true
|
||||
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
|
||||
try {
|
||||
const updated = await executeUsTruckCommand(orderNo, customerId, operation)
|
||||
if (props.detail.customerId !== customerId || props.detail.orderNo !== orderNo) return
|
||||
pending.value = null; open.value = false; captureBaseline()
|
||||
ElMessage.success(`${truckCommandLabels[operation.command]}成功`)
|
||||
emit('updated', updated)
|
||||
} catch (cause) {
|
||||
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="commands.length" class="truck-order-actions">
|
||||
<el-button v-for="command in commands" :key="command" :type="command === 'cancel' ? 'danger' : 'primary'" :plain="command === 'cancel'" :disabled="saving" @click="start(command)">{{ truckCommandLabels[command] }}</el-button>
|
||||
<p v-if="pending && !open" role="status">上次操作尚未确认结果,请重新打开重试,或刷新订单核对最新记录。</p>
|
||||
</div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="pending ? '重试原操作' : '确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
|
||||
<template v-if="active">
|
||||
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
|
||||
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可重试原操作;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
|
||||
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="active === 'receive-pallets'">
|
||||
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
|
||||
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item v-if="active === 'allocate-pallets'" label="分配托盘" required>
|
||||
<el-checkbox-group v-model="form.palletIds"><el-checkbox v-for="pallet in selectablePallets" :key="pallet.id" :value="pallet.id">{{ pallet.palletNo }}({{ pallet.packageCount ?? '未登记' }} 件)</el-checkbox></el-checkbox-group>
|
||||
<p v-if="!selectablePallets.length">暂无可分配托盘,请刷新核对实收记录。</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="review ? '审核意见' : '操作说明'" :required="active === 'cancel' || (review && form.decision === 'REJECT')"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-order-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.truck-order-actions p { flex-basis: 100%; margin: 0; color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -295,6 +295,14 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
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('opens the requested truck review from the list using its customer scope', async () => {
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status')
|
||||
await wrapper.vm.$nextTick()
|
||||
const review = wrapper.findAll('button').find(button => button.text() === '审核')
|
||||
expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } })
|
||||
})
|
||||
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('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() })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -163,10 +164,10 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
<section><h3>差异处置记录</h3><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></section>
|
||||
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></section>
|
||||
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></section>
|
||||
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版</h3><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></section>
|
||||
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版<InlineHelp title="面单核对说明"><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p></InlineHelp></h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></section>
|
||||
<section><h3>两段实际交寄</h3><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></section>
|
||||
<section><h3>费用与资金结果</h3><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></section>
|
||||
<section><h3>费用更正记录</h3><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></section>
|
||||
<section><h3>费用与资金结果<InlineHelp title="费用与资金说明"><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p></InlineHelp></h3><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></section>
|
||||
<section><h3>费用更正记录<InlineHelp title="费用更正说明"><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p></InlineHelp></h3><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></section>
|
||||
<section><h3>事实说明与阻断</h3><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></section>
|
||||
<section><h3>剩余货物去向核对</h3><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></section>
|
||||
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></section>
|
||||
|
||||
@@ -74,7 +74,7 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
<el-form-item label="订单编号"><el-input :model-value="form.orderNo || '—'" readonly /></el-form-item>
|
||||
<el-form-item label="商品名称"><el-input :model-value="form.productName || '—'" readonly /></el-form-item>
|
||||
<el-form-item label="采购数量"><el-input :model-value="loaded ? form.purchaseQuantity : '—'" readonly /></el-form-item>
|
||||
<el-form-item class="span-3" label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="仅供平台内部使用" /></el-form-item>
|
||||
<el-form-item class="span-3" label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</div></FormSection>
|
||||
</el-form>
|
||||
<footer class="form-actions"><el-button type="primary" :disabled="!canSave" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="back">取消</el-button></footer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { inquiryReturnTarget, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -11,6 +11,8 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { truckStatusLabels } from '../truckWorkflow'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import { poolOrderStatusLabels, type FleetArrangement } from '../fleetArrangement'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -37,7 +39,6 @@ const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
|
||||
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
|
||||
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
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 fields = computed<QueryField[]>(() => [
|
||||
@@ -46,7 +47,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
|
||||
])
|
||||
const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'actions', label: '操作', width: 220, 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: '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 },
|
||||
@@ -97,6 +98,20 @@ function search(model: QueryModel) { applied.value = { ...model }; if (page.valu
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
function truckActions(row: LocalTransportOrder): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 },
|
||||
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
|
||||
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
|
||||
]
|
||||
}
|
||||
function handleTruckAction(key: string, row: LocalTransportOrder) {
|
||||
if (key === 'edit') openEdit(row)
|
||||
else if (key === 'arrangement') showArrangement(row)
|
||||
else if (key === 'detail') void router.push({ name: 'overseas-business-truck-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } })
|
||||
}
|
||||
let activatedOnce = false
|
||||
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route.query.orderNo], () => { page.value = 1; applied.value = { orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
@@ -111,7 +126,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #actions="{ row }"><RowActions v-if="!isLooseboard" :actions="truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder)" /><div v-else class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
|
||||
</DataTable></section>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import TruckOrderActions from '../components/TruckOrderActions.vue'
|
||||
import { executeUsTruckCommand, getUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions as permissions } from '../permissions'
|
||||
import type { LocalTransportDetail } from '../types'
|
||||
import TruckOrderDetailView from './TruckOrderDetailView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'TR-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ getUsTruckOrder: vi.fn(), executeUsTruckCommand: vi.fn() }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
|
||||
function order(overrides: Partial<LocalTransportDetail> = {}): LocalTransportDetail {
|
||||
return {
|
||||
id: '9007199254741001', orderNo: 'TR-TEST', customerId: '9007199254741999', fleetArrangement: null,
|
||||
vehicleType: 'PALLET_15', origin: 'LA', originAddress: '起运仓地址', destination: 'ONT', destinationAddress: '目的仓地址',
|
||||
originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
|
||||
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00',
|
||||
palletCount: 0, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, packageCount: 20,
|
||||
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
|
||||
status: 'PENDING_REVIEW', version: 1, remark: '运输备注', createdBy: '7', createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00',
|
||||
pallets: [], histories: [], ...overrides,
|
||||
}
|
||||
}
|
||||
function mountDetail(manage = true) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
|
||||
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true } } })
|
||||
}
|
||||
async function click(wrapper: VueWrapper, label: string) {
|
||||
const button = wrapper.findAll('button').find(item => item.text() === label)
|
||||
expect(button, `button ${label}`).toBeDefined()
|
||||
await button!.trigger('click'); await flushPromises()
|
||||
}
|
||||
async function confirm(wrapper: VueWrapper) {
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
}
|
||||
const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model')
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
routing.route.params = { orderId: 'TR-TEST' }; routing.route.query = { customerId: '9007199254741999' }
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order())
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('truck review and fulfillment', () => {
|
||||
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
|
||||
let current = order()
|
||||
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
|
||||
const next = { ...current, version: current.version + 1 }
|
||||
switch (operation.command) {
|
||||
case 'review': next.status = 'WAITING_INBOUND'; break
|
||||
case 'receive-pallets':
|
||||
next.status = 'WAITING_CONSOLIDATION'; next.palletCount = 2; next.receivedPalletCount = 2
|
||||
next.pallets = [1, 2].map(sequenceNo => ({ id: `900719925474100${sequenceNo}`, sequenceNo, palletNo: `P-${sequenceNo}`, source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: '2026-10-20T10:00:00' }))
|
||||
break
|
||||
case 'allocate-pallets': next.status = 'ALLOCATED'; next.allocatedPalletCount = 2; next.pallets = next.pallets.map(row => ({ ...row, status: 'ALLOCATED' })); break
|
||||
case 'prepare-dispatch': next.status = 'PENDING_DISPATCH'; break
|
||||
case 'dispatch': next.status = 'DISPATCHED'; break
|
||||
case 'start-transit': next.status = 'IN_TRANSIT'; break
|
||||
case 'complete': next.status = 'COMPLETED'; break
|
||||
}
|
||||
next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }]
|
||||
current = next; return next
|
||||
})
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999')
|
||||
expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录')
|
||||
await click(wrapper, '审核'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('登记实收'); expect(wrapper.text()).toContain('资料核对通过')
|
||||
await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper)
|
||||
await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper)
|
||||
for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) }
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
expect(current.status).toBe('COMPLETED')
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7])
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } })
|
||||
})
|
||||
|
||||
it('requires a rejection reason and displays the resulting rejected state', async () => {
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
|
||||
formModel(wrapper).decision = 'REJECT'; await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因')
|
||||
formModel(wrapper).remark = '取送时间需重新确认'
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
|
||||
await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
|
||||
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
|
||||
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('订单版本冲突')
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
|
||||
wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await click(wrapper, '审核'); await confirm(wrapper)
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
|
||||
expect(wrapper.text()).toContain('登记实收')
|
||||
})
|
||||
|
||||
it('locks duplicate submission until the original response arrives', async () => {
|
||||
let finish!: (value: LocalTransportDetail) => void
|
||||
vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('登记实收')
|
||||
})
|
||||
|
||||
it('validates cumulative receipt totals without submitting invalid input', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'RECEIVING', palletCount: 3, receivedPalletCount: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '登记实收')
|
||||
formModel(wrapper).actualPalletCount = 1; await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('不能少于已实收托数')
|
||||
Object.assign(formModel(wrapper), { actualPalletCount: 3, packageCount: -1 }); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires received pallets and permits partial allocation using their string IDs', async () => {
|
||||
const pallet = { id: '9007199254741007', sequenceNo: 1, palletNo: 'P-1', source: 'PDA_RECEIPT', status: 'RECEIVED', warehousePalletId: null, packageCount: 10, receivedAt: null }
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', palletCount: 2, receivedPalletCount: 2, pallets: [pallet] }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '分配托盘'); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
formModel(wrapper).palletIds = [pallet.id]
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'PARTIALLY_ALLOCATED', version: 2, allocatedPalletCount: 1, pallets: [{ ...pallet, status: 'ALLOCATED' }] }))
|
||||
await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('部分分配'); expect(wrapper.text()).not.toContain('确认待派车')
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'allocate-pallets', payload: expect.objectContaining({ palletIds: [pallet.id] }) }))
|
||||
})
|
||||
|
||||
it.each(['APPROVE', 'REJECT'] as const)('reviews pallet overage with %s', async decision => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PALLET_OVERAGE_REVIEW', palletCount: 2, receivedPalletCount: 3 }))
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'WAITING_CONSOLIDATION', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '超托审核')
|
||||
Object.assign(formModel(wrapper), { decision, remark: '已核对超收货物' }); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review-pallet-overage', payload: expect.objectContaining({ decision }) }))
|
||||
})
|
||||
|
||||
it('requires cancellation reason and submits only the cancellation status', async () => {
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '取消订单'); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
formModel(wrapper).remark = '客户取消需求'; vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'CANCELLED', version: 2 })); await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'cancel', payload: expect.objectContaining({ status: 'CANCELLED', remark: '客户取消需求' }) }))
|
||||
})
|
||||
|
||||
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
|
||||
const wrapper = mountDetail(false); await flushPromises()
|
||||
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps pool sourced orders on the existing fleet fulfillment path', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ fleetArrangement: {
|
||||
sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-TEST', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST',
|
||||
plannedDepartureAt: null, originWarehouseName: 'LA', destinationLabel: 'ONT', 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: '按拼车承运实际履约推进',
|
||||
} }))
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('按拼车承运实际履约推进')
|
||||
expect(wrapper.findComponent(TruckOrderActions).exists()).toBe(false)
|
||||
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a scoped error without requesting detail when customer is missing', async () => {
|
||||
routing.route.query = {}; const wrapper = mountDetail(); await flushPromises()
|
||||
expect(getUsTruckOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少有效客户或订单信息')
|
||||
})
|
||||
|
||||
it('retains error metadata and retries a failed detail read', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'DETAIL-409')).mockResolvedValueOnce(order())
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('读取失败'); expect(wrapper.text()).toContain('DETAIL-409')
|
||||
await wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('TR-TEST'); expect(wrapper.text()).toContain('审核与操作记录')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
|
||||
import TruckOrderActions from '../components/TruckOrderActions.vue'
|
||||
import { truckStatusLabels } from '../truckWorkflow'
|
||||
import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistory } from '../types'
|
||||
|
||||
const route = useRoute(), router = useRouter()
|
||||
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false), saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let requestSequence = 0
|
||||
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
const statusLabel = (value: string | null) => value ? truckStatusLabels[value as keyof typeof truckStatusLabels] || value : '—'
|
||||
const fields = computed(() => detail.value ? [
|
||||
['客户编号', detail.value.customerId], ['车辆类型', detail.value.vehicleType ? ({ PALLET_15: '15拖', PALLET_30: '30拖' })[detail.value.vehicleType] : '—'],
|
||||
['起运地', detail.value.origin], ['起运地详细地址', detail.value.originAddress], ['起运地联系人', detail.value.originContactName], ['起运地联系电话', detail.value.originContactPhone],
|
||||
['目的地', detail.value.destination], ['目的地详细地址', detail.value.destinationAddress], ['目的地联系人', detail.value.destinationContactName], ['目的地联系电话', detail.value.destinationContactPhone],
|
||||
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
|
||||
['申报件数', detail.value.packageCount], ['总重量(kg)', detail.value.totalWeightKg], ['总体积(m³)', detail.value.totalVolumeCbm],
|
||||
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
|
||||
['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount],
|
||||
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
|
||||
] : [])
|
||||
const palletLabels: Record<string, string> = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 },
|
||||
{ key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '实收件数', width: 120 },
|
||||
{ key: 'receivedAt', label: '实收时间', minWidth: 170, formatter: (row: LocalTransportPallet) => dateTime(row.receivedAt) },
|
||||
]
|
||||
const operations: Record<string, string> = { CREATE: '创建', UPDATE: '编辑', REVIEW: '订单审核', RECEIVE_PALLETS: '登记实收', REVIEW_PALLET_OVERAGE: '超托审核', ALLOCATE_PALLETS: '分配托盘', PREPARE_DISPATCH: '确认待派车', DISPATCH: '确认已派车', START_TRANSIT: '确认发运', COMPLETE: '确认完成', CANCEL: '取消订单' }
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', label: '操作时间', minWidth: 170, formatter: (row: OverseasBusinessHistory) => dateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作', minWidth: 130, formatter: (row: OverseasBusinessHistory) => operations[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'fromStatus', label: '原状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.fromStatus) },
|
||||
{ key: 'toStatus', label: '新状态', width: 130, formatter: (row: OverseasBusinessHistory) => statusLabel(row.toStatus) },
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 },
|
||||
]
|
||||
async function load() {
|
||||
if (saving.value) return
|
||||
const sequence = ++requestSequence
|
||||
detail.value = null; error.value = null
|
||||
if (!orderNo.value || !customerId.value || customerId.value === '0') {
|
||||
loading.value = false; error.value = { message: '缺少有效客户或订单信息,请返回本土卡车列表重新打开' }; return
|
||||
}
|
||||
loading.value = true
|
||||
try { const result = await getUsTruckOrder(orderNo.value, customerId.value); if (sequence === requestSequence) detail.value = result }
|
||||
catch (cause) { if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '本土卡车订单加载失败,请稍后重试') }
|
||||
finally { if (sequence === requestSequence) loading.value = false }
|
||||
}
|
||||
watch([orderNo, customerId], load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout aria-label="本土卡车订单详情">
|
||||
<template #header><div class="truck-detail-header"><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button><h1>本土卡车订单 {{ detail?.orderNo || orderNo }}</h1></div></template>
|
||||
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="loading" :rows="8" animated />
|
||||
<template v-else-if="detail">
|
||||
<section class="truck-detail-section"><div class="truck-detail-header"><h2>{{ statusLabel(detail.status) }}</h2><el-button :disabled="saving" @click="load">刷新订单</el-button></div>
|
||||
<FleetArrangementSummary v-if="detail.fleetArrangement" :arrangement="detail.fleetArrangement" />
|
||||
<template v-else><TruckOrderActions :detail="detail" @updated="detail = $event" @busy="saving = $event" /></template>
|
||||
</section>
|
||||
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
|
||||
<section class="truck-detail-section"><h2>托盘记录</h2><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></section>
|
||||
<section class="truck-detail-section"><h2>审核与操作记录</h2><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></section>
|
||||
</template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-detail-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.truck-detail-header h1, .truck-detail-header h2 { margin: 0; }
|
||||
.truck-detail-section { margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ describe('overseas business routes', () => {
|
||||
['overseas-business/purchases/:purchaseId/edit', permission.purchaseEdit],
|
||||
['overseas-business/trucks', permission.truckList],
|
||||
['overseas-business/trucks/new', permission.truckCreate],
|
||||
['overseas-business/trucks/:orderId', permission.truckList],
|
||||
['overseas-business/trucks/:orderId/edit', permission.truckEdit],
|
||||
['overseas-business/looseboards', permission.looseboardList],
|
||||
['overseas-business/looseboards/new', permission.looseboardCreate],
|
||||
@@ -17,10 +18,10 @@ describe('overseas business routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not invent excluded create or detail routes', () => {
|
||||
it('adds the requested truck detail without opening unrelated routes', () => {
|
||||
const paths = overseasBusinessRoutes.map((route) => route.path)
|
||||
expect(paths).not.toContain('overseas-business/purchases/new')
|
||||
expect(paths).not.toContain('overseas-business/trucks/:orderId')
|
||||
expect(paths).toContain('overseas-business/trucks/:orderId')
|
||||
expect(paths).not.toContain('overseas-business/looseboards/:orderId')
|
||||
expect(JSON.stringify(overseasBusinessRoutes)).not.toContain('permissionAnyOf')
|
||||
})
|
||||
|
||||
@@ -34,6 +34,12 @@ export const overseasBusinessRoutes = [
|
||||
props: { domain: 'truck' },
|
||||
meta: { title: '新增本土卡车订单', description: '创建海外本土卡车订单', permission: permission.truckCreate, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-business/trucks/:orderId',
|
||||
name: 'overseas-business-truck-detail',
|
||||
component: () => import('./pages/TruckOrderDetailView.vue'),
|
||||
meta: { title: '本土卡车订单详情', description: '审核及处理本土卡车订单', permission: permission.truckList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-business/trucks/:orderId/edit',
|
||||
name: 'overseas-business-truck-edit',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LocalTransportDetail, LocalTransportStatus } from './types'
|
||||
|
||||
export 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: '已取消',
|
||||
}
|
||||
export const truckCommandLabels = {
|
||||
review: '审核', 'receive-pallets': '登记实收', 'review-pallet-overage': '超托审核', 'allocate-pallets': '分配托盘',
|
||||
'prepare-dispatch': '确认待派车', dispatch: '确认已派车', 'start-transit': '确认发运', complete: '确认完成', cancel: '取消订单',
|
||||
} as const
|
||||
export type TruckCommand = keyof typeof truckCommandLabels
|
||||
export interface TruckCommandBase { version: number; idempotencyKey: string; remark?: string }
|
||||
export type TruckOperation =
|
||||
| { command: 'review' | 'review-pallet-overage'; payload: TruckCommandBase & { decision: 'APPROVE' | 'REJECT' } }
|
||||
| { command: 'receive-pallets'; payload: TruckCommandBase & { actualPalletCount: number; packageCount?: number; palletPackageCounts?: number[] } }
|
||||
| { command: 'allocate-pallets'; payload: TruckCommandBase & { palletIds: string[] } }
|
||||
| { command: 'prepare-dispatch' | 'dispatch' | 'start-transit' | 'complete'; payload: TruckCommandBase }
|
||||
| { command: 'cancel'; payload: TruckCommandBase & { status: 'CANCELLED' } }
|
||||
|
||||
const nextCommands: Partial<Record<LocalTransportStatus, TruckCommand>> = {
|
||||
PENDING_REVIEW: 'review', WAITING_INBOUND: 'receive-pallets', RECEIVING: 'receive-pallets',
|
||||
PALLET_OVERAGE_REVIEW: 'review-pallet-overage', WAITING_CONSOLIDATION: 'allocate-pallets',
|
||||
PARTIALLY_ALLOCATED: 'allocate-pallets', ALLOCATED: 'prepare-dispatch', PENDING_DISPATCH: 'dispatch',
|
||||
DISPATCHED: 'start-transit', IN_TRANSIT: 'complete',
|
||||
}
|
||||
const cancellable = new Set<LocalTransportStatus>(['PENDING_REVIEW', 'WAITING_INBOUND', 'RECEIVING', 'PALLET_OVERAGE_REVIEW', 'WAITING_CONSOLIDATION', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH'])
|
||||
|
||||
/** 拼车来源只读,独立单据只能执行当前状态对应的具名命令。 */
|
||||
export function availableTruckCommands(order: Pick<LocalTransportDetail, 'status' | 'fleetArrangement'>): TruckCommand[] {
|
||||
if (order.fleetArrangement) return []
|
||||
const next = nextCommands[order.status]
|
||||
return [...(next ? [next] : []), ...(cancellable.has(order.status) ? ['cancel' as const] : [])]
|
||||
}
|
||||
|
||||
export const truckCommandHints: Record<TruckCommand, string> = {
|
||||
review: '请核对车型、路线、两端联系信息、取送时间和货物资料。通过后进入待入仓,拒绝后结束本次申请。',
|
||||
'receive-pallets': '按实际收货填写截至本次的累计实收托数。首次实收确定本单托数,后续超出已确认托数需要超托审核。',
|
||||
'review-pallet-overage': '通过后将超收托盘纳入本单;拒绝后将超收托盘标记为拒收,按原已确认托数继续。',
|
||||
'allocate-pallets': '选择本单已实收且未分配的托盘,可分批分配;全部已接收托盘分配完成后才能进入待派车。',
|
||||
'prepare-dispatch': '确认全部已接收托盘已分配完成,将本单交由调度安排车辆。',
|
||||
dispatch: '请在车辆安排已实际完成后确认,本单将进入已派车。',
|
||||
'start-transit': '请在车辆已实际发运后确认,本单将进入运输中。',
|
||||
complete: '请在运输及交付已实际完成后确认。确认完成后不能撤回。',
|
||||
cancel: '取消后本单不能继续履约,请填写取消原因并核对已有收货和托盘记录。',
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# 海外通用资料与车辆管理
|
||||
|
||||
- 自有业务:`pages/OwnedBusinessView.vue`、`OwnedBusinessDetailView.vue`及`api/ownedBusiness.ts`沿`fleet-owned-business-v1.yaml`,是原排车装车后的自有车辆运营视图,不按货源分类或另建履约流程;共同规则见`../../../../../docs/海外仓业务/自有业务首版开发契约.md`。详情三个页签,运营登记1080px居中、照片760px居中;金额/油耗单位、附加与排车版本分离,`owned-business:list/edit/upload`独立,统计沿完整计划发车日期筛选。外配页面本批不实现。
|
||||
|
||||
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
|
||||
|
||||
- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# 海外通用资料原型核对矩阵
|
||||
|
||||
## 自有业务准备核对(2026-10-20,主规则已确认)
|
||||
|
||||
用户当前要求逐页处理,先看自有业务;HTTP 扩展由既有排车后端对话独立负责。本节记录原型与已确认规则的映射,不授权外配页面改造。自有与外配均为原排车装车后的车辆归属视图,不另建任务或履约流程;自有车增加运营细项,外配管理细度后续确认。视觉沿当前 Admin 高密度规范,业务与操作承载依据用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-10/悦/悦/新版.html` 及本轮截图。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 承载与字段 | 当前实现及待处理 |
|
||||
|---|---|---|---|---|
|
||||
| 自有业务列表 | 统计、筛选、查询/重置 | `page-vehicle-own-business`,8772行;`renderVehicleBusiness` 22794行、`resetVehicleBusiness` 22827行 | 独立页面;四项统计为排车数、装车完成、派送中、派送完成;计划发车日期范围、排车单号/车牌/FBA仓、状态、仓库筛选 | 已注册源码菜单和列表/详情路由;统计跟随同一全部筛选结果,不用当前页行数;不保留原型写死日期。页面实现和环境验收分别核对 |
|
||||
| 自有业务列表 | 任务明细 | 同页面表头及渲染函数 | 预约号、排车单号、车牌、司机、提货仓、目的仓、派送仓、油耗、托数、总价/单价、最晚/实际装车完成时间、预约/实际到达时间、状态;正式业务操作列按全局规则放第一列 | 原型动态改表头造成重复托数及仓库列错位,不作为正式字段定义;按我们自己的车辆归类,装车完成后三阶段可见;整车总金额除实际物理托数得单价,同车多仓及预约行内展开 |
|
||||
| 自有业务 | 查看明细 | `page-vehicle-own-business-detail` 8779行;`openOwnBusinessDetail` 22836行 | 独立详情页及返回入口;任务与车辆信息、路线货物与凭证、操作记录三个页签 | 不是排车详情四页签;不能直接复用旧车次八页签替代;原型驾驶证、路线、GPS/ETA、时间线等含固定演示值,须逐项映射真实来源,缺失不得伪造 |
|
||||
| 自有业务 | 上传照片、填写实际迈数 | `modal-own-business-images` 8791行;最终生效的 `openOwnBusinessImages` 23082行覆盖22842行同名函数;`switchOwnBusinessImageTab` 23080行、`saveOwnBusinessMileage` 23083行 | 760px居中弹窗;装车完成、油耗图片两页签,逐框JPG/PNG;油耗页包含实际迈数;底部关闭 | 原型仅内存/FileReader保存,存在示例图片占位;真实文件归属、资料版本、保存语义及迈数单位需沿正式提供者核对,不能把关闭或上传文件本身当作确认装车 |
|
||||
|
||||
用户已明确:按我们自己的车辆归类;装车完成后展示三个阶段;后台登记一次整车总金额,单价为总金额除实际物理托数;一张排车单一行,多仓及预约行内展开;实到时间、里程和油耗由后台补录,照片按原型上传。计划发车日期作为查询口径,统计随筛选变化;新增“登记运营信息”居中弹窗,照片仍独立弹窗。共同业务及三方写入边界唯一见工作区 `docs/海外仓业务/自有业务首版开发契约.md`,金额不擅自改称收入或成本,不因原型演示值自行新增业务事实。当前源码和自动检查不代表真实页面已验收。
|
||||
|
||||
## 运输路线地图
|
||||
|
||||
本轮最新要求:使用已有起点、目的地展示规划路线,后续另接GPS。唯一组件为`components/TransportRouteDialog.vue`,经`index.ts`公开导出;无固定样例、无定位采集、不产生业务记录。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user