Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b1e7163e | ||
|
|
5679b156ca | ||
|
|
3879a47673 | ||
|
|
b34626bd4b | ||
|
|
7005228b52 | ||
|
|
836e210b09 | ||
|
|
41a498657c |
@@ -38,44 +38,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"
|
||||
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -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 与人工截图验收。
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -34,6 +34,7 @@ type FilterModel = QueryModel & {
|
||||
}
|
||||
|
||||
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
|
||||
@@ -218,6 +219,7 @@ function openDetail(row: InboundOrderSummary) {
|
||||
}
|
||||
|
||||
async function handleTaskChanged() {
|
||||
emit('changed')
|
||||
const target = selectedInbound.value
|
||||
if (!isReview.value) {
|
||||
reviewDialogOpen.value = false
|
||||
@@ -297,6 +299,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} 条测量数据,费用初算已生成`)
|
||||
|
||||
@@ -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(880px, 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>
|
||||
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" placeholder="请输入累计实收托数" /></el-form-item>
|
||||
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" placeholder="选填" /></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" placeholder="请输入审核意见或操作说明,拒绝或取消时必填" 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,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><p>审核通过 → 实收托盘 → 分配托盘 → 待派车 → 已派车 → 运输中 → 完成</p><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: '取消后本单不能继续履约,请填写取消原因并核对已有收货和托盘记录。',
|
||||
}
|
||||
@@ -2,8 +2,13 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
|
||||
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
|
||||
export interface CargoDiscardItem { cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
|
||||
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
|
||||
export interface CargoDiscardItem { quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
|
||||
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
|
||||
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
|
||||
const response = await http.get<ApiEnvelope<CargoDiscardStatistics>, ApiEnvelope<CargoDiscardStatistics>>('/platform/v1/last-leg/cargo-discards/statistics', { params: query })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
|
||||
export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
|
||||
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
|
||||
|
||||
@@ -42,6 +42,36 @@ export interface InventoryDashboardDto {
|
||||
procurementPackages: number
|
||||
}
|
||||
|
||||
export interface InventoryPieceGroup {
|
||||
customerId: string
|
||||
warehouseId: string
|
||||
destinationCode: string | null
|
||||
anchorSkuCode: string | null
|
||||
availablePieceCount: number | string | null
|
||||
knownAvailablePieceCount: number | string
|
||||
unknownCartonCount: number | string
|
||||
availableCartonCount: number | string
|
||||
availablePalletCount: number | string
|
||||
skuContents: { skuCode: string; quantity: number | string }[]
|
||||
cartons: { cartonMark: string; inventoryPositionId: string; palletId: string | null; quantity: number | string | null; items: { skuCode: string; quantity: number | string }[] }[]
|
||||
}
|
||||
export interface InventoryPieceProjection {
|
||||
availablePieceCount: number | string | null
|
||||
knownAvailablePieceCount: number | string
|
||||
unknownCartonCount: number | string
|
||||
availableCartonCount: number | string
|
||||
availablePalletCount: number | string
|
||||
groups: InventoryPieceGroup[]
|
||||
}
|
||||
|
||||
/** 完整筛选的真实可用件数及混箱全部SKU,不按记账SKU换算。 */
|
||||
export async function getInventoryPieceProjection(query: InventoryBalanceQuery) {
|
||||
const response = await http.get<ApiEnvelope<InventoryPieceProjection>, ApiEnvelope<InventoryPieceProjection>>(
|
||||
'/platform/v1/last-leg/inventory/piece-projection', { params: params(query) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface InventoryPositionDto {
|
||||
id: string
|
||||
balanceId: string
|
||||
|
||||
@@ -51,8 +51,17 @@ export interface LastMileBusinessSource {
|
||||
businessOrderNo: string | null
|
||||
externalBookingNo: string | null
|
||||
}
|
||||
export interface ReturnOrderStatistics {
|
||||
totals: { orderCount: number; skuTypes: number; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }
|
||||
pageDimensions: Array<{ returnNo: string; lastlegOrderId: string; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }>
|
||||
}
|
||||
export async function getReturnOrderStatistics(params: LastMileOrderQuery) {
|
||||
const response = await http.get<ApiEnvelope<ReturnOrderStatistics>, ApiEnvelope<ReturnOrderStatistics>>('/platform/v1/last-leg/return-orders/statistics', { params })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface LastMileOrderSummaryDto {
|
||||
warehouseTransferQuantity?: import('./transferQuantityFacts').TransferQuantityFacts | null
|
||||
businessSource?: LastMileBusinessSource | null
|
||||
id: string
|
||||
customerId: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions } from './relabelOrders'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type CreateRelabelPayload } from './relabelOrders'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
@@ -29,7 +29,7 @@ describe('relabel overseas API', () => {
|
||||
it('uses the real create and status endpoints', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: {} })
|
||||
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
|
||||
const payload: CreateRelabelPayload = { customerOrderNo: 'CO-1', operationWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ sourceInventoryBalanceId: '11', sourceInventoryPositionId: '12', sourceInboundOrderId: '13', cartonMark: 'BOX', operationType: 'SKU_LABEL', skuItems: [{ skuCode: 'SKU', newSkuCode: 'SKU-2', quantity: 8 }], skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
|
||||
await createRelabelOrder('10001', payload)
|
||||
await changeRelabelOrderStatus('RL/1', 'PROCESSING', 'ok')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } })
|
||||
|
||||
@@ -12,6 +12,15 @@ export interface RelabelOrderQuery {
|
||||
}
|
||||
|
||||
export interface CreateRelabelItemPayload {
|
||||
sourceInventoryBalanceId?: string
|
||||
sourceInventoryPositionId?: string
|
||||
sourceInboundOrderId?: string
|
||||
returnCartonId?: string
|
||||
cartonMark: string
|
||||
fbaCartonMark?: string
|
||||
destinationFbaWarehouseCode?: string
|
||||
operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
|
||||
skuItems: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
|
||||
skuCode: string
|
||||
productNameZh: string
|
||||
totalPackageCount: number
|
||||
@@ -30,9 +39,9 @@ export interface CreateRelabelItemPayload {
|
||||
|
||||
export interface CreateRelabelPayload {
|
||||
customerOrderNo: string
|
||||
operationWarehouseId?: string
|
||||
/** @deprecated compatibility alias for older platform fixtures */
|
||||
overseasWarehouseId?: string
|
||||
operationWarehouseId: string
|
||||
deliveryMethod?: 'TRUCK' | 'EXPRESS' | 'PICKUP' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP'
|
||||
printShippingLabel?: boolean
|
||||
labelDeadline: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
@@ -52,6 +61,7 @@ export interface RelabelWarehouseOptionDto {
|
||||
}
|
||||
|
||||
export interface RelabelSummaryDto {
|
||||
operationTypes?: Array<'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL'>
|
||||
id: string
|
||||
relabelNo: string
|
||||
lastlegOrderId?: string | null
|
||||
@@ -93,7 +103,7 @@ export interface RelabelSummaryDto {
|
||||
|
||||
export interface RelabelItemDto {
|
||||
id: string
|
||||
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
|
||||
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU' | 'RETURN_BULK_SKU_LABEL'
|
||||
skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
|
||||
skuCode?: string | null
|
||||
productNameZh?: string | null
|
||||
@@ -129,6 +139,55 @@ export interface RelabelDetailDto {
|
||||
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string | null; reviewerName?: string | null; reviewRemark?: string | null; completedAt?: string | null } | null
|
||||
}
|
||||
|
||||
export interface RelabelQuantity {
|
||||
sourceUnitCounts?: Record<string, number | string> | null
|
||||
labelPieceCount: number | string | null
|
||||
cartonCount: number | string | null
|
||||
skuCount: number | string
|
||||
duplicateCarton: boolean
|
||||
quantityUnit: string
|
||||
sourcePalletCount: number | string | null
|
||||
targetPalletCount: number | string | null
|
||||
}
|
||||
export interface RelabelQuantityTotals {
|
||||
knownSourcePalletCount?: number | string
|
||||
unknownSourcePalletOrders?: number | string
|
||||
knownTargetPalletCount?: number | string
|
||||
unknownTargetPalletOrders?: number | string
|
||||
orderCount: number | string
|
||||
skuCount: number | string
|
||||
knownLabelPieceCount: number | string
|
||||
unknownPieceOrders: number | string
|
||||
knownCartonCount: number | string
|
||||
unknownCartonOrders: number | string
|
||||
orders: Record<string, RelabelQuantity>
|
||||
}
|
||||
export interface RelabelInventoryCandidate {
|
||||
inventoryBalanceId?: string | null
|
||||
inboundOrderId?: string | null
|
||||
inboundOrderNo: string
|
||||
inboundCode?: string
|
||||
skuCode: string
|
||||
productNameZh: string
|
||||
availablePackages: number | string
|
||||
sourceType: string
|
||||
returnCartonId?: string | null
|
||||
cartonMark?: string | null
|
||||
cartonAvailablePackages?: number | string
|
||||
cartonSkuItems?: Array<{ skuCode: string; quantity: number }>
|
||||
positions: Array<{ positionId: string; palletNo: string; availablePackages: number | string }>
|
||||
}
|
||||
/** 候选归属和可用量由服务端决定,长整型来源ID按字符串原样提交。 */
|
||||
export async function listRelabelInventoryCandidates(customerId: string, operationWarehouseId: string) {
|
||||
const response = await http.get<ApiEnvelope<RelabelInventoryCandidate[]>, ApiEnvelope<RelabelInventoryCandidate[]>>('/platform/v1/last-leg/relabel-applications/inventory-candidates', { params: { customerId, operationWarehouseId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
/** 汇总同一筛选下全部单据,不以当前页合计代替。 */
|
||||
export async function getRelabelQuantities(query: RelabelOrderQuery) {
|
||||
const response = await http.get<ApiEnvelope<RelabelQuantityTotals>, ApiEnvelope<RelabelQuantityTotals>>('/platform/v1/last-leg/relabel-applications/quantity-summary', { params: query })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface ReturnPackingPlanBoxItem {
|
||||
skuCode: string
|
||||
quantity: number
|
||||
@@ -233,8 +292,14 @@ export async function changeRelabelOrderStatus(relabelNo: string, status: string
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface RelabelReviewTask {
|
||||
version: number
|
||||
taskStatus: string
|
||||
items?: Array<{ sourcePalletCode?: string | null; sourcePalletBasis?: string }>
|
||||
history?: Array<{ action: string; detail?: string }>
|
||||
}
|
||||
export async function getRelabelReviewTask(relabelNo: string) {
|
||||
const response = await http.get<ApiEnvelope<{ version: number; taskStatus: string }>, ApiEnvelope<{ version: number; taskStatus: string }>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`)
|
||||
const response = await http.get<ApiEnvelope<RelabelReviewTask>, ApiEnvelope<RelabelReviewTask>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
|
||||
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; relabelLockedQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
|
||||
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
|
||||
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
|
||||
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
|
||||
export interface ReturnCartonDiscardHistory { discardNo: string; skuCode: string; productNameZh?: string; beforeQuantity: number; quantity: number; afterQuantity: number; operatorId?: string; occurredAt: string }
|
||||
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[]; discardHistory?: ReturnCartonDiscardHistory[] }
|
||||
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
|
||||
export interface ReturnPackingSource { cartonMark: string; sourceReturnNo?: string; sourceUnitType?: string; items: ReturnCartonDetail['items'] }
|
||||
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
|
||||
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND' | 'DISCARD'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
|
||||
export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string }
|
||||
|
||||
export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnStockRow>>, ApiEnvelope<OverseasPagePayload<ReturnStockRow>>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { LastMileOrderQuery } from './lastMileOrders'
|
||||
export interface TransferQuantityFacts {
|
||||
declaredPieceCount: number | string | null
|
||||
receivedPieceCount: number | string | null
|
||||
declaredCartonCount: number | string
|
||||
receivedCartonCount: number | string
|
||||
receivedPalletCount: number | string
|
||||
availableCartonCount: number | string
|
||||
shippedCartonCount: number | string
|
||||
remainingPalletCount: number | string
|
||||
shippedPalletCount: number | string
|
||||
}
|
||||
export interface TransferQuantityTotals extends TransferQuantityFacts {
|
||||
orderCount: number | string
|
||||
knownDeclaredPieceCount: number | string
|
||||
unknownDeclaredOrderCount: number | string
|
||||
knownReceivedPieceCount: number | string
|
||||
unknownReceivedOrderCount: number | string
|
||||
}
|
||||
export async function getTransferQuantityTotals(query: LastMileOrderQuery) {
|
||||
const response = await http.get<ApiEnvelope<TransferQuantityTotals>, ApiEnvelope<TransferQuantityTotals>>(
|
||||
'/platform/v1/last-leg/orders/warehouse-transfer-quantities', { params: query },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { listRelabelInventoryCandidates } from '../api/relabelOrders'
|
||||
import RelabelInventoryItemsEditor from './RelabelInventoryItemsEditor.vue'
|
||||
vi.mock('../api/relabelOrders', () => ({ listRelabelInventoryCandidates: vi.fn() }))
|
||||
describe('formal relabel source tasks', () => {
|
||||
it('submits one carton with long source IDs and independently editable SKU pieces; warehouse change clears it', async () => {
|
||||
vi.mocked(listRelabelInventoryCandidates).mockResolvedValue([{ inventoryBalanceId: '9007199254740993', inboundOrderId: '9007199254740994', inboundOrderNo: 'IN-1', skuCode: 'A', productNameZh: '商品', availablePackages: 1, sourceType: 'INBOUND', positions: [{ positionId: '9007199254740995', palletNo: 'P1', availablePackages: 1 }] }])
|
||||
const wrapper = mount(RelabelInventoryItemsEditor, { props: { customerId:'C', warehouseId:'W',operationType:'CARTON_AND_SKU',shipmentNo:'S',destination:'SCK8',requirement:'要求' }, global:{plugins:[ElementPlus]} })
|
||||
await flushPromises()
|
||||
wrapper.findComponent({name:'ElSelect'}).vm.$emit('update:modelValue','inventory:9007199254740995')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '添加箱任务')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('input').find(input => input.attributes('maxlength') === '255')!.setValue('BOX-1')
|
||||
await wrapper.get('input[aria-label="新 SKU"]').setValue('B')
|
||||
wrapper.findComponent({name:'ElInputNumber'}).vm.$emit('update:modelValue',8)
|
||||
await flushPromises()
|
||||
const items = wrapper.emitted('update:items')!.at(-1)![0] as Array<Record<string, unknown>>
|
||||
expect(items).toMatchObject([{sourceInventoryBalanceId:'9007199254740993',sourceInventoryPositionId:'9007199254740995',sourceInboundOrderId:'9007199254740994',cartonMark:'BOX-1',totalPackageCount:1,notifiedPackageCount:1,destinationFbaWarehouseCode:'SCK8',skuItems:[{skuCode:'A',newSkuCode:'B',quantity:8}]}])
|
||||
expect(items[0]).not.toHaveProperty('sourceLabel')
|
||||
await wrapper.setProps({warehouseId:'OTHER'}); await flushPromises()
|
||||
expect(wrapper.emitted('update:items')!.at(-1)![0]).toEqual([])
|
||||
expect(listRelabelInventoryCandidates).toHaveBeenLastCalledWith('C','OTHER')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { listRelabelInventoryCandidates, type CreateRelabelItemPayload, type RelabelInventoryCandidate } from '../api/relabelOrders'
|
||||
const props = defineProps<{ customerId: string; warehouseId: string; operationType: CreateRelabelItemPayload['operationType']; shipmentNo: string; destination: string; requirement: string }>()
|
||||
const emit = defineEmits<{ 'update:items': [CreateRelabelItemPayload[]] }>()
|
||||
const candidates = ref<RelabelInventoryCandidate[]>([]), selected = ref(''), loading = ref(false), error = ref('')
|
||||
const tasks = ref<Array<CreateRelabelItemPayload & { sourceLabel: string; sourceKey: string; available: number }>>([])
|
||||
let generation = 0
|
||||
type SourceOption = { key: string; label: string; candidate: RelabelInventoryCandidate; position: RelabelInventoryCandidate['positions'][number] | undefined; available: number }
|
||||
const options = computed(() => candidates.value.flatMap<SourceOption>(candidate => candidate.returnCartonId
|
||||
? Number(candidate.cartonAvailablePackages) > 0 ? [{ key: `return:${candidate.returnCartonId}`, label: `${candidate.inboundOrderNo} · ${candidate.cartonMark}`, candidate, position: undefined, available: 1 }] : []
|
||||
: candidate.positions.map(position => ({ key: `inventory:${position.positionId}`, label: `${candidate.inboundOrderNo} · ${candidate.skuCode} · 记账托盘 ${position.palletNo || '未提供'} · 可用 ${position.availablePackages} 箱`, candidate, position, available: Number(position.availablePackages) }))))
|
||||
/** 客户或仓库切换立即清空旧来源,异步返回只接收最后一次请求。 */
|
||||
async function load() {
|
||||
const current = ++generation
|
||||
candidates.value = []; selected.value = ''; tasks.value = []; error.value = ''
|
||||
if (!props.customerId || !props.warehouseId) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try { const result = await listRelabelInventoryCandidates(props.customerId, props.warehouseId); if (current === generation) candidates.value = result }
|
||||
catch { if (current === generation) error.value = '库存来源加载失败,请重试' }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [props.customerId, props.warehouseId], load, { immediate: true })
|
||||
watch(() => props.operationType, () => { tasks.value = [] })
|
||||
function add() {
|
||||
const option = options.value.find(item => item.key === selected.value)
|
||||
if (!option) return
|
||||
if (tasks.value.filter(item => item.sourceKey === option.key).length >= option.available) { error.value = '已达到该来源可用箱数'; return }
|
||||
const source = option.candidate
|
||||
tasks.value.push({ sourceLabel: option.label, sourceKey: option.key, available: option.available,
|
||||
sourceInventoryBalanceId: source.inventoryBalanceId || undefined, sourceInventoryPositionId: option.position?.positionId,
|
||||
sourceInboundOrderId: source.inboundOrderId || undefined, returnCartonId: source.returnCartonId || undefined,
|
||||
skuCode: source.skuCode, productNameZh: source.productNameZh || source.skuCode,
|
||||
totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: source.cartonMark || '', fbaCartonMark: '',
|
||||
operationType: props.operationType, shipmentNo: props.shipmentNo, newSkuCode: '',
|
||||
skuItems: props.operationType === 'CARTON_LABEL' ? [] : (source.cartonSkuItems?.length ? source.cartonSkuItems : [{ skuCode: source.skuCode, quantity: 1 }]).map(item => ({ skuCode: item.skuCode, newSkuCode: '', quantity: item.quantity })) })
|
||||
error.value = ''
|
||||
}
|
||||
/** 每箱任务数量固定为一,SKU子项单独按件提交,额外展示字段不进入正式请求。 */
|
||||
watch(() => [tasks.value, props.shipmentNo, props.destination, props.requirement], () => {
|
||||
emit('update:items', tasks.value.map(({ sourceLabel: _label, sourceKey: _key, available: _available, ...item }) => ({ ...item,
|
||||
shipmentNo: props.shipmentNo.trim(), labelRequirement: props.requirement.trim() || undefined,
|
||||
destinationFbaWarehouseCode: props.operationType === 'SKU_LABEL' ? undefined : props.destination.trim(),
|
||||
newSkuCode: item.skuItems.map(sku => sku.newSkuCode).join(','),
|
||||
skuItems: props.operationType === 'CARTON_LABEL' ? [] : item.skuItems })))
|
||||
}, { deep: true, immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="库存与箱任务">
|
||||
<h3 class="density-dialog-section__title">库存与箱任务({{ tasks.length }} 箱)</h3>
|
||||
<p class="density-dialog-note">普通入库托盘仅为冻结记账位置;每箱填写一个我方箱唛,SKU 数量按件填写,实际来源托盘由 PDA 现场确认。</p>
|
||||
<el-select v-model="selected" filterable :loading="loading" :disabled="!customerId || !warehouseId" placeholder="请选择正式库存来源">
|
||||
<el-option v-for="option in options" :key="option.key" :label="option.label" :value="option.key" />
|
||||
</el-select>
|
||||
<el-button :disabled="!selected || loading" @click="add">添加箱任务</el-button>
|
||||
<p v-if="error" role="alert">{{ error }} <el-button link @click="load">重试加载</el-button></p>
|
||||
<section v-for="(item, index) in tasks" :key="index" class="density-dialog-section">
|
||||
<p>{{ index + 1 }}. {{ item.sourceLabel }} <el-button link type="danger" @click="tasks.splice(index, 1)">移除</el-button></p>
|
||||
<el-form-item label="我方箱唛" required><el-input v-model="item.cartonMark" :readonly="Boolean(item.returnCartonId)" maxlength="255" placeholder="请输入来源箱的我方箱唛" /></el-form-item>
|
||||
<el-form-item label="中文品名" required><el-input v-model="item.productNameZh" maxlength="255" placeholder="请输入中文品名" /></el-form-item>
|
||||
<el-form-item v-if="operationType !== 'SKU_LABEL'" label="新 FBA 箱唛" required><el-input v-model="item.fbaCartonMark" maxlength="255" placeholder="请输入新 FBA 箱唛" /></el-form-item>
|
||||
<table v-if="operationType !== 'CARTON_LABEL'" class="density-table density-table--editable"><thead><tr><th>旧 SKU</th><th>新 SKU</th><th>件数</th><th>操作</th></tr></thead><tbody>
|
||||
<tr v-for="(sku, skuIndex) in item.skuItems" :key="skuIndex"><td><el-input v-model="sku.skuCode" aria-label="旧 SKU" placeholder="请输入旧 SKU" /></td><td><el-input v-model="sku.newSkuCode" aria-label="新 SKU" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="sku.quantity" :min="1" :precision="0" aria-label="SKU 件数" placeholder="请输入件数" /></td><td><el-button link @click="item.skuItems.splice(skuIndex, 1)">移除 SKU</el-button></td></tr>
|
||||
</tbody></table>
|
||||
<el-button v-if="operationType !== 'CARTON_LABEL'" @click="item.skuItems.push({ skuCode: '', newSkuCode: '', quantity: 1 })">添加 SKU</el-button>
|
||||
</section>
|
||||
<p v-if="!loading && !error && customerId && warehouseId && !options.length" role="status">当前客户及仓库没有可用库存来源。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TransferQuantitySummary from './TransferQuantitySummary.vue'
|
||||
import { getTransferQuantityTotals } from '../api/transferQuantityFacts'
|
||||
vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() }))
|
||||
|
||||
describe('转运完整筛选数量', () => {
|
||||
it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => {
|
||||
vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' })
|
||||
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)')
|
||||
expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托')
|
||||
expect(wrapper.text()).toContain('剩余 2 托')
|
||||
expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)')
|
||||
await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('全筛选 2 票')
|
||||
expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' })
|
||||
})
|
||||
it('shows a recoverable error instead of zero totals', async () => {
|
||||
vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('加载失败')
|
||||
expect(wrapper.text()).not.toContain('全筛选 0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { getTransferQuantityTotals, type TransferQuantityTotals } from '../api/transferQuantityFacts'
|
||||
import type { LastMileOrderQuery } from '../api/lastMileOrders'
|
||||
const props = defineProps<{ query: LastMileOrderQuery }>()
|
||||
const summary = ref<TransferQuantityTotals>()
|
||||
const error = ref('')
|
||||
let sequence = 0
|
||||
watch(() => JSON.stringify(props.query), async () => {
|
||||
const current = ++sequence
|
||||
summary.value = undefined; error.value = ''
|
||||
try { const value = await getTransferQuantityTotals(props.query); if (current === sequence) summary.value = value }
|
||||
catch { if (current === sequence) error.value = '转运全筛选数量加载失败,请重新查询' }
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="summary" role="status" aria-label="仓储转运全筛选汇总">
|
||||
<strong>仓储转运 · 全筛选 {{ summary.orderCount }} 票</strong> ·
|
||||
预报 {{ summary.declaredPieceCount ?? `未记录(已知 ${summary.knownDeclaredPieceCount} 件,${summary.unknownDeclaredOrderCount} 票未知)` }}{{ summary.declaredPieceCount == null ? '' : ' 件' }} / {{ summary.declaredCartonCount }} 箱 ·
|
||||
历史实收 {{ summary.receivedPieceCount ?? `未记录(已知 ${summary.knownReceivedPieceCount} 件,${summary.unknownReceivedOrderCount} 票未知)` }}{{ summary.receivedPieceCount == null ? '' : ' 件' }} / {{ summary.receivedCartonCount }} 箱 / {{ summary.receivedPalletCount }} 托 ·
|
||||
当前已清点 {{ summary.availableCartonCount }} 箱 · 剩余 {{ summary.remainingPalletCount }} 托 · 已出库 {{ summary.shippedCartonCount }} 箱 / {{ summary.shippedPalletCount }} 托(托盘可重叠)
|
||||
</div>
|
||||
<p v-else-if="error" role="alert">{{ error }}</p>
|
||||
</template>
|
||||
@@ -35,6 +35,19 @@ function fixture(): WarehouseTransferDetailDto {
|
||||
beforeEach(() => { can.mockReset().mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset(); vi.mocked(increaseWarehouseTransferForecast).mockReset() })
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
describe('转运预报与实际签收明细', () => {
|
||||
it('keeps historical receipt totals after discard or shipment and leaves unknown declaration differences blank', async () => {
|
||||
const value = fixture()
|
||||
value.items[0]!.quantity = null
|
||||
value.cartons[0]!.status = 'DISCARD_PENDING'
|
||||
value.cartons[1]!.status = 'SHIPPED'
|
||||
value.cartons[2]!.status = 'DISCARDED'
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.emitted('summary')!.at(-1)![0]).toMatchObject({ boxes: 3, quantity: 27, skus: 2, forecastQuantity: null })
|
||||
const rows = wrapper.findAllComponents(DataTableStub)[0]!.props('data')
|
||||
expect(rows[0]).toMatchObject({ receivedQuantity: 20, receivedBoxes: 2, quantityDifference: null })
|
||||
expect(wrapper.findAll('.test-table')[0]!.text()).toContain('未记录')
|
||||
})
|
||||
it('shows every SKU in a mixed box and counts its physical measurements once', async () => {
|
||||
const value = fixture()
|
||||
value.cartons = [{ ...value.cartons[0]!, quantity: 10, items: [{ transferItemId: '10', skuCode: 'SKU-A', quantity: 3 }, { transferItemId: '20', skuCode: 'SKU-B', quantity: 7 }] }]
|
||||
|
||||
@@ -53,7 +53,8 @@ const cartonsOpen = ref(false)
|
||||
let generation = 0
|
||||
// 同一完成原因保存于箱记录,去重后在尾程详情直接展示。
|
||||
const discrepancyReasons = computed(() => [...new Set((detail.value?.cartons || []).map(box => box.discrepancyReason?.trim()).filter((reason): reason is string => Boolean(reason)))])
|
||||
const cartons = computed(() => (detail.value?.cartons || []).filter(box => box.status === 'COUNTED'))
|
||||
// 已签收是历史事实,后续废弃占用、废弃或出库不能从实收汇总中移除。
|
||||
const cartons = computed(() => (detail.value?.cartons || []).filter(box => ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].includes(box.status)))
|
||||
function itemFor(box: WarehouseTransferCartonDto) { return detail.value?.items.find(item => box.transferItemId != null && String(item.id) === String(box.transferItemId)) }
|
||||
function boxLines(box: WarehouseTransferCartonDto) {
|
||||
if (box.items?.length) return box.items
|
||||
@@ -71,9 +72,10 @@ watch([detail, totals], () => emit('summary', detail.value ? { ...totals.value,
|
||||
const comparisonRows = computed(() => (detail.value?.items || []).map(item => {
|
||||
const boxes = cartons.value.filter(box => boxLines(box).some(line => String(line.transferItemId) === String(item.id)))
|
||||
const receivedQuantity = boxes.reduce((sum, box) => sum + boxLines(box).filter(line => String(line.transferItemId) === String(item.id)).reduce((total, line) => total + Number(line.quantity), 0), 0)
|
||||
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: receivedQuantity - Number(item.quantity || 0), boxDifference: boxes.length - Number(item.packageCount || 0) }
|
||||
return { ...item, receivedQuantity, receivedBoxes: boxes.length, quantityDifference: item.quantity == null ? null : receivedQuantity - Number(item.quantity), boxDifference: boxes.length - Number(item.packageCount || 0) }
|
||||
}))
|
||||
function differenceText(value: unknown) {
|
||||
if (value == null) return '未记录'
|
||||
if (!cartons.value.length) return '待签收'
|
||||
const difference = Number(value)
|
||||
return difference > 0 ? `+${difference}` : String(difference)
|
||||
|
||||
@@ -183,7 +183,9 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
cartons: metric(dto.packageCount),
|
||||
cartonDifference: warehouseTransfer ? metric(dto.packageDifference) : '—',
|
||||
volume: metric(dto.volumeCbm),
|
||||
quantity: metric(dto.productQuantity),
|
||||
quantity: warehouseTransfer ? (dto.warehouseTransferQuantity?.receivedPieceCount ?? '未记录') : metric(dto.productQuantity),
|
||||
declaredPieces: warehouseTransfer ? (dto.warehouseTransferQuantity?.declaredPieceCount ?? '未记录') : '—',
|
||||
receivedPieces: warehouseTransfer ? (dto.warehouseTransferQuantity?.receivedPieceCount ?? '未记录') : '—',
|
||||
volumeWeight: metric(dto.volumeWeightKg),
|
||||
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
|
||||
portAta: formatOverseasDateTime(dto.actualArrivalAt),
|
||||
|
||||
@@ -10,11 +10,11 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import type { CargoDiscardDetail, CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
import CargoDiscardsView from './CargoDiscardsView.vue'
|
||||
|
||||
const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn() }))
|
||||
const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn(), statistics: vi.fn() }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
|
||||
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo }))
|
||||
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo, getCargoDiscardStatistics: api.statistics }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
|
||||
@@ -25,6 +25,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks(); can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
api.customers.mockResolvedValue({ records: [{ id: '9007199254740994', tenantName: '客户一' }] })
|
||||
api.statistics.mockResolvedValue({ orderCount: 1, skuCount: 1, requestedPackages: 2, actualPackages: null, requestedPieces: null, actualPieces: null, palletCount: null })
|
||||
api.list.mockResolvedValue({ records: [summary()], total: 1 })
|
||||
api.detail.mockImplementation(async () => detail()); api.review.mockResolvedValue(detail())
|
||||
api.photo.mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
|
||||
@@ -8,202 +8,107 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<CargoDiscardSummary[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const customersLoading = ref(false)
|
||||
const listError = ref<ApiErrorDetails | null>(null)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const auditingNo = ref('')
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref<CargoDiscardDetail | null>(null)
|
||||
const detailRequest = ref<Pick<CargoDiscardSummary, 'discardNo' | 'customerId'> | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '请选择客户', options: [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))], props: { disabled: customersLoading.value || Boolean(customersError.value), filterable: true } },
|
||||
{ key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
|
||||
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
|
||||
const statistics = ref<CargoDiscardStatistics | null>(null)
|
||||
/** 数量事实缺失时明确提示,不用零代替未记录。 */
|
||||
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
|
||||
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
|
||||
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
const photoUrls = reactive<Record<string, string>>({})
|
||||
const photoLoading = reactive<Record<string, boolean>>({})
|
||||
const photoErrors = reactive<Record<string, ApiErrorDetails | undefined>>({})
|
||||
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null); const statisticsError = ref<ApiErrorDetails | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null)
|
||||
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null)
|
||||
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, ApiErrorDetails | null>>({})
|
||||
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
|
||||
let photoGeneration = 0
|
||||
let detailGeneration = 0
|
||||
let listGeneration = 0
|
||||
|
||||
function clearPhotos() {
|
||||
photoGeneration++
|
||||
Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url))
|
||||
for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key])
|
||||
}
|
||||
let photoGeneration = 0, detailGeneration = 0, loadGeneration = 0, disposed = false
|
||||
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url)); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
|
||||
async function loadPhoto(fileId: string) {
|
||||
const current = detail.value
|
||||
const generation = photoGeneration
|
||||
if (!current || !detailOpen.value || photoLoading[fileId] || !access.can(permission.discardList)) return
|
||||
photoLoading[fileId] = true; delete photoErrors[fileId]
|
||||
try {
|
||||
const customerId = String(current.summary.customerId ?? '').trim()
|
||||
if (!customerId) { photoErrors[fileId] = getApiErrorDetails(null, '照片缺少客户范围,无法读取'); return }
|
||||
const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, customerId)
|
||||
if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return
|
||||
if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId])
|
||||
photoUrls[fileId] = URL.createObjectURL(blob)
|
||||
} catch (error) { if (generation === photoGeneration) photoErrors[fileId] = getApiErrorDetails(error, '废弃照片加载失败,请重试') }
|
||||
const current = detail.value; const generation = photoGeneration
|
||||
if (!current || photoLoading[fileId]) return
|
||||
if (!current.summary.customerId) { photoErrors[fileId] = { message: '照片缺少客户范围' }; return }
|
||||
photoLoading[fileId] = true; photoErrors[fileId] = null
|
||||
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, current.summary.customerId); if (disposed || photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
|
||||
catch (error) { if (!disposed && generation === photoGeneration) photoErrors[fileId] = getApiErrorDetails(error, '废弃照片加载失败') }
|
||||
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
|
||||
}
|
||||
watch(detailOpen, value => {
|
||||
if (value) return
|
||||
detailGeneration++; detailLoading.value = false; detailRequest.value = null; detail.value = null; detailError.value = null; clearPhotos()
|
||||
})
|
||||
onBeforeUnmount(() => { detailGeneration++; listGeneration++; clearPhotos() })
|
||||
watch(detailOpen, value => { if (!value) { detailGeneration++; clearPhotos() } })
|
||||
onBeforeUnmount(() => { disposed = true; loadGeneration++; detailGeneration++; clearPhotos() })
|
||||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||||
async function loadCustomers() {
|
||||
if (customersLoading.value) return
|
||||
customersLoading.value = true; customersError.value = null
|
||||
try { customers.value = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records }
|
||||
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
/** 分页与全筛选统计独立保留诊断,过期响应不得覆盖新的查询。 */
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
const generation = ++listGeneration
|
||||
loading.value = true; listError.value = null
|
||||
try {
|
||||
const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') })
|
||||
if (generation !== listGeneration) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (error) {
|
||||
if (generation !== listGeneration) return
|
||||
rows.value = []; total.value = 0; listError.value = getApiErrorDetails(error, '废弃单列表加载失败')
|
||||
} finally { if (generation === listGeneration) loading.value = false }
|
||||
const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null; statisticsError.value = null; statistics.value = null
|
||||
const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }
|
||||
const [list, stat] = await Promise.allSettled([listCargoDiscards(query), getCargoDiscardStatistics(query)])
|
||||
if (disposed || generation !== loadGeneration) return
|
||||
if (list.status === 'fulfilled') { rows.value = list.value.records; total.value = list.value.total }
|
||||
else { rows.value = []; total.value = 0; listError.value = getApiErrorDetails(list.reason, '废弃单列表加载失败') }
|
||||
if (stat.status === 'fulfilled') statistics.value = stat.value
|
||||
else statisticsError.value = getApiErrorDetails(stat.reason, '废弃统计加载失败')
|
||||
loading.value = false
|
||||
}
|
||||
async function openDetail(row: CargoDiscardSummary) {
|
||||
if (detailOpen.value || detailLoading.value || auditingNo.value || !access.can(permission.discardList)) return
|
||||
detailRequest.value = { discardNo: row.discardNo, customerId: row.customerId }
|
||||
detail.value = null; detailError.value = null; detailOpen.value = true
|
||||
await loadDetail()
|
||||
async function loadCustomers() {
|
||||
customerError.value = null
|
||||
try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); if (disposed) return; customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))] }
|
||||
catch (error) { if (!disposed) customerError.value = getApiErrorDetails(error, '客户列表加载失败') }
|
||||
}
|
||||
async function loadDetail() {
|
||||
const request = detailRequest.value
|
||||
if (!request || !detailOpen.value || detailLoading.value || !access.can(permission.discardList)) return
|
||||
const generation = ++detailGeneration
|
||||
detailLoading.value = true; detailError.value = null; detail.value = null; clearPhotos()
|
||||
try {
|
||||
const result = await getCargoDiscard(request.discardNo, request.customerId || undefined)
|
||||
if (generation !== detailGeneration || !detailOpen.value) return
|
||||
detail.value = result
|
||||
void Promise.all(result.photos.map(photo => loadPhoto(photo.fileId)))
|
||||
} catch (error) { if (generation === detailGeneration && detailOpen.value) detailError.value = getApiErrorDetails(error, '废弃单详情加载失败') }
|
||||
async function readDetail() {
|
||||
const target = detailTarget.value; if (!target) return
|
||||
const generation = ++detailGeneration; detailLoading.value = true; detailError.value = null
|
||||
try { const result = await getCargoDiscard(target.discardNo, target.customerId || undefined); if (disposed || generation !== detailGeneration || !detailOpen.value) return; detail.value = result; void Promise.all(result.photos.map(photo => loadPhoto(photo.fileId))) }
|
||||
catch (error) { if (!disposed && generation === detailGeneration) detailError.value = getApiErrorDetails(error, '废弃单详情加载失败') }
|
||||
finally { if (generation === detailGeneration) detailLoading.value = false }
|
||||
}
|
||||
function canAudit(row: CargoDiscardSummary) { return access.can(permission.discardApprove) && row.status === 'PENDING_REVIEW' }
|
||||
async function openDetail(row: CargoDiscardSummary) { if (reviewing.value) return; clearPhotos(); detail.value = null; detailTarget.value = row; detailOpen.value = true; await readDetail() }
|
||||
async function audit(row: CargoDiscardSummary) {
|
||||
if (!canAudit(row) || auditingNo.value || detailOpen.value || detailLoading.value) return
|
||||
auditingNo.value = row.discardNo; actionError.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认审核废弃单“${row.discardNo}”?审核通过后将推送 PDA 执行废弃,确认继续?`, '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm', type: 'warning' })
|
||||
if (!canAudit(row)) return
|
||||
await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined)
|
||||
ElMessage.success('已审核通过并推送 PDA'); await load()
|
||||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '审核失败') }
|
||||
finally { auditingNo.value = '' }
|
||||
if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return
|
||||
reviewing.value = row.discardNo; reviewError.value = null
|
||||
try { await ElMessageBox.confirm('确认审核废弃单 ' + row.discardNo + ' 并推送 PDA 执行废弃?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm' }); if (!access.can(permission.discardApprove)) return; await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() }
|
||||
catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') }
|
||||
finally { reviewing.value = '' }
|
||||
}
|
||||
onMounted(() => { void loadCustomers(); void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density">
|
||||
<ListPageLayout aria-label="废弃单列表"><section>
|
||||
<DataTable appearance="integrated" :stripe="false" :results-visible="!listError" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>废弃单</h1></template>
|
||||
<template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="customersError" title="客户列表加载失败" :message="customersError.message" :loading="customersLoading" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="actionError" title="审核失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
||||
</template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions">
|
||||
<el-button v-if="access.can(permission.discardList)" class="density-action" data-tone="neutral" size="small" :disabled="detailOpen || detailLoading || Boolean(auditingNo)" @click="openDetail(row as CargoDiscardSummary)">详情</el-button>
|
||||
<el-button v-if="canAudit(row as CargoDiscardSummary)" class="density-action" data-tone="success" size="small" :disabled="detailOpen || detailLoading || Boolean(auditingNo)" :loading="auditingNo === (row as CargoDiscardSummary).discardNo" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button>
|
||||
</div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '—'" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条废弃单</template>
|
||||
</DataTable>
|
||||
</section></ListPageLayout>
|
||||
</AdminPage>
|
||||
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="`废弃单详情 · ${detailRequest?.discardNo || '—'}`" fullscreenable :show-confirm="false" cancel-text="关闭" :loading="detailLoading" :confirm-loading="detailLoading" width="min(920px, calc(100vw - 28px))">
|
||||
<p v-if="detailLoading" class="density-dialog-note" role="status">正在读取废弃单详情…</p>
|
||||
<LoadErrorState v-if="detailError" title="废弃单详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<div v-if="detail" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="废弃单基础资料">
|
||||
<table class="density-table discard-summary"><tbody>
|
||||
<tr><th scope="row">废弃单号</th><td><LineClampText :content="detail.summary.discardNo" /></td><th scope="row">状态</th><td>{{ statusLabels[String(detail.summary.status)] || detail.summary.status || '—' }}</td></tr>
|
||||
<tr><th scope="row">仓库</th><td><LineClampText :content="detail.summary.overseasWarehouseName" /></td><th scope="row">出库单号</th><td><LineClampText :content="detail.summary.outboundNo" /></td></tr>
|
||||
<tr><th scope="row">入仓码</th><td colspan="3"><LineClampText :content="detail.summary.inboundCodes" :lines="2" /></td></tr>
|
||||
<tr><th scope="row">备注</th><td colspan="3"><LineClampText :content="detail.remark" :lines="2" /></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="废弃明细">
|
||||
<h3 class="density-dialog-section__title">废弃明细</h3>
|
||||
<el-table :data="detail.items" border :stripe="false" row-key="id">
|
||||
<el-table-column prop="inboundCode" label="入仓码" min-width="140"><template #default="{ row }"><LineClampText :content="row.inboundCode" /></template></el-table-column>
|
||||
<el-table-column prop="skuCode" label="SKU" min-width="140"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
|
||||
<el-table-column prop="productName" label="品名" min-width="160"><template #default="{ row }"><LineClampText :content="row.productName" /></template></el-table-column>
|
||||
<el-table-column prop="discardPackages" label="申请数量" width="100" />
|
||||
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、')" /></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="废弃现场照片">
|
||||
<h3 class="density-dialog-section__title">废弃现场照片</h3>
|
||||
<div v-if="detail.photos.length" class="discard-photo-grid">
|
||||
<div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
|
||||
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :alt="photo.fileName || '废弃现场照片'" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
|
||||
<div v-else class="discard-photo-stage">
|
||||
<span v-if="photoLoading[photo.fileId]" role="status">照片加载中…</span>
|
||||
<LoadErrorState v-else-if="photoErrors[photo.fileId]" compact title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" retry-text="重新加载照片" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
|
||||
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button>
|
||||
</div>
|
||||
<small>托盘:{{ photo.palletNo || '—' }} · {{ photo.submittedAt || '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="density-dialog-note">暂无已提交的废弃现场照片</p>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="废弃状态流转">
|
||||
<h3 class="density-dialog-section__title">状态流转</h3>
|
||||
<table class="density-table"><thead><tr><th>时间</th><th>状态</th><th>备注</th></tr></thead><tbody>
|
||||
<tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
|
||||
<tr v-if="!detail.histories.length"><td colspan="3">暂无状态流转记录</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="statisticsError" title="废弃统计加载失败" :message="statisticsError.message" @retry="load()"><template #details><ApiErrorMeta :details="statisticsError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></section></ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="detailOpen" class="admin-density-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
|
||||
<p v-if="detailLoading" role="status">正在读取废弃单…</p>
|
||||
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<table class="density-table" aria-label="废弃单基础资料"><tbody>
|
||||
<tr data-tone="summary"><th>废弃单号</th><td>{{ detail.summary.discardNo }}</td><th>出库单号</th><td>{{ detail.summary.outboundNo || '—' }}</td></tr>
|
||||
<tr data-tone="source"><th>仓库</th><td>{{ detail.summary.overseasWarehouseName || '—' }}</td><th>入仓码</th><td>{{ detail.summary.inboundCodes || '—' }}</td></tr>
|
||||
<tr data-tone="work"><th>状态</th><td>{{ statusLabels[detail.summary.status || ''] || detail.summary.status || '未记录' }}</td><th>备注</th><td>{{ detail.remark || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
<h3>废弃明细</h3><el-table :data="detail.items" border>
|
||||
<el-table-column label="入仓码"><template #default="{ row }"><LineClampText :content="row.inboundCode || ''" /></template></el-table-column>
|
||||
<el-table-column label="SKU"><template #default="{ row }"><LineClampText :content="row.skuCode || ''" /></template></el-table-column>
|
||||
<el-table-column label="品名"><template #default="{ row }"><LineClampText :content="row.productName || ''" /></template></el-table-column>
|
||||
<el-table-column label="申请数量" width="120"><template #default="{ row }">{{ quantity(row.discardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
|
||||
<el-table-column label="实际废弃" width="150"><template #default="{ row }">{{ quantity(row.actualDiscardPackages, row.quantityUnit || '单位未记录') }}</template></el-table-column>
|
||||
<el-table-column label="所选箱唛" min-width="180"><template #default="{ row }"><LineClampText :content="row.cartonMarks?.join('、') || ''" /></template></el-table-column>
|
||||
</el-table>
|
||||
<h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item">
|
||||
<el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported />
|
||||
<span v-else-if="photoLoading[photo.fileId]">照片加载中…</span>
|
||||
<LoadErrorState v-else-if="photoErrors[photo.fileId]" title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
|
||||
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small>
|
||||
</div></div><p v-else>暂无已提交的废弃现场照片</p>
|
||||
<h3>状态流转</h3><table v-if="detail.histories.length" class="density-table" aria-label="废弃状态流转"><tbody><tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '未记录' }}</td><td>{{ item.remark || '—' }}</td></tr></tbody></table><p v-else>暂无状态流转记录</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
.discard-summary { table-layout: fixed; }
|
||||
.discard-summary th { width: 100px; }
|
||||
.discard-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.discard-photo-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.discard-photo-item .el-image { width: 100%; height: 150px; }
|
||||
.discard-photo-stage { display: grid; align-items: center; min-height: 150px; }
|
||||
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height: 150px}.discard-photo-item small{color:var(--cc-text-secondary)}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@ import ElementPlus from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory'
|
||||
import { getInventoryPieceProjection, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory'
|
||||
import InventoryDetailView from './InventoryDetailView.vue'
|
||||
import InventoryView from './InventoryView.vue'
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
|
||||
vi.mock('../api/inventory', () => ({
|
||||
listInventoryBalances: vi.fn(), getInventorySummary: vi.fn(),
|
||||
listInventoryPositions: vi.fn(), listInventoryLedger: vi.fn(),
|
||||
getInventoryPieceProjection: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -45,6 +46,7 @@ describe('inventory customer-name snapshots', () => {
|
||||
vi.mocked(listInventoryBalances).mockReset().mockResolvedValue({ records: [balance], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(getInventorySummary).mockReset().mockResolvedValue({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 })
|
||||
vi.mocked(listInventoryPositions).mockReset().mockResolvedValue([])
|
||||
vi.mocked(getInventoryPieceProjection).mockReset().mockResolvedValue({ availablePieceCount: null, knownAvailablePieceCount: 0, unknownCartonCount: 10, availableCartonCount: 10, availablePalletCount: 1, groups: [] })
|
||||
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
})
|
||||
|
||||
@@ -72,6 +74,24 @@ describe('inventory customer-name snapshots', () => {
|
||||
expect(wrapper.text()).not.toContain(balance.id)
|
||||
})
|
||||
|
||||
it('keeps the formal customer and carton list when piece projection fails', async () => {
|
||||
vi.mocked(getInventoryPieceProjection).mockRejectedValueOnce(new Error('projection unavailable'))
|
||||
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('可用件数及箱内SKU加载失败')
|
||||
expect(wrapper.text()).not.toContain('库存数据加载失败')
|
||||
})
|
||||
|
||||
it('renders every mixed-carton SKU and real piece count alongside carton totals', async () => {
|
||||
vi.mocked(getInventoryPieceProjection).mockResolvedValueOnce({ availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', groups: [{ customerId, warehouseId: '200', destinationCode: null, anchorSkuCode: 'SKU-1', availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', skuContents: [{ skuCode: 'SKU-1', quantity: '5' }, { skuCode: 'SKU-SECOND', quantity: '7' }], cartons: [] }] })
|
||||
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件')
|
||||
expect(wrapper.text()).toContain('可用 12 件 / 2 箱 / 1 托')
|
||||
})
|
||||
|
||||
it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => {
|
||||
vi.mocked(listInventoryBalances).mockResolvedValueOnce({ records: [{ ...balance, inboundOrderNo: '', skuCode: '' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
routing.route.params = { inventoryId: balance.id }
|
||||
|
||||
@@ -12,7 +12,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
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 { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
|
||||
import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceProjection, type InventoryPieceGroup } from '../api/inventory'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { toInventoryListItem } from '../inventoryPresentation'
|
||||
@@ -25,6 +25,9 @@ const access = useAccessPreviewStore()
|
||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
const rows = ref<InventoryListItem[]>([])
|
||||
const summary = ref<InventoryDashboardDto | null>(null)
|
||||
const pieces = ref<InventoryPieceProjection | null>(null)
|
||||
const rowPieces = ref<Record<string, InventoryPieceGroup | undefined>>({})
|
||||
const pieceError = ref('')
|
||||
const loading = ref(false)
|
||||
const contextLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -50,7 +53,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
|
||||
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 },
|
||||
{ key: 'sku', prop: 'sku', label: '箱内SKU / 可用件数', minWidth: 220, slotName: 'skuContents', singleLine: false, className: 'inventory-sku-contents' },
|
||||
{ key: 'availablePieces', label: '可用件数', minWidth: 130, slotName: 'availablePieces' },
|
||||
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'location', prop: 'location', label: '库位', minWidth: 140, defaultVisible: false },
|
||||
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
|
||||
@@ -75,9 +79,18 @@ function query() {
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true; loadError.value = null
|
||||
try { const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(query()), getInventorySummary(query())]); rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult }
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
loading.value = true; loadError.value = null; pieces.value = null; rowPieces.value = {}; pieceError.value = ''
|
||||
try {
|
||||
const currentQuery = query()
|
||||
const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)])
|
||||
rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult
|
||||
try {
|
||||
const pieceResult = await getInventoryPieceProjection(currentQuery)
|
||||
rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))]))
|
||||
pieces.value = pieceResult
|
||||
} catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' }
|
||||
}
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; pieces.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -95,17 +108,20 @@ onMounted(() => { void loadWarehouses(); void load() })
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>库存管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><p v-if="pieceError" role="alert">{{ pieceError }}</p></template>
|
||||
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}(箱)</span><strong>{{ metric[1] }}</strong></div></section><p v-if="pieces" role="status" aria-label="可用库存件箱托汇总">全部筛选结果 · 可用 {{ pieces.availablePieceCount ?? `未知(已知 ${pieces.knownAvailablePieceCount} 件,${pieces.unknownCartonCount} 箱件数未记录)` }}{{ pieces.availablePieceCount == null ? '' : ' 件' }} / {{ pieces.availableCartonCount }} 箱 / {{ pieces.availablePalletCount }} 托</p></template>
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
|
||||
<template #skuContents="{ row }"><span v-if="rowPieces[row.id]?.skuContents.length">{{ rowPieces[row.id]!.skuContents.map(sku => `${sku.skuCode} × ${sku.quantity} 件`).join(';') }}<template v-if="Number(rowPieces[row.id]!.unknownCartonCount) > 0">;另有 {{ rowPieces[row.id]!.unknownCartonCount }} 箱内容未知</template></span><span v-else>记账SKU:{{ row.sku }};箱内件数未记录</span></template>
|
||||
<template #availablePieces="{ row }">{{ rowPieces[row.id]?.availablePieceCount ?? (row.available === 0 ? 0 : '未记录') }}</template>
|
||||
<template #pagination-left>共 {{ total }} 条库存记录</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-density :deep(.inventory-sku-contents .cell) { white-space: normal; overflow-wrap: anywhere; }
|
||||
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; }
|
||||
.inventory-metric:last-child { border-right: 0; }
|
||||
.inventory-metric span { color: #64748b; font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -45,6 +45,16 @@ async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE')
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
it('counts mixed parcel and box receipts by their physical unit instead of calling both parcels', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1' }, cargo: [], fees: [], statusHistory: [], operationHistory: [],
|
||||
returnReceipt: { order: { returnNo: 'RT-1', expectedQuantity: 8, receivedQuantity: 8 }, receipts: [{ receiptNo: 'P1', unitType: 'PARCEL', receivedQuantity: 1, boxCount: 0, signedAt: '2026-10-20T10:00:00' }, { receiptNo: 'B1', unitType: 'BOX', receivedQuantity: 1, boxCount: 1, signedAt: '2026-10-20T10:00:00', length: 30, width: 20, height: 10, weight: 2 }],
|
||||
cartons: [{ receiptNo: 'P1', cartonMark: 'PK1', countStatus: 'COUNTED', items: [] }, { receiptNo: 'B1', cartonMark: 'RC1', countStatus: 'COUNTED', items: [] }] } })
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
await wrapper.get('#tab-return-receipt').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('签收实物明细(1 包裹 · 1 箱)')
|
||||
expect(wrapper.text()).not.toContain('2 包裹')
|
||||
expect(wrapper.text()).toContain('长 30 cm · 宽 20 cm · 高 10 cm · 体积 0.006 m³ · 重量 2 kg')
|
||||
})
|
||||
it.each(['PENDING_REVIEW', 'PARTIALLY_RECEIVED', 'COMPLETED'])('shows only customer SKU forecast regardless of receipt status (%s)', async status => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: status, packageCount: 30 },
|
||||
cargo: [], fees: [], statusHistory: [], operationHistory: [],
|
||||
|
||||
@@ -75,10 +75,25 @@ function isSingleLineBusinessField(field: DetailField) { return field.label ===
|
||||
|
||||
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
|
||||
const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL')
|
||||
const returnReceiptUnitLabel = computed(() => detail.value?.returnReceipt?.receipts.some(receipt => receipt.unitType === 'PARCEL') ? '包裹' : '箱')
|
||||
const returnReceiptUnits = computed(() => {
|
||||
const receiptTypes = new Map((detail.value?.returnReceipt?.receipts || []).map(receipt => [receipt.receiptNo, receipt.unitType]))
|
||||
const counts = { parcels: 0, boxes: 0, unknown: 0 }
|
||||
for (const carton of detail.value?.returnReceipt?.cartons || []) {
|
||||
const type = receiptTypes.get(carton.receiptNo)
|
||||
if (type === 'PARCEL') counts.parcels++
|
||||
else if (type === 'BOX' || type === 'PALLET') counts.boxes++
|
||||
else counts.unknown++
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const returnReceiptUnitLabel = computed(() => returnReceiptUnits.value.parcels && !returnReceiptUnits.value.boxes && !returnReceiptUnits.value.unknown ? '包裹' : returnReceiptUnits.value.parcels || returnReceiptUnits.value.unknown ? '实物' : '箱')
|
||||
const returnReceiptUnitSummary = computed(() => {
|
||||
const { parcels, boxes, unknown } = returnReceiptUnits.value
|
||||
return [parcels ? `${parcels} 包裹` : '', boxes ? `${boxes} 箱` : '', unknown ? `${unknown} 个未识别载体` : ''].filter(Boolean).join(' · ') || '0 箱'
|
||||
})
|
||||
const signedReturnCartons = computed(() => (detail.value?.returnReceipt?.cartons || []).map(box => {
|
||||
const parcel = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && receipt.unitType === 'PARCEL')
|
||||
return { ...box, length: box.length ?? parcel?.length, width: box.width ?? parcel?.width, height: box.height ?? parcel?.height, weight: box.weight ?? parcel?.weight }
|
||||
const measuredUnit = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && (receipt.unitType === 'PARCEL' || receipt.unitType === 'BOX'))
|
||||
return { ...box, length: box.length ?? measuredUnit?.length, width: box.width ?? measuredUnit?.width, height: box.height ?? measuredUnit?.height, weight: box.weight ?? measuredUnit?.weight }
|
||||
}))
|
||||
function returnCartonVolume(box: { length?: number | null; width?: number | null; height?: number | null }) {
|
||||
if (box.length == null || box.width == null || box.height == null) return '—'
|
||||
@@ -104,6 +119,8 @@ const orderFields = computed<DetailField[]>(() => {
|
||||
{ label: '备注(客户)', wide: true, value: text(order?.customerRemark) },
|
||||
]
|
||||
if (order?.orderType === 'WAREHOUSE_TRANSFER') fields.splice(6, 0,
|
||||
{ label: '客户预报件数', value: order.warehouseTransferQuantity?.declaredPieceCount ?? '未记录' },
|
||||
{ label: '历史实收件数', value: order.warehouseTransferQuantity?.receivedPieceCount ?? '未记录' },
|
||||
{ label: '客户预报箱数', value: order.packageCount == null ? '—' : order.packageCount },
|
||||
{ label: '实收箱数', value: order.receivedPackageCount == null ? '—' : order.receivedPackageCount },
|
||||
{ label: '箱数差异', value: packageDifferenceText(order.packageDifference) },
|
||||
@@ -303,7 +320,7 @@ onMounted(load)
|
||||
<el-table-column prop="boxCount" label="实收箱数" width="100" />
|
||||
<el-table-column prop="receivedQuantity" label="实收数量" width="100" />
|
||||
</el-table>
|
||||
<h3>签收{{ returnReceiptUnitLabel }}明细({{ detail.returnReceipt.cartons?.length || 0 }} {{ returnReceiptUnitLabel }})</h3>
|
||||
<h3>签收{{ returnReceiptUnitLabel }}明细({{ returnReceiptUnitSummary }})</h3>
|
||||
<el-collapse v-if="detail.returnReceipt.cartons?.length">
|
||||
<el-collapse-item v-for="box in signedReturnCartons" :key="box.cartonMark" :name="box.cartonMark">
|
||||
<template #title><span class="return-carton-title"><span>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</span><span class="return-carton-measurements">长 {{ box.length ?? '—' }} cm · 宽 {{ box.width ?? '—' }} cm · 高 {{ box.height ?? '—' }} cm · 体积 {{ returnCartonVolume(box) }} m³ · 重量 {{ box.weight ?? '—' }} kg</span></span></template>
|
||||
@@ -320,7 +337,7 @@ onMounted(load)
|
||||
<el-empty v-else description="暂无签收箱号明细" />
|
||||
<h3>合箱记录</h3>
|
||||
<el-table :data="detail.returnReceipt.packingDetails || []" size="small" border empty-text="暂无合箱记录">
|
||||
<el-table-column prop="planNo" label="合箱计划" min-width="210" />
|
||||
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }">{{ row.planNo && row.planNo !== 'null' ? row.planNo : '—' }}</template></el-table-column>
|
||||
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
|
||||
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
|
||||
<el-table-column prop="skuCode" label="SKU" min-width="180" />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getContainerPlanProgress,
|
||||
listContainerPlanProgress,
|
||||
listLastMileOrders,
|
||||
getReturnOrderStatistics,
|
||||
reviewCargoDiscard,
|
||||
transferLastMileOrders,
|
||||
updateLastMileOrderPalletMerge,
|
||||
@@ -35,6 +36,7 @@ const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
getReturnOrderStatistics: vi.fn(),
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
changeContainerReceivingWarehouse: vi.fn(),
|
||||
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
@@ -52,6 +54,10 @@ function memoryStorage(): Storage {
|
||||
}
|
||||
|
||||
const bookingId = '2092873463568597001'
|
||||
const emptyReturnStatistics = { totals: { orderCount: 0, skuTypes: 0, expectedQuantity: 0, receivedQuantity: 0, remainingQuantity: 0, receivedBoxCount: 0, receivedParcelCount: 0, receivedPalletCount: 0 }, pageDimensions: [] }
|
||||
beforeEach(() => {
|
||||
vi.mocked(getReturnOrderStatistics).mockReset().mockResolvedValue(emptyReturnStatistics)
|
||||
})
|
||||
const customerId = '2092618717861048321'
|
||||
const summary = {
|
||||
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15,
|
||||
@@ -188,6 +194,26 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
}
|
||||
|
||||
describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('keeps server-wide return totals across pages and clears them on a statistics failure', async () => {
|
||||
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5, receivedBoxCount: 3, receivedParcelCount: 2, receivedPalletCount: 1 }
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 30, pages: 3 })
|
||||
vi.mocked(getReturnOrderStatistics).mockResolvedValue({ totals, pageDimensions: [] })
|
||||
const wrapper = await mountView()
|
||||
const summary = () => wrapper.find('[aria-label="退货全筛选汇总"]')
|
||||
expect(summary().text()).toContain('预报 19 件')
|
||||
expect(summary().text()).toContain('3 种 SKU')
|
||||
const table = wrapper.findComponent(DataTable)
|
||||
table.vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(getReturnOrderStatistics).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
|
||||
expect(summary().text()).toContain('实收 14 件')
|
||||
vi.mocked(getReturnOrderStatistics).mockRejectedValueOnce(new Error('统计暂不可用'))
|
||||
table.vm.$emit('update:page', 3)
|
||||
await flushPromises()
|
||||
expect(summary().exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('尾程订单加载失败')
|
||||
expect(table.props('data')).toEqual([])
|
||||
})
|
||||
it('shows commercial source alongside fulfillment type and sends source filters to the server', async () => {
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: '1', customerId: 'A', orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = await mountView()
|
||||
|
||||
@@ -14,11 +14,13 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import TransferQuantitySummary from '../components/TransferQuantitySummary.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
|
||||
import { businessOrderLink } from '../businessOrderLink'
|
||||
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { reviewCargoDiscard } from '../api/lastMileOrders'
|
||||
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
|
||||
@@ -51,6 +53,11 @@ const transferReviewOpen = ref(false)
|
||||
const transferReviewTarget = ref<LastMileOrderListItem | null>(null)
|
||||
const selectedRows = ref<LastMileOrderListItem[]>([])
|
||||
const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['INBOUND_COMPLETED', 'COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || ''))))
|
||||
const returnStatistics = ref<ReturnOrderStatistics>()
|
||||
let orderLoadRequest = 0
|
||||
function returnDimension(row: { id?: unknown; orderTypeCode?: unknown }) {
|
||||
return row.orderTypeCode === 'RETURN_RELABEL' ? returnStatistics.value?.pageDimensions.find(item => String(item.lastlegOrderId) === String(row.id)) : undefined
|
||||
}
|
||||
function openReturnPackingPlan() {
|
||||
const selected = selectedReturnRows.value
|
||||
if (!selected.length) { ElMessage.warning('请勾选已入库的退货订单'); return }
|
||||
@@ -290,11 +297,17 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 },
|
||||
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 },
|
||||
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
|
||||
{ key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'cartons', prop: 'cartons', label: '箱数', width: 80, align: 'right' },
|
||||
{ key: 'pallets', prop: 'pallets', label: '托数(退货实收)', width: 130, align: 'right', slotName: 'receiptPallets' },
|
||||
{ key: 'cartons', prop: 'cartons', label: '箱数(退货实收)', width: 130, align: 'right', slotName: 'receiptBoxes' },
|
||||
{ key: 'returnParcels', label: '退货实收包裹', width: 120, align: 'right', slotName: 'returnParcels' },
|
||||
{ key: 'returnExpected', label: '退货预报(件)', width: 120, align: 'right', slotName: 'returnExpected' },
|
||||
{ key: 'returnReceived', label: '退货实收(件)', width: 120, align: 'right', slotName: 'returnReceived' },
|
||||
{ key: 'returnRemaining', label: '退货待收(件)', width: 120, align: 'right', slotName: 'returnRemaining' },
|
||||
{ key: 'cartonDifference', prop: 'cartonDifference', label: '差异(箱)', width: 100, align: 'right', slotName: 'cartonDifference' },
|
||||
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 100, align: 'right' },
|
||||
{ key: 'quantity', prop: 'quantity', label: '数量', width: 80, align: 'right' },
|
||||
{ key: 'quantity', prop: 'quantity', label: '数量(非退货)', width: 110, align: 'right', slotName: 'nonReturnQuantity' },
|
||||
{ key: 'declaredPieces', prop: 'declaredPieces', label: '转运预报件数', width: 120, align: 'right' },
|
||||
{ key: 'receivedPieces', prop: 'receivedPieces', label: '转运实收件数', width: 120, align: 'right' },
|
||||
{ key: 'volumeWeight', prop: 'volumeWeight', label: '体积重量', width: 100, align: 'right' },
|
||||
{ key: 'portEta', prop: 'portEta', label: '预计到港时间', minWidth: 150 },
|
||||
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
|
||||
@@ -391,6 +404,8 @@ function orderQuery(): LastMileOrderQuery {
|
||||
}
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
const request = ++orderLoadRequest
|
||||
returnStatistics.value = undefined
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
@@ -416,17 +431,21 @@ async function load(model: QueryModel = filters.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await listLastMileOrders(orderQuery())
|
||||
const query = orderQuery()
|
||||
const [result, summary] = await Promise.all([listLastMileOrders(query), getReturnOrderStatistics(query)])
|
||||
if (request !== orderLoadRequest) return
|
||||
rows.value = result.records.map(toLastMileOrderListItem)
|
||||
total.value = result.total
|
||||
returnStatistics.value = summary
|
||||
} catch (error) {
|
||||
if (request !== orderLoadRequest) return
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
|
||||
? { message: error.message }
|
||||
: getApiErrorDetails(error, '尾程订单加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (request === orderLoadRequest) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,6 +1109,10 @@ onMounted(() => { void load() })
|
||||
</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
<TransferQuantitySummary v-if="!loading && statusTab !== '柜子'" :query="orderQuery()" />
|
||||
<div v-if="returnStatistics && !loading" class="return-filter-summary" role="status" aria-label="退货全筛选汇总">
|
||||
当前筛选中的退货:{{ returnStatistics.totals.orderCount }} 票 · {{ returnStatistics.totals.skuTypes }} 种 SKU · 预报 {{ returnStatistics.totals.expectedQuantity }} 件 · 实收 {{ returnStatistics.totals.receivedQuantity }} 件 · 待收 {{ returnStatistics.totals.remainingQuantity }} 件 · 签收 {{ returnStatistics.totals.receivedBoxCount }} 箱 / {{ returnStatistics.totals.receivedParcelCount }} 包裹 / {{ returnStatistics.totals.receivedPalletCount }} 托 · 预报载体数未提供
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template #notices><template v-if="loadError">
|
||||
@@ -1142,7 +1165,14 @@ onMounted(() => { void load() })
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
|
||||
<template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template>
|
||||
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
|
||||
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
|
||||
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': row.orderTypeCode !== 'RETURN_RELABEL' && typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? '—' : packageDifferenceText(row.cartonDifference) }}</span></template>
|
||||
<template #receiptPallets="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? returnDimension(row)?.receivedPalletCount ?? '—' : row.pallets }}</template>
|
||||
<template #receiptBoxes="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? returnDimension(row)?.receivedBoxCount ?? '—' : row.cartons }}</template>
|
||||
<template #returnParcels="{ row }">{{ returnDimension(row)?.receivedParcelCount ?? '—' }}</template>
|
||||
<template #returnExpected="{ row }">{{ returnDimension(row)?.expectedQuantity ?? '—' }}</template>
|
||||
<template #returnReceived="{ row }">{{ returnDimension(row)?.receivedQuantity ?? '—' }}</template>
|
||||
<template #returnRemaining="{ row }">{{ returnDimension(row)?.remainingQuantity ?? '—' }}</template>
|
||||
<template #nonReturnQuantity="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? '—' : row.quantity }}</template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable>
|
||||
</ListPageLayout></AdminPage>
|
||||
@@ -1281,6 +1311,8 @@ onMounted(() => { void load() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.return-filter-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--cc-text); background: var(--cc-surface); font-size: var(--admin-font-body); }
|
||||
.return-filter-summary > span { color: var(--cc-muted); }
|
||||
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
|
||||
.last-mile-dialog-facts th { width: var(--admin-label-width); }
|
||||
.last-mile-command-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import RelabelInventoryItemsEditor from '../components/RelabelInventoryItemsEditor.vue'
|
||||
import RelabelOrdersView from './RelabelOrdersView.vue'
|
||||
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { RelabelDetailDto } from '../api/relabelOrders'
|
||||
import { getRelabelQuantities, listRelabelInventoryCandidates, type RelabelDetailDto } from '../api/relabelOrders'
|
||||
|
||||
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), create: vi.fn(), status: vi.fn(), customers: vi.fn(), warehouses: vi.fn(), pdf: vi.fn(), upload: vi.fn(), documents: vi.fn(), complete: vi.fn(), task: vi.fn(), rework: vi.fn() }))
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
|
||||
vi.mock('../api/relabelOrders', () => ({ listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() }))
|
||||
vi.mock('../api/relabelOrders', () => ({ getRelabelQuantities: vi.fn().mockResolvedValue({orderCount:1,skuCount:2,knownLabelPieceCount:15,unknownPieceOrders:0,knownCartonCount:1,unknownCartonOrders:0,orders:{'RL-1':{cartonCount:1,labelPieceCount:15,skuCount:2}}}), listRelabelInventoryCandidates: vi.fn().mockResolvedValue([]), listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
|
||||
function detail(): RelabelDetailDto { return { application: { id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', status: 'PENDING_REVIEW', chargeAmount: 20, totalPackageCount: 1, totalFileId: '9007199254740993', totalFileName: '总换标.pdf' }, items: [{ id: '9007199254740994', skuCode: 'SKU-A', productNameZh: '商品甲', shipmentNo: 'SHIP-1', labelRequirement: '换SKU标', relabelFileId: '9007199254740995', relabelFileName: '明细换标.pdf', destinationFbaWarehouseCode: null, destinationWarehouseId: '9007199254740999', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] } }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); can.mockReturnValue(true)
|
||||
vi.mocked(listRelabelInventoryCandidates).mockResolvedValue([])
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({orderCount:1,skuCount:2,knownLabelPieceCount:15,unknownPieceOrders:0,knownCartonCount:1,unknownCartonOrders:0,orders:{'RL-1':{cartonCount:1,labelPieceCount:15,skuCount:2,duplicateCarton:false,quantityUnit:'CARTON',sourcePalletCount:null,targetPalletCount:null}}})
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
api.list.mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }], total: 1 })
|
||||
api.detail.mockImplementation(async () => detail()); api.create.mockImplementation(async () => detail()); api.status.mockResolvedValue(null)
|
||||
@@ -32,9 +35,13 @@ beforeEach(() => {
|
||||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
async function openForm(action = '新增换标单') { const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||||
async function fill(dialog: VueWrapper) {
|
||||
for (const [label, value] of [['关联订单', 'CO-1'], ['商品SKU', 'SKU-1'], ['中文品名', '商品一'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1'], ['新SKU', 'NEW-1']]) await field(dialog, label!).get('input').setValue(value!)
|
||||
for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1'], ['贴标要求', '换SKU标']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
field(dialog, '箱数').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
|
||||
for (const [label, value] of [['关联订单', 'CO-1'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1']]) await field(dialog, label!).get('input').setValue(value!)
|
||||
for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||||
field(dialog, '换标类型').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SKU_LABEL')
|
||||
await flushPromises()
|
||||
field(dialog, '贴标要求').get('input').setValue('换SKU标')
|
||||
await flushPromises()
|
||||
dialog.findComponent(RelabelInventoryItemsEditor).vm.$emit('update:items', [{ sourceInventoryBalanceId:'B1',sourceInventoryPositionId:'P1',sourceInboundOrderId:'I1',cartonMark:'BOX1',operationType:'SKU_LABEL',skuCode:'SKU-1',productNameZh:'商品一',totalPackageCount:1,notifiedPackageCount:1,shipmentNo:'SHIP-1',newSkuCode:'NEW-1',labelRequirement:'换SKU标',skuItems:[{skuCode:'SKU-1',newSkuCode:'NEW-1',quantity:8}]}])
|
||||
field(dialog, '换标费用(CNY)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||||
field(dialog, '截止时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00'); await flushPromises()
|
||||
}
|
||||
@@ -54,17 +61,17 @@ describe('native relabel operation dialogs', () => {
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), items: [{ skuCode: 'SKU-1', productNameZh: '商品一', totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: 'SHIP-1', newSkuCode: 'NEW-1', labelRequirement: '换SKU标' }] })
|
||||
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', expect.objectContaining({ customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), deliveryMethod:undefined, items:[expect.objectContaining({sourceInventoryPositionId:'P1',cartonMark:'BOX1',operationType:'SKU_LABEL',totalPackageCount:1,notifiedPackageCount:1,skuItems:[{skuCode:'SKU-1',newSkuCode:'NEW-1',quantity:8}]})] }))
|
||||
})
|
||||
it('prevents duplicate creation and retains inputs and the original creation key after failure', async () => {
|
||||
let reject!: (cause: Error) => void
|
||||
api.create.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
|
||||
const { dialog } = await openForm(); await fill(dialog)
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '商品SKU').get('input').attributes('disabled')).toBeDefined()
|
||||
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '关联订单').get('input').attributes('disabled')).toBeDefined()
|
||||
const key = api.create.mock.calls[0]![1].idempotencyKey
|
||||
reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'relabel-create-request')); await flushPromises()
|
||||
expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '商品SKU').get('input').element as HTMLInputElement).value).toBe('SKU-1')
|
||||
expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![1].idempotencyKey).toBe(key)
|
||||
})
|
||||
it('shows every original SKU as plain facts and requires a rejection reason', async () => {
|
||||
@@ -109,12 +116,13 @@ describe('native relabel operation dialogs', () => {
|
||||
let resolve!: (value: null) => void
|
||||
api.complete.mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const { wrapper } = await openDetail()
|
||||
expect(api.task).toHaveBeenCalledExactlyOnceWith('RL-1')
|
||||
await wrapper.findAll('button').find(button => button.text().includes('确认换标完成'))!.trigger('click'); await flushPromises()
|
||||
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!
|
||||
await new DOMWrapper(element).get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
|
||||
const rework = wrapper.findAll('button').find(button => button.text().includes('退回重新贴标'))!
|
||||
expect(rework.attributes('disabled')).toBeDefined(); await rework.trigger('click'); await flushPromises()
|
||||
expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).not.toHaveBeenCalled()
|
||||
expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).toHaveBeenCalledExactlyOnceWith('RL-1'); expect(api.rework).not.toHaveBeenCalled()
|
||||
resolve(null); await flushPromises()
|
||||
})
|
||||
it('keeps readonly file previews available while hiding upload and internal warehouse identifiers', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
|
||||
import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework, getRelabelQuantities, type RelabelQuantity } from '../api/relabelOrders'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -22,6 +22,10 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const detail = ref<RelabelDetailDto | null>(null)
|
||||
const quantity = ref<RelabelQuantity | null>(null)
|
||||
const currentPallets = ref<string[]>([])
|
||||
const sourceMissing = ref(false)
|
||||
const quantityMessage = ref('')
|
||||
const loading = ref(false)
|
||||
const access = useAccessPreviewStore()
|
||||
const returning = ref(false)
|
||||
@@ -76,7 +80,7 @@ watch(previewOpen, visible => { if (!visible) resetPreview() })
|
||||
watch(orderId, () => { previewOpen.value = false; resetPreview() })
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] },
|
||||
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
|
||||
{ title: '费用明细', fields: ['换标费总计', '资金异常说明'] },
|
||||
]
|
||||
@@ -107,7 +111,12 @@ const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
|
||||
商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—',
|
||||
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
|
||||
箱数: Number(value?.application.totalPackageCount || 0),
|
||||
箱数: quantity.value?.cartonCount ?? '未提供',
|
||||
贴标件数: quantity.value?.labelPieceCount ?? '未提供/不适用',
|
||||
SKU种数: quantity.value?.skuCount ?? '未提供',
|
||||
当前关联托盘: currentPallets.value.length ? `${currentPallets.value.length} 托:${currentPallets.value.join('、')}${sourceMissing.value ? '(部分来源未提供)' : ''}` : '未提供',
|
||||
本轮目标托盘: quantity.value?.targetPalletCount == null ? '未提供/不适用' : `${quantity.value.targetPalletCount} 托(本轮各任务最新绑定)`,
|
||||
原托盘快照: quantity.value?.sourcePalletCount == null ? '未提供(缺少可信快照)' : `${quantity.value.sourcePalletCount} 托(首次可信来源)`,
|
||||
预算总价: '—',
|
||||
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
|
||||
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
|
||||
@@ -119,7 +128,18 @@ const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
async function load() {
|
||||
if (!orderId.value) { loadError.value = { message: '详情缺少换标单号,请从换标单列表重新进入' }; return }
|
||||
loading.value = true; loadError.value = null
|
||||
try { detail.value = await getRelabelOrder(orderId.value) }
|
||||
try {
|
||||
detail.value = await getRelabelOrder(orderId.value)
|
||||
quantity.value = null; quantityMessage.value = ''; currentPallets.value = []; sourceMissing.value = false
|
||||
try {
|
||||
const result = await getRelabelQuantities({ page: 1, size: 1, relabelNo: orderId.value })
|
||||
quantity.value = result.orders[orderId.value] || null
|
||||
if (quantity.value?.duplicateCarton) quantityMessage.value = '该单存在重复箱唛关联,箱数已按箱唛去重,贴标件数待核查。'
|
||||
const task = await getRelabelReviewTask(orderId.value)
|
||||
currentPallets.value = [...new Set((task.items || []).map(item => item.sourcePalletCode).filter((value): value is string => Boolean(value)))]
|
||||
sourceMissing.value = (task.items || []).some(item => !item.sourcePalletCode)
|
||||
} catch { quantityMessage.value = '部分数量或托盘资料加载失败,未将缺失值显示为0。' }
|
||||
}
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -166,6 +186,7 @@ async function previewCompletionPhoto(fileId: string) {
|
||||
<LoadErrorState v-if="uploadError" title="换标PDF操作失败" :message="uploadError.message" :show-retry="false"><template #details><ApiErrorMeta :details="uploadError" /></template></LoadErrorState>
|
||||
<RelabelDocumentsPanel v-if="detail" :relabel-no="detail.application.relabelNo" :detail="detail" /><section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
|
||||
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label v-if="access.can(permission.relabelOrderCreate)" class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
|
||||
<p v-if="quantityMessage" role="status">{{ quantityMessage }}</p>
|
||||
<section class="relabel-mark-list" aria-label="箱唛换标信息">
|
||||
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
|
||||
<div class="relabel-mark-list__scroll">
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
createRelabelOrder,
|
||||
getRelabelOrder,
|
||||
listRelabelOrders,
|
||||
getRelabelQuantities,
|
||||
listRelabelWarehouseOptions,
|
||||
} from '../api/relabelOrders'
|
||||
import RelabelInventoryItemsEditor from '../components/RelabelInventoryItemsEditor.vue'
|
||||
import RelabelOrdersView from './RelabelOrdersView.vue'
|
||||
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
@@ -21,7 +23,9 @@ vi.mock('../api/relabelOrders', () => ({
|
||||
createRelabelOrder: vi.fn(),
|
||||
getRelabelOrder: vi.fn(),
|
||||
listRelabelOrders: vi.fn(),
|
||||
getRelabelQuantities: vi.fn(),
|
||||
listRelabelWarehouseOptions: vi.fn(),
|
||||
listRelabelInventoryCandidates: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -42,7 +46,26 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('RelabelOrdersView', () => {
|
||||
it('uses full-filter totals instead of the visible page and preserves unknown boxes', async () => {
|
||||
vi.mocked(listRelabelOrders).mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-PIECE', skuCodes: 'A', totalPackageCount: 100, status: 'PROCESSING' }], page: 1, size: 10, total: 45, pages: 5 })
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 45, skuCount: 12, knownLabelPieceCount: 300, unknownPieceOrders: 1, knownCartonCount: 8, unknownCartonOrders: 2, orders: { 'RL-PIECE': { labelPieceCount: 100, cartonCount: null, skuCount: 1, duplicateCarton: false, quantityUnit: 'PIECE', sourcePalletCount: null, targetPalletCount: null } } })
|
||||
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const summary = wrapper.get('[aria-label="全部筛选结果数量汇总"]')
|
||||
expect(summary.text()).toContain('45 票')
|
||||
expect(summary.text()).toContain('300 件')
|
||||
expect(summary.text()).toContain('8 箱')
|
||||
expect(wrapper.findAll('tbody tr').some(row => row.text().includes('RL-PIECE') && row.text().includes('未提供') && row.text().includes('100'))).toBe(true)
|
||||
})
|
||||
it('does not display all-unknown carton quantities as zero boxes', async () => {
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 1, knownLabelPieceCount: 100, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 1, orders: {} })
|
||||
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).toContain('箱数未提供(1票)')
|
||||
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).not.toContain('0 箱')
|
||||
})
|
||||
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 2, knownLabelPieceCount: 15, unknownPieceOrders: 0, knownCartonCount: 1, unknownCartonOrders: 0, orders: { 'RL-MULTI': { labelPieceCount: 15, cartonCount: 1, skuCount: 2, duplicateCarton: false, quantityUnit: 'CARTON', sourcePalletCount: null, targetPalletCount: null } } })
|
||||
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-MULTI', skuCodes: 'SKU-A,SKU-B', totalPackageCount: 1, status: 'PENDING_REVIEW' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-MULTI', totalPackageCount: 1 }, items: [{ id: '1', skuCode: 'SKU-A', newSkuCode: 'NEW-A', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] })
|
||||
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
@@ -64,6 +87,7 @@ describe('RelabelOrdersView', () => {
|
||||
expect(wrapper.text()).toContain('贴标要求111')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.mocked(getRelabelQuantities).mockReset().mockResolvedValue({ orderCount: 0, skuCount: 0, knownLabelPieceCount: 0, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 0, orders: {} })
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
@@ -104,18 +128,18 @@ describe('RelabelOrdersView', () => {
|
||||
const warehouse = select(wrapper, '请选择公共海外收货仓')
|
||||
warehouse.vm.$emit('update:modelValue', 'WH-1')
|
||||
formItem(wrapper, '关联订单').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'CO-1')
|
||||
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
|
||||
formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '商品一')
|
||||
formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
|
||||
formItem(wrapper, '换标费用(CNY)').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||||
formItem(wrapper, '截止时间').findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-30T10:00:00')
|
||||
formItem(wrapper, '联系人').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '张三')
|
||||
formItem(wrapper, '联系电话').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '13800000000')
|
||||
formItem(wrapper, '贴标要求').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '换SKU标')
|
||||
formItem(wrapper, '贴标要求').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '换SKU标')
|
||||
formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SHIP-1')
|
||||
formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-2')
|
||||
await flushPromises()
|
||||
|
||||
formItem(wrapper, '换标类型').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SKU_LABEL')
|
||||
await flushPromises()
|
||||
wrapper.findComponent(RelabelInventoryItemsEditor).vm.$emit('update:items', [{ sourceInventoryBalanceId: '9007199254740999', sourceInventoryPositionId: '9007199254740998', sourceInboundOrderId: '9007199254740997', skuCode: 'SKU-1', productNameZh: '商品一', cartonMark: 'BOX-1', operationType: 'SKU_LABEL', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'SHIP-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标', skuItems: [{ skuCode: 'SKU-1', newSkuCode: 'SKU-2', quantity: 8 }] }])
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(candidate => candidate.props('title') === '新增换标单')!
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
@@ -130,7 +154,7 @@ describe('RelabelOrdersView', () => {
|
||||
expect(retryKey).toBe(firstKey)
|
||||
expect(createRelabelOrder).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
customerOrderNo: 'CO-1', operationWarehouseId: 'WH-1', chargeAmount: 20, contactName: '张三', contactPhone: '13800000000', labelDeadline: '2026-08-30T10:00:00', remark: undefined, idempotencyKey: firstKey,
|
||||
items: [expect.objectContaining({ skuCode: 'SKU-1', newSkuCode: 'SKU-2', labelRequirement: '换SKU标' })],
|
||||
deliveryMethod: undefined, items: [expect.objectContaining({ sourceInventoryPositionId: '9007199254740998', cartonMark: 'BOX-1', totalPackageCount: 1, notifiedPackageCount: 1, skuItems: [{ skuCode: 'SKU-1', newSkuCode: 'SKU-2', quantity: 8 }] })],
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -15,14 +15,17 @@ 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 { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, getRelabelQuantities, type RelabelQuantityTotals, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation'
|
||||
import RelabelInventoryItemsEditor from '../components/RelabelInventoryItemsEditor.vue'
|
||||
import type { CreateRelabelItemPayload } from '../api/relabelOrders'
|
||||
import type { RelabelOrderListItem } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const rows = ref<RelabelOrderListItem[]>([])
|
||||
type RelabelRow = Omit<RelabelOrderListItem, 'cartons'> & { cartons: string; labelPieces: string; skuCount: string }
|
||||
const rows = ref<RelabelRow[]>([])
|
||||
const loading = ref(false)
|
||||
const voiding = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -33,11 +36,15 @@ const warehousesLoading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const quantities = ref<RelabelQuantityTotals | null>(null)
|
||||
const quantityError = ref('')
|
||||
function dimension(value: number | string | null | undefined) { return value == null ? '未提供' : String(value) }
|
||||
function knownDimension(value: number | string, unknown: number | string, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : unit === '托' ? '托盘数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` }
|
||||
const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
|
||||
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '待客户确认', value: 'CUSTOMER_CONFIRM_PENDING' }, { label: '已驳回', value: 'REJECTED' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已作废', value: 'CANCELLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
@@ -46,6 +53,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'sku', label: '商品SKU', minWidth: 150 },
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'labelPieces', label: '贴标件数', width: 110, align: 'right' },
|
||||
{ key: 'skuCount', label: 'SKU种数', width: 100, align: 'right' },
|
||||
{ key: 'budgetTotal', label: '预算总价', width: 120, align: 'right' },
|
||||
{ key: 'relabelCost', label: '换标费用', width: 120, align: 'right' },
|
||||
{ key: 'walletStatus', label: '资金状态', width: 120 },
|
||||
@@ -59,11 +68,16 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const submitting = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const editSourceStatus = ref('')
|
||||
const auditLabelPieces = ref('未提供')
|
||||
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
const createItems = ref<CreateRelabelItemPayload[]>([])
|
||||
const createOperation = ref<CreateRelabelItemPayload['operationType']>('CARTON_LABEL')
|
||||
const createDestination = ref('')
|
||||
const createDelivery = ref<'TRUCK' | 'EXPRESS' | 'PICKUP' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP'>('PICKUP')
|
||||
const createIdempotencyKey = ref('')
|
||||
const dialogError = ref<ApiErrorDetails | null>(null), customersError = ref<ApiErrorDetails | null>(null), warehousesError = ref<ApiErrorDetails | null>(null)
|
||||
const formLocked = computed(() => submitting.value || detailLoading.value || dialogMode.value === 'create' && (customersLoading.value || warehousesLoading.value) || !access.can(dialogMode.value === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus))
|
||||
const selectedRows = ref<RelabelOrderListItem[]>([])
|
||||
const selectedRows = ref<RelabelRow[]>([])
|
||||
const allowedStatusOptions = computed(() => {
|
||||
if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED']
|
||||
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
|
||||
@@ -79,9 +93,10 @@ function openPackingPlan() {
|
||||
void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } })
|
||||
}
|
||||
async function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
const row = source as RelabelOrderListItem | undefined
|
||||
const row = source as RelabelRow | undefined
|
||||
if (dialogOpen.value || submitting.value || detailLoading.value || !access.can(mode === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus) || mode === 'edit' && (!row || !canAudit(row))) return
|
||||
dialogError.value = null; customersError.value = null; warehousesError.value = null
|
||||
|
||||
dialogMode.value = mode
|
||||
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
editSourceStatus.value = ''
|
||||
@@ -90,6 +105,7 @@ async function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
Object.assign(form, { orderNo: String(row.orderNo || ''), relatedOrderNo: String(row.relatedOrderNo || ''), sku: String(row.sku || ''), productName: String(row.productName || ''), cartons: optionalNumber(row.cartons), budgetTotal: optionalNumber(row.budgetTotal), status: '', labelRequirement: String(row.labelRequirement || ''), relabelCost: optionalNumber(row.relabelCost), remark: String(row.remark || '') })
|
||||
}
|
||||
if (mode === 'create') {
|
||||
createItems.value = []; createOperation.value = 'CARTON_LABEL'; createDestination.value = ''; createDelivery.value = 'PICKUP'
|
||||
createIdempotencyKey.value = globalThis.crypto?.randomUUID?.() || `admin-relabel-${Date.now()}`
|
||||
warehouses.value = []
|
||||
void loadCustomers()
|
||||
@@ -105,13 +121,14 @@ async function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
relatedOrderNo: String(detail.application?.lastlegOrderNo || detail.application?.customerOrderNo || row.relatedOrderNo || ''),
|
||||
sku: [...new Set(detail.items.flatMap(relabelSkuCodes))].join('、') || row.sku || '',
|
||||
productName: [...new Set(detail.items.map(line => line.productNameZh?.trim()).filter(Boolean))].join('、') || row.productName || '',
|
||||
cartons: optionalNumber(detail.application?.totalPackageCount ?? row.cartons),
|
||||
cartons: optionalNumber(quantities.value?.orders[String(row.orderNo)]?.cartonCount),
|
||||
labelRequirement: [...new Set(detail.items.map(line => line.labelRequirement?.trim()).filter(Boolean))].join(';') || row.labelRequirement || '',
|
||||
shipmentNo: [...new Set(detail.items.map(line => line.shipmentNo?.trim()).filter(Boolean))].join('、'),
|
||||
newSkuCode: detail.items.flatMap(line => line.skuItems?.length ? line.skuItems.map(sku => `${sku.newSkuCode} × ${sku.quantity} 件`) : [line.newSkuCode || '']).filter(Boolean).join(';'),
|
||||
relabelCost: optionalNumber(detail.application?.chargeAmount ?? row.relabelCost),
|
||||
remark: String(detail.remark || detail.application?.reviewRemark || row.remark || ''),
|
||||
})
|
||||
auditLabelPieces.value = dimension(quantities.value?.orders[String(row.orderNo)]?.labelPieceCount)
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '换标单详情加载失败,请重试').message)
|
||||
dialogOpen.value = false
|
||||
@@ -157,12 +174,16 @@ async function submitForm() {
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请选择客户和公共海外收货仓'); return }
|
||||
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.relabelCost || form.relabelCost <= 0 || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
|
||||
if (!form.relatedOrderNo.trim() || !createItems.value.length || !form.relabelCost || form.relabelCost <= 0 || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim()) {
|
||||
ElMessage.warning('请完整填写换标单必填信息')
|
||||
return
|
||||
}
|
||||
const marks = createItems.value.map(item => item.cartonMark.trim())
|
||||
if (marks.some(mark => !mark) || new Set(marks).size !== marks.length) { ElMessage.warning('请填写不重复的我方箱唛'); return }
|
||||
if (createOperation.value !== 'SKU_LABEL' && (!createDestination.value.trim() || createItems.value.some(item => !item.fbaCartonMark?.trim()))) { ElMessage.warning('请填写 FBA 目的仓和每箱的新箱唛'); return }
|
||||
if (createOperation.value !== 'CARTON_LABEL' && createItems.value.some(item => !item.skuItems.length || item.skuItems.some(sku => !sku.skuCode.trim() || !sku.newSkuCode.trim() || !Number.isInteger(sku.quantity) || sku.quantity <= 0))) { ElMessage.warning('请完整填写每箱 SKU 换标及件数'); return }
|
||||
submitting.value = true
|
||||
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), operationWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
|
||||
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), operationWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, deliveryMethod: createOperation.value === 'SKU_LABEL' ? undefined : createDelivery.value, printShippingLabel: false, items: createItems.value })
|
||||
ElMessage.success('换标单已创建')
|
||||
} else {
|
||||
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
|
||||
@@ -179,21 +200,23 @@ async function submitForm() {
|
||||
}
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
|
||||
}
|
||||
|
||||
function relabelStatus(value: unknown) {
|
||||
if (value === 'REJECTED') return '已驳回'
|
||||
if (value === 'PENDING_REVIEW') return '待审核'
|
||||
if (value === 'CANCELLED') return '已作废'
|
||||
if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认'
|
||||
return relabelBusinessStatusLabel(value)
|
||||
}
|
||||
function canVoid(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
return !['READY_FOR_OUTBOUND', 'COMPLETED', 'CANCELLED'].includes(row.rawStatus || relabelStatusCode(row.status))
|
||||
}
|
||||
async function voidOrder(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
if (voiding.value || !canVoid(row) || !access.can(permission.relabelOrderStatus)) return
|
||||
voiding.value = String(row.orderNo)
|
||||
try {
|
||||
@@ -219,24 +242,32 @@ function relabelStatusCode(value: unknown) {
|
||||
}
|
||||
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || String(value || '—') }
|
||||
function canAudit(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
const row = source as RelabelRow
|
||||
return row.reviewChannel !== 'TAIL_ORDER'
|
||||
&& (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
|
||||
}
|
||||
let loadGeneration = 0
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
const current = ++loadGeneration
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
|
||||
const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }
|
||||
quantities.value = null; quantityError.value = ''
|
||||
const result = await listRelabelOrders(query)
|
||||
const measured = await getRelabelQuantities(query).catch(() => null)
|
||||
if (current !== loadGeneration) return
|
||||
quantities.value = measured
|
||||
if (!measured) quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计'
|
||||
rows.value = result.records.map((item) => ({
|
||||
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
|
||||
productName: item.productNameZh?.trim() || '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
|
||||
productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
|
||||
reviewChannel: item.reviewChannel || undefined,
|
||||
createdAt: formatOverseasDateTime(item.createdAt),
|
||||
}))
|
||||
total.value = result.total
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
} catch (error) { if (current === loadGeneration) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') } }
|
||||
finally { if (current === loadGeneration) loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
@@ -249,6 +280,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表">
|
||||
|
||||
|
||||
<section class="quantity-summary" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘:{{ knownDimension(quantities.knownSourcePalletCount ?? 0, quantities.unknownSourcePalletOrders ?? quantities.orderCount, '托') }}</span><span>本轮目标托盘:{{ knownDimension(quantities.knownTargetPalletCount ?? 0, quantities.unknownTargetPalletOrders ?? quantities.orderCount, '托') }}</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @selection-change="selectedRows = $event" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>换标单</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
@@ -269,13 +301,19 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<el-form class="density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="formLocked">
|
||||
<template v-if="dialogMode === 'create'">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="换标单生成方式"><table class="density-table"><tbody><tr><th scope="row">换标单号</th><td>系统自动生成</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="换标基础资料"><h3 class="density-dialog-section__title">订单信息</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></td><td><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择公共海外收货仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></td></tr><tr><td><el-form-item label="商品SKU" required><el-input v-model="form.sku" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入商品SKU" /></el-form-item></td><td><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></td></tr><tr><td><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></td><td><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="换标作业要求"><h3 class="density-dialog-section__title">作业要求</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></td><td><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" placeholder="请输入货件号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入新SKU" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="换标基础资料"><h3 class="density-dialog-section__title">订单信息</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></td><td><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择公共海外收货仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="换标作业要求"><h3 class="density-dialog-section__title">作业要求</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="贴标要求" required><el-input v-model="form.labelRequirement" placeholder="请输入贴标要求" /></el-form-item></td><td><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" placeholder="请输入货件号" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="换标类型与去向">
|
||||
<el-form-item label="换标类型" required><el-select v-model="createOperation" placeholder="请选择换标类型"><el-option label="只换箱唛" value="CARTON_LABEL" /><el-option label="只换 SKU" value="SKU_LABEL" /><el-option label="箱唛和 SKU 都换" value="CARTON_AND_SKU" /></el-select></el-form-item>
|
||||
<template v-if="createOperation !== 'SKU_LABEL'"><el-form-item label="FBA 目的仓" required><el-input v-model="createDestination" placeholder="填写正式 FBA 仓码" /></el-form-item><el-form-item label="配送方式" required><el-select v-model="createDelivery" placeholder="请选择配送方式"><el-option label="普通自提" value="PICKUP" /><el-option label="平台卡车" value="TRUCK" /><el-option label="快递" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></el-form-item></template>
|
||||
</section>
|
||||
<RelabelInventoryItemsEditor :customer-id="form.customerId" :warehouse-id="form.warehouseId" :operation-type="createOperation" :shipment-no="form.shipmentNo" :destination="createDestination" :requirement="form.labelRequirement" @update:items="createItems = $event" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="换标审核原始资料"><h3 class="density-dialog-section__title">原始资料</h3><table class="density-table"><tbody>
|
||||
<tr><th scope="row">关联订单</th><td colspan="3">{{ form.relatedOrderNo || '—' }}</td></tr><tr><th scope="row">商品SKU</th><td colspan="3">{{ form.sku || '—' }}</td></tr><tr><th scope="row">中文品名</th><td colspan="3">{{ form.productName || '—' }}</td></tr>
|
||||
<tr><th scope="row">箱数</th><td>{{ form.cartons ?? '—' }}</td><th scope="row">换标费用(CNY)</th><td>{{ form.relabelCost ?? '—' }}</td></tr>
|
||||
<tr><th scope="row">贴标件数</th><td colspan="3">{{ auditLabelPieces }}</td></tr>
|
||||
<tr><th scope="row">贴标要求</th><td colspan="3">{{ form.labelRequirement || '—' }}</td></tr><tr><th scope="row">货件号</th><td colspan="3">{{ form.shipmentNo || '—' }}</td></tr><tr><th scope="row">新SKU</th><td colspan="3">{{ form.newSkuCode || '—' }}</td></tr>
|
||||
</tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="换标审核结论"><h3 class="density-dialog-section__title">审核结论</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td colspan="2"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
@@ -288,6 +326,9 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; margin-bottom: 12px; }
|
||||
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
|
||||
.relabel-editor-table { table-layout: fixed; }
|
||||
.relabel-editor-table td:not([colspan]) { width: 50%; }
|
||||
@media (max-width: 720px) { .relabel-editor-table, .relabel-editor-table tbody { display: block; }.relabel-editor-table tr { display: grid; grid-template-columns: 1fr; }.relabel-editor-table td:not([colspan]) { width: auto; } }
|
||||
|
||||
@@ -57,6 +57,26 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ReturnInventoryManagementView shared list', () => {
|
||||
it('shows discard deductions in stock movements and the physical carton history', async () => {
|
||||
vi.mocked(listReturnInventoryMovements).mockResolvedValue({ records: [{ ...stock, movementType: 'DISCARD', referenceNo: 'CD-TEST-1', totalDelta: -5, availableDelta: -5, occurredAt: '2026-10-19T17:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const inventory = await mountView('inventory')
|
||||
const tabs = inventory.getComponent({ name: 'ElTabs' })
|
||||
tabs.vm.$emit('update:modelValue', 'movements'); tabs.vm.$emit('tab-change', 'movements')
|
||||
await flushPromises()
|
||||
expect(inventory.text()).toContain('货物废弃')
|
||||
expect(inventory.text()).toContain('CD-TEST-1')
|
||||
expect(inventory.text()).toContain('-5')
|
||||
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, totalQuantity: 5, items: [{ skuCode: 'SKU-TEST', quantity: 5 }], discardHistory: [{ discardNo: 'CD-TEST-1', skuCode: 'SKU-TEST', beforeQuantity: 10, quantity: 5, afterQuantity: 5, occurredAt: '2026-10-19T17:00:00' }] })
|
||||
const cartons = await mountView('cartons')
|
||||
const cartonRow = cartons.findAll('.el-table__body tr').filter(row => row.text().includes('RETURN-BOX-1')).at(-1)!
|
||||
await cartonRow.findAll('button').find(button => button.text() === '详情')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getReturnCarton).toHaveBeenCalledWith('RETURN-BOX-1')
|
||||
expect(document.body.textContent).toContain('废弃扣减明细')
|
||||
expect(document.body.textContent).toContain('扣减前(件)')
|
||||
expect(document.body.textContent).toContain('扣减后(件)')
|
||||
expect(document.body.textContent).toContain('CD-TEST-1')
|
||||
})
|
||||
it('退货库存展示服务端换标占用列', async () => {
|
||||
const wrapper = await mountView('inventory')
|
||||
const columns = wrapper.findComponent(DataTable).props('columns')
|
||||
|
||||
@@ -731,7 +731,7 @@ const tableColumns = computed(() =>
|
||||
warehouseName(row.overseasWarehouseId)
|
||||
}}</template>
|
||||
<template #movement-4="{ row }">{{
|
||||
({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库",
|
||||
({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库", DISCARD: "货物废弃",
|
||||
SKU_CONVERSION_OUT: "SKU换标", SKU_CONVERSION_IN: "SKU换标",
|
||||
SKU_CONVERSION_REVERSE_IN: "SKU换标撤销", SKU_CONVERSION_REVERSE_OUT: "SKU换标撤销"
|
||||
} as Record<string, string>)[row.movementType] || row.movementType || "—"
|
||||
@@ -928,10 +928,20 @@ const tableColumns = computed(() =>
|
||||
</el-table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="退货合箱明细">
|
||||
<h3 class="density-dialog-section__title">废弃扣减明细</h3>
|
||||
<el-table :data="detail.discardHistory || []" empty-text="暂无废弃记录">
|
||||
<el-table-column prop="occurredAt" label="操作时间" min-width="170" />
|
||||
<el-table-column prop="discardNo" label="废弃单号" min-width="210"><template #default="{ row }"><LineClampText :content="row.discardNo" /></template></el-table-column>
|
||||
<el-table-column prop="skuCode" label="SKU" min-width="170"><template #default="{ row }"><LineClampText :content="row.skuCode" /></template></el-table-column>
|
||||
<el-table-column prop="productNameZh" label="中文品名" min-width="140" />
|
||||
<el-table-column prop="beforeQuantity" label="扣减前(件)" width="110" align="right" />
|
||||
<el-table-column prop="quantity" label="废弃数量(件)" width="120" align="right" />
|
||||
<el-table-column prop="afterQuantity" label="扣减后(件)" width="110" align="right" />
|
||||
</el-table>
|
||||
<h3 class="density-dialog-section__title">合箱明细</h3>
|
||||
<el-table :data="detail.mergeHistory || []" empty-text="暂无合箱记录">
|
||||
<el-table-column prop="createdAt" label="合箱时间" min-width="170" />
|
||||
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }"><LineClampText :content="row.planNo" /></template></el-table-column>
|
||||
<el-table-column label="合箱计划" min-width="210"><template #default="{ row }"><LineClampText :content="row.planNo && row.planNo !== 'null' ? row.planNo : '现场合箱 / 历史未关联'" /></template></el-table-column>
|
||||
<el-table-column label="流向" width="80"><template #default="{ row }">{{ row.targetCartonMark === detail.cartonMark ? '合入' : '合出' }}</template></el-table-column>
|
||||
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.sourceCartonMark" /></template></el-table-column>
|
||||
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220"><template #default="{ row }"><LineClampText :content="row.targetCartonMark" /></template></el-table-column>
|
||||
|
||||
@@ -367,7 +367,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
|
||||
'actions', 'selection', 'orderNo', 'businessSource', 'businessOrderNo', 'externalBookingNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
|
||||
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
|
||||
'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
|
||||
// 本轮明确拆分退货件数/载体与转运预报/实收,保留其他原型列的相对顺序。
|
||||
'cartons', 'returnParcels', 'returnExpected', 'returnReceived', 'returnRemaining', 'cartonDifference', 'volume', 'quantity', 'declaredPieces', 'receivedPieces', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
|
||||
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
|
||||
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
|
||||
])
|
||||
@@ -376,11 +377,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
|
||||
])
|
||||
expect(columnKeys('InventoryView.vue')).toEqual([
|
||||
'actions', 'owner', 'warehouse', 'sku', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
|
||||
'actions', 'owner', 'warehouse', 'sku', 'availablePieces', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
|
||||
'inTransit', 'updatedAt',
|
||||
])
|
||||
expect(columnKeys('RelabelOrdersView.vue')).toEqual([
|
||||
'actions', 'selection', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt',
|
||||
'actions', 'selection', 'orderNo', 'sku', 'productName', 'cartons', 'labelPieces', 'skuCount', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt',
|
||||
])
|
||||
expect(columnKeys('PalletsView.vue')).toEqual([
|
||||
'actions', 'code', 'type', 'warehouse', 'volume', 'purchasePrice', 'fbaWarehouse', 'status', 'remark',
|
||||
|
||||
@@ -39,7 +39,9 @@ export interface LastMileOrderListItem {
|
||||
cartons: number | '—'
|
||||
cartonDifference: number | '—'
|
||||
volume: number | '—'
|
||||
quantity: number | '—'
|
||||
quantity: number | string
|
||||
declaredPieces?: number | string
|
||||
receivedPieces?: number | string
|
||||
volumeWeight: number | '—'
|
||||
portEta: string
|
||||
portAta: string
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin PDA 版本发布模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/pda-releases/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/PdaReleasesView.vue` 与 `api/pdaReleases.ts`;上传草稿、发布与停发都在同一页。
|
||||
- 后端归 `file/`(`PdaReleaseController`、`PdaReleaseService`、`pda-releases-v1.yaml`),接口经 `/platform/v1/files/pda-releases`;设备端更新逻辑在两端 PDA 的 `app-update` 工具里。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 国内/海外 APK、WGT 版本列表与上传 | `pages/PdaReleasesView.vue` | `api/pdaReleases.ts`;`file/` |
|
||||
| 发布与停发 | 列表行操作 | `api/pdaReleases.ts`;权限 `platform:pda-release:manage` |
|
||||
| 匿名清单与包下载(设备侧) | 不在本页 | `file/` 匿名入口,只暴露当前已发布与历史未停发文件 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 上传只创建草稿,不自动发布;发布与停发必须二次确认,并展示影响端(国内/海外)。
|
||||
- 国内与海外按 `domestic/overseas` 分开查询与操作,AppID、包名、签名与版本序列不得混用。
|
||||
- 列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。
|
||||
- 停发只影响后续下载,不追回已安装设备;强更与 WGT 安装重启由设备端策略决定,本模块不越权。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/pda-releases/<目标测试>`;接口变化同时核对 `file/` 的 `pda-releases-v1.yaml`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,28 @@
|
||||
# Admin 船期节点模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/port-schedules/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,船节点标准见 `docs/海外仓业务/船节点标准化契约.md`(工作区路径)。
|
||||
- 船节点分两类:船公司船期**查询任务**与货柜**实际节点**;本模块维护两者,订舱资料归 `../ocean-bookings/`,码头拖车归 `../container-loading/`。
|
||||
- 后端只查 `firstleg/portschedule/`(查询、日历与节点事实)与 `integration-service/`(船司原始状态适配)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 船期信息列表(组合下面两个面板) | `pages/PortSchedulesView.vue`;`port-schedules` | 页面本身只做壳层,接口在各面板 |
|
||||
| 船公司船期查询任务 | `components/CarrierScheduleQueryPanel.vue` | `api/platformCarrierScheduleQueries.ts`、`api/platformContainerShippingNodes.ts`;`integration-service/` |
|
||||
| 货柜实际节点与十节点展示 | `components/ContainerPortSchedulePanel.vue` | `api/platformPortSchedules.ts`;`firstleg/portschedule/` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 预计节点只展示,不推进实际进度;不得用预计到港替代实际节点或计划到仓。
|
||||
- 手工实际节点必须严格向后追加并防重复;已保存的人工节点不得回退柜级节点。
|
||||
- 十节点标准状态与命名按 `docs/海外仓业务/船节点标准化契约.md`,新增船司先按该契约接入标准化映射。
|
||||
- 装柜完成后的节点由服务端事件幂等创建;前端不重复追加实际船节点,也不把拖车状态当阻断门禁。
|
||||
- 船司原始状态与标准节点分离:页面只消费标准节点,不直接展示或判断船司原始字符串。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/port-schedules/<目标测试>`;节点语义变更同时核对 `firstleg` 船节点合同与标准化契约。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 船期日历模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/sailing-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/SailingCalendarView.vue`、`api/platformSailingCalendar.ts`、`types.ts`;规划背景见 `../../../docs/国内货柜履约/船期日历功能规划.md`。
|
||||
- 后端查 `firstleg/` 的船期与渠道能力;船节点实际时间归 `../port-schedules/`,本模块只管计划与模板。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 渠道、周模板与单次船期维护 | `pages/SailingCalendarView.vue` | `api/platformSailingCalendar.ts`;`firstleg/` 船期 |
|
||||
| 收货截止时间 | 日历单元格与详情 | `api/platformSailingCalendar.ts` |
|
||||
| 按渠道货量统计 | 日历侧栏 | `api/platformSailingCalendar.ts`;服务端统计 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 日历只表达计划船期,不推进货柜实际节点;实际开船、到港归 `../port-schedules/`。
|
||||
- 收货截止时间参与下单与装柜门禁,修改前确认是否已有在途订单引用该船期。
|
||||
- 渠道、周模板与单次船期三层结构不可互相覆盖:单次船期变更不回写周模板。
|
||||
- 货量统计按服务端渠道口径,不用柜量或订单数在前端换算。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/sailing-calendar/<目标测试>`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -3,7 +3,7 @@
|
||||
- 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。
|
||||
- 文档路径从工作区根起算;本目录代码路径从当前模块起算。总览设计变更查 `docs/自营业务/经营总览图表设计.md`,采集页面变更查 `docs/自营业务/数据采集页面设计.md`;纯文案或局部代码定位不默认加载设计全文。
|
||||
- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。经营总览不增加货主筛选或面板;销量、库存明细和库存周转支持按客户名称/编码选择货主,名称取 queryAsOf 时可见的完整领星资料批次,按账号+货主编码关联。
|
||||
- API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。
|
||||
- API 只经统一 `../../shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。
|
||||
- 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。
|
||||
- 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。
|
||||
- 任务编号仅用 taskNo,不以数据库 ID 回退。重试需独立 sync 权限与 allowedActions,沿 POST sync-tasks 的 RETRY 和 requestKey。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Admin 供应商模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/suppliers/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,供应商权限语义见 `../../../docs/平台客户层级与权限契约.md`。
|
||||
- 供应商是平台公共主数据(`tenant_id=0`),不按客户隔离;往来单位归 `../master-data/`,国内整柜归 `../domestic-containers/`。
|
||||
- 后端只查 `warehouse/`(公共往来单位与供应商主档)与 `file/`(资质附件)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 供应商列表、类别与范围筛选 | `pages/SuppliersView.vue`;`suppliers` | `api/platformSuppliers.ts`;`warehouse/` 供应商主档 |
|
||||
| 新增供应商 | `pages/SupplierFormView.vue`;`suppliers/new` | `api/platformSuppliers.ts` |
|
||||
| 供应商详情 | `pages/SupplierDetailView.vue`;`suppliers/:id` | `api/platformSuppliers.ts` |
|
||||
| 编辑供应商 | `pages/SupplierFormView.vue`;`suppliers/:id/edit` | `api/platformSuppliers.ts` |
|
||||
| 资质与证照附件 | `components/SupplierLicenseDialog.vue` | `file/` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 供应商查询接口固定只使用 `platform:supplier:list`;`platform:supplier:view` 必须保持停用/移除,不得为兼容旧调用重新提升。
|
||||
- 订舱代理、拖车承运商、报关代理与海外车队都从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;提交服务端供应商 ID,不提交名称或本地推断。
|
||||
- 供应商是平台公共主数据,不绑定客户;客户筛选不得缩小供应商候选。
|
||||
- 停用、改类别或改范围属高风险操作,必须确认与结果反馈;失败保留用户上下文。
|
||||
- 资质附件归 `file/` 的平台作用域,不写入客户文件路径。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/suppliers/<目标测试>`;权限码或类别枚举变更同时核对 `warehouse` 供应商合同与权限初始化 SQL。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin 拖车模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/trucking/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,订舱级拖车细则见 `../../../docs/business-index.md`。
|
||||
- 本模块只做**订舱级拖车预约**,与目的港码头拖车(`../container-loading/api/terminalDrayages.ts`)是不同生命周期;改前先确认属于哪一类。
|
||||
- 后端只查 `firstleg/` 的 `firstleg/service/TruckAppointmentService.java` 与 `firstleg/openapi/truck-appointments-v1.yaml`;附件归 `file/`。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 拖车预约列表、状态与筛选 | `pages/TruckAppointmentsView.vue`;`trucking` | `api/platformTrucking.ts`;`firstleg/service/TruckAppointmentService.java` |
|
||||
| 新建拖车预约、订舱候选 | `pages/TruckAppointmentCreateView.vue`、`components/TruckAppointmentForm.vue`;`trucking/new` | `api/platformTrucking.ts` 的 `/candidates` |
|
||||
| 预约详情、附件与承运商 | `pages/TruckAppointmentDetailView.vue`、`components/TruckAttachmentDialog.vue`;`trucking/:id` | `api/platformTrucking.ts`;附件 `file/` |
|
||||
| 集装箱号校验 | `iso6346.ts` | 本地格式校验,服务端仍复核 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商;不选择或提交客户、柜次、装柜计划或真实集装箱号。
|
||||
- 一个订舱最多一张非终态拖车单;重复创建时转入现有单继续编辑,不新建第二张。
|
||||
- 提单号和仓库由订舱冻结事实带入,表单不自行拼接或改写。
|
||||
- PDA 完成装柜不自动推进拖车状态;拖车生命周期与 PDA 任务解耦。
|
||||
- 附件为平台作用域,不重复上传提单文件;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/trucking/<目标测试>`;表单字段或状态链变更同时核对 `truck-appointments-v1.yaml`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Admin 统一订单模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/unified-orders/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,状态与申报细则见 `../../../docs/business-index.md`。
|
||||
- 三个路由(`orders`、`orders/inbound/:inboundCode`、`orders/:orderId`)共用 `pages/UnifiedOrdersView.vue`;列表、详情、弹框的区别靠路由与内部状态,不在页面外再拆。
|
||||
- 后端只查 `order/`(状态、申报、批量审核)、`firstleg/`(扣货、合柜、入库费用、智能装柜)与 `file/`;纯展示与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 订单列表、筛选、详情与商品图片 | `pages/UnifiedOrdersView.vue`;`orders`、`orders/:orderId`、`orders/inbound/:inboundCode` | `api/unifiedOrders.ts`;`order/` 的 `UnifiedOrderQueryService.java`;图片归 `file/` |
|
||||
| 批量审核预览 | 列表内批量审核弹框 | `api/unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview`;`order/service/BatchOrderReviewPreviewService.java` |
|
||||
| 客户原始申报编辑与渠道变更 | `components/OrderDeclarationEditorDialog.vue` | `api/unifiedOrders.ts`;`order/service/OrderDeclarationUpdateService.java` → `firstleg/service/OrderDeclarationSyncService.java` |
|
||||
| 履约进度与明细树 | `components/OrderFulfillmentTreePanel.vue` | `api/orderFulfillment.ts`;`firstleg/` 履约投影 |
|
||||
| 入库扣货、解除与凭证历史 | 列表与详情动作 | `../inbounds/core/api.ts`;`firstleg/service/InboundHoldService.java`、`firstleg/service/InboundHoldGuard.java`、`firstleg/service/InboundHoldEvidenceService.java`;凭证归 `file/` |
|
||||
| 合柜报关、同柜不可拆组 | `api/coLoadGroups.ts`;装柜消费 `../container-loading/` | `firstleg/loading/coload/service/ColoadGroupService.java`;整组原子分配/移除/取消 |
|
||||
| 入库费用明细与人工调整 | `components/InboundFeeDetailManager.vue` | `api/inboundFeeDetails.ts`;`firstleg/service/InboundFeeDetailService.java` |
|
||||
| 一件代发托盘打印 | 详情“托盘打印”页签 | `../inbounds/core/DropshipPalletPrintPanel.vue`;`print/` |
|
||||
|
||||
`workflows/` 存放统一订单工作流页与面板(`workflows/pages/UnifiedOrderWorkflowView.vue`、`workflows/components/UnifiedOrderWorkflowPanel.vue`、`SmartLoadingWorkbench.vue`),路由装配以 `routes.ts` 与 `../../app/router/index.ts` 为准,改前先确认是否已注册。
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 申报编辑按订单版本与货物 `itemNo` 整体提交,不允许增删货物行;只改客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实;渠道变化需二次确认。
|
||||
- Order 状态为 `SUBMITTED` 时列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。
|
||||
- 批量审核弹框一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情,不用内部 ID 作标题。
|
||||
- 扣货与解除只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`,提交当前版本、幂等键与必填原因;凭证下载只走授权入口。
|
||||
- 同柜成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户与不同目的仓港。
|
||||
- 入库费用实际、冻结与调整只显示服务端结果;计费量调整不得超出服务端允许区间。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/unified-orders/<目标测试>`;申报、合柜、扣货变更同时核对 `order`/`firstleg` 对应合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getPlatformUnifiedOrder,
|
||||
downloadPlatformProductImage,
|
||||
listPlatformUnifiedOrders,
|
||||
getPlatformUnifiedOrderStatusCounts,
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
updatePlatformUnifiedOrderDeclaration,
|
||||
updatePlatformUnifiedOrderPlanning,
|
||||
@@ -26,6 +27,14 @@ describe('unified orders API contract', () => {
|
||||
vi.mocked(http.put).mockReset()
|
||||
})
|
||||
|
||||
it('requests unpaged counts without main status and preserves unknown counts as absent', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { counts: { PENDING_REVIEW: 8, WAIT_MEASURE: 0, LOADED: -1, LOADING: null, DELIVERED: '2' } } })
|
||||
await expect(getPlatformUnifiedOrderStatusCounts({ inboundCode: 'IN-8', loaded: false, fullContainer: false, channelName: '', ...{ page: 2, size: 20, mainStatus: 'LOADING' } })).resolves.toEqual({ counts: { PENDING_REVIEW: 8, WAIT_MEASURE: 0 } })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/status-counts', {
|
||||
params: { inboundCode: 'IN-8', loaded: false, fullContainer: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads the old-system workbook with a retry-safe key', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { total: 1, succeeded: 1, failed: 0, items: [] } })
|
||||
const file = new File(['xlsx'], 'legacy.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
|
||||
@@ -11,16 +11,26 @@ export type UnifiedOrderMainStatus =
|
||||
| 'LOADED'
|
||||
| 'DELIVERED'
|
||||
| 'COMPLETED'
|
||||
| 'WAIT_WEIGHT_CONFIRM' | 'WEIGHT_CONFIRMED' | 'WAIT_CUSTOMS_DOCS' | 'EXPORT_CUSTOMS'
|
||||
| 'SAILING_CLEARANCE' | 'WAIT_UNLOAD' | 'WAIT_DISPATCH' | 'OUTBOUND_DOCUMENT' | 'DELIVERING' | 'SIGNED'
|
||||
|
||||
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量数据' },
|
||||
{ value: 'WAIT_WEIGHT_CONFIRM', label: '待客户确认' },
|
||||
{ value: 'WEIGHT_CONFIRMED', label: '已确认数据' },
|
||||
{ value: 'WAIT_CUSTOMS_DOCS', label: '待上传资料' },
|
||||
{ value: 'WAIT_PALLETIZE', label: '一件代发打托' },
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
{ value: 'LOADED', label: '装柜完成' },
|
||||
{ value: 'DELIVERED', label: '派送完成' },
|
||||
{ value: 'COMPLETED', label: '已完成' },
|
||||
{ value: 'EXPORT_CUSTOMS', label: '出口报关' },
|
||||
{ value: 'SAILING_CLEARANCE', label: '已开船/待清关' },
|
||||
{ value: 'WAIT_UNLOAD', label: '待拆柜' },
|
||||
{ value: 'WAIT_DISPATCH', label: '待排车' },
|
||||
{ value: 'OUTBOUND_DOCUMENT', label: '出库单' },
|
||||
{ value: 'DELIVERING', label: '派送中' },
|
||||
{ value: 'SIGNED', label: '签收完成' },
|
||||
]
|
||||
|
||||
export interface UnifiedOrderMeasurementSummary {
|
||||
@@ -314,6 +324,23 @@ export interface UnifiedOrderQuery {
|
||||
size: number
|
||||
}
|
||||
|
||||
export type UnifiedOrderStatusCountQuery = Omit<UnifiedOrderQuery, 'page' | 'size' | 'mainStatus'>
|
||||
export interface UnifiedOrderStatusCounts {
|
||||
counts: Partial<Record<UnifiedOrderMainStatus | 'MEASUREMENT_REVIEW', number>>
|
||||
}
|
||||
|
||||
export async function getPlatformUnifiedOrderStatusCounts(query: UnifiedOrderStatusCountQuery): Promise<UnifiedOrderStatusCounts> {
|
||||
// Strip these fields at runtime too, so callers cannot accidentally scope the other tabs.
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([key, value]) =>
|
||||
!['page', 'size', 'mainStatus'].includes(key) && value !== undefined && value !== ''))
|
||||
const response = await http.get<ApiEnvelope<UnifiedOrderStatusCounts>, ApiEnvelope<UnifiedOrderStatusCounts>>(
|
||||
`${unifiedOrdersApiPath}/status-counts`, { params },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
return { counts: Object.fromEntries(Object.entries(result.counts || {}).filter(([, count]) =>
|
||||
typeof count === 'number' && Number.isSafeInteger(count) && count >= 0)) }
|
||||
}
|
||||
|
||||
type PagePayload = Omit<UnifiedOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
|
||||
page: number | string
|
||||
size: number | string
|
||||
@@ -667,5 +694,7 @@ export async function listPlatformUnifiedOrderFeeDetails(accountId: string, inbo
|
||||
}
|
||||
|
||||
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
|
||||
if (status === 'DELIVERED') return '签收完成'
|
||||
if (status === 'COMPLETED') return '已完成'
|
||||
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || '未知状态'
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('InboundFeeDetailManager', () => {
|
||||
const dialog = document.querySelector<HTMLElement>('.el-dialog.admin-density-dialog')!
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.querySelector('.density-form')).not.toBeNull()
|
||||
expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe(action === '编辑' ? 'min(680px, calc(100vw - 28px))' : 'min(560px, calc(100vw - 28px))')
|
||||
expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe(action === '编辑' ? 'min(880px, calc(100vw - 28px))' : 'min(720px, calc(100vw - 28px))')
|
||||
if (action === '驳回') {
|
||||
const confirm = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '确认驳回')!
|
||||
confirm.click()
|
||||
|
||||
@@ -336,7 +336,7 @@ onMounted(() => void load())
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(880px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
|
||||
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="selectedFeeItem?.feeCode === privateDeliveryFeeCode" title="私人仓尾程派送费仅适用于目的仓类型为“私人仓”的订单,最终资格由服务端校验。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
|
||||
@@ -369,7 +369,7 @@ onMounted(() => void load())
|
||||
</el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" @confirm="reject">
|
||||
<AppDialog class="admin-density-dialog" v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" width="min(720px, calc(100vw - 28px))" @confirm="reject">
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="驳回原因" required><el-input v-model="rejectReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -63,6 +63,20 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
expect([...parent.querySelectorAll('.density-dialog-section__title')].map(title => title.textContent)).toEqual(['原订单资料', '订单申报资料', '货品申报明细 不允许新增、删除或调整明细序号'])
|
||||
expect(parent.style.getPropertyValue('--el-dialog-width')).toBe('min(1180px, calc(100vw - 36px))')
|
||||
expect(parent.querySelector('.density-descriptions')).not.toBeNull()
|
||||
const fields = parent.querySelector<HTMLTableElement>('table[aria-label="订单申报资料"]')!
|
||||
expect(fields.rows).toHaveLength(2)
|
||||
expect([...fields.querySelectorAll('.el-form-item__label')].map(label => label.textContent?.trim())).toEqual(['渠道名称', '渠道编码', '预估体积(m³)', '预计到货时间', '客户备注'])
|
||||
expect(fields.rows[1]!.cells[1]!.colSpan).toBe(2)
|
||||
const remark = fields.querySelector<HTMLInputElement>('input[aria-label="客户备注"]')!
|
||||
remark.value = '待保存的客户备注'
|
||||
remark.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
for (const label of ['展开全屏', '退出全屏']) {
|
||||
parent.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!.click()
|
||||
await flushPromises()
|
||||
expect(parent.querySelector<HTMLInputElement>('input[aria-label="客户备注"]')!.value).toBe('待保存的客户备注')
|
||||
expect(fields.querySelector('[aria-label="渠道编码"]')!.textContent).toBe('OLD')
|
||||
}
|
||||
parent.querySelector<HTMLButtonElement>('.cargo-table .density-action')!.click()
|
||||
await flushPromises()
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('.el-dialog.admin-density-dialog')
|
||||
@@ -97,10 +111,20 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
expect(listWarehouseLogisticsChannels).toHaveBeenCalledWith('700000000000000001')
|
||||
expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单')
|
||||
expect(wrapper.text()).toContain('RK-001')
|
||||
const volume = wrapper.findComponent({ name: 'ElInputNumber' })
|
||||
expect(volume.props()).toMatchObject({ min: 0.0001, precision: 4, controls: false, modelValue: 0.625 })
|
||||
volume.vm.$emit('update:modelValue', 0.875)
|
||||
const arrival = wrapper.findComponent({ name: 'ElDatePicker' })
|
||||
expect(arrival.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm:ss', format: 'YYYY-MM-DD HH:mm', clearable: true, modelValue: '2026-09-06T10:00:00' })
|
||||
arrival.vm.$emit('update:modelValue', '2026-09-07T11:30:00')
|
||||
const remark = wrapper.get('input[aria-label="客户备注"]')
|
||||
expect(remark.attributes('maxlength')).toBe('1000')
|
||||
await remark.setValue('调整后的客户备注')
|
||||
const channelSelect = wrapper.findAllComponents({ name: 'ElSelect' })[0]!
|
||||
channelSelect.vm.$emit('update:modelValue', 'NEW')
|
||||
channelSelect.vm.$emit('change', 'NEW')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.get('span[aria-label="渠道编码"]').text()).toBe('NEW')
|
||||
await wrapper.get('[data-testid="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -108,7 +132,9 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('纸面上的旧渠道文字不会自动变化'), '确认变更渠道', expect.any(Object))
|
||||
expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({
|
||||
expectedVersion: 7,
|
||||
estimatedVolumeCbm: 0.625,
|
||||
estimatedVolumeCbm: 0.875,
|
||||
expectedArrivalAt: '2026-09-07T11:30:00',
|
||||
customerRemark: '调整后的客户备注',
|
||||
channelCode: 'NEW',
|
||||
channelName: '新渠道',
|
||||
cargoes: [expect.objectContaining({ itemNo: 1, usage: '照明' })],
|
||||
@@ -122,7 +148,8 @@ describe('OrderDeclarationEditorDialog', () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[aria-label="渠道编码"]').attributes('readonly')).toBeDefined()
|
||||
expect(wrapper.find('input[aria-label="渠道编码"]').exists()).toBe(false)
|
||||
expect(wrapper.get('span[aria-label="渠道编码"]').text()).toBe('OLD')
|
||||
expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(1)
|
||||
expect(wrapper.find('.cargo-table').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('箱数 / 数量')
|
||||
|
||||
@@ -253,14 +253,21 @@ async function save() {
|
||||
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<h3 class="density-dialog-section__title">订单申报资料</h3>
|
||||
<el-form label-position="top" class="order-fields density-form">
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="8"><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="渠道编码"><el-input :model-value="form.channelCode" readonly aria-label="渠道编码" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="预估体积(m³)"><el-input-number v-model="form.estimatedVolumeCbm" :min="0.0001" :precision="4" :controls="false" placeholder="请输入预估体积(选填)" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" clearable /></el-form-item></el-col>
|
||||
<el-col :span="16"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit placeholder="请输入客户备注(选填)" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form label-position="left" label-width="110px" class="order-fields density-form">
|
||||
<table class="density-table density-table--editable order-declaration-fields" aria-label="订单申报资料">
|
||||
<colgroup><col style="width: 40%"><col style="width: 27%"><col style="width: 33%"></colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></td>
|
||||
<td><el-form-item label="渠道编码"><span aria-label="渠道编码">{{ form.channelCode || '—' }}</span></el-form-item></td>
|
||||
<td><el-form-item label="预估体积(m³)"><el-input-number v-model="form.estimatedVolumeCbm" :min="0.0001" :precision="4" :controls="false" aria-label="预估体积(m³)" placeholder="请输入预估体积(选填)" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" aria-label="预计到货时间" clearable /></el-form-item></td>
|
||||
<td colspan="2"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit aria-label="客户备注" placeholder="请输入客户备注(选填)" /></el-form-item></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
@@ -314,13 +321,13 @@ async function save() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-fields :deep(.el-date-editor), .order-fields :deep(.el-input) { width: 100%; }
|
||||
.order-declaration-fields { table-layout: fixed; }
|
||||
.cargo-table { width: 100%; }
|
||||
.cargo-table small, .cargo-table .cell-primary { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cargo-table small { margin-top: 3px; color: #98a2b3; }
|
||||
.cargo-editor-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-5) var(--admin-space-5); }
|
||||
.cargo-editor-grid label { display: grid; min-width: 0; gap: var(--admin-space-1); color: #667085; font-size: var(--admin-font-caption); }
|
||||
.cargo-editor-grid label.wide { grid-column: span 3; }
|
||||
.cargo-editor-grid :deep(.el-input-number), .cargo-editor-grid :deep(.el-input), .order-fields :deep(.el-select) { width: 100%; }
|
||||
.cargo-editor-grid :deep(.el-input-number), .cargo-editor-grid :deep(.el-input) { width: 100%; }
|
||||
@media (max-width: 900px) { .cargo-editor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cargo-editor-grid label.wide { grid-column: span 2; } }
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundM
|
||||
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
|
||||
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), getPlatformUnifiedOrderStatusCounts: vi.fn().mockResolvedValue({ counts: {} }), listPlatformUnifiedOrders: vi.fn(),
|
||||
unifiedOrderStatusOptions: [{ value: 'WAIT_SPLIT', label: '待分柜' }],
|
||||
}))
|
||||
vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock('@/shared/api/inboundCodeRelations', () => ({
|
||||
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
updatePlatformUnifiedOrderRemarks: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
|
||||
downloadPlatformProductImage: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), getPlatformUnifiedOrderStatusCounts: vi.fn().mockResolvedValue({ counts: {} }), listPlatformUnifiedOrders: vi.fn(),
|
||||
unifiedOrderStatusOptions: [],
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformUnifiedOrders, updatePlatformUnifiedOrderPlanning } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { getPlatformUnifiedOrderStatusCounts, listPlatformUnifiedOrders, updatePlatformUnifiedOrderPlanning } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
@@ -31,7 +31,7 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
approvePlatformUnifiedOrder: vi.fn(),
|
||||
getPlatformUnifiedOrder: vi.fn(),
|
||||
listPlatformUnifiedOrderFeeDetails: vi.fn(),
|
||||
listPlatformUnifiedOrders: vi.fn(),
|
||||
getPlatformUnifiedOrderStatusCounts: vi.fn().mockResolvedValue({ counts: {} }), listPlatformUnifiedOrders: vi.fn(),
|
||||
updatePlatformUnifiedOrderPlanning: vi.fn(),
|
||||
unifiedOrderStatusOptions: [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||
@@ -39,8 +39,8 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
{ value: 'LOADED', label: '装柜完成' },
|
||||
{ value: 'DELIVERED', label: '派送完成' },
|
||||
{ value: 'COMPLETED', label: '已完成' },
|
||||
{ value: 'DELIVERING', label: '派送中' },
|
||||
{ value: 'SIGNED', label: '签收完成' },
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -69,12 +69,70 @@ function mountView() {
|
||||
|
||||
describe('UnifiedOrdersView list request coordination', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockReset().mockResolvedValue({ counts: {} })
|
||||
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
|
||||
vi.mocked(updatePlatformUnifiedOrderPlanning).mockReset()
|
||||
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '国内仓订单', taskNo: 'EXP-1' } as never)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('shows only supported counts, keeps all text-only and never scopes counts by status or pagination', async () => {
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValue({ counts: { PENDING_REVIEW: 12, WAIT_MEASURE: 0, SIGNED: 3 } })
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
expect(tabs[0]!.text()).toBe('全部')
|
||||
expect(tabs[1]!.text()).toBe('待审核(12)')
|
||||
expect(tabs[2]!.text()).toBe('待测量数据(0)')
|
||||
expect(tabs[3]!.text()).toBe('测量数据审核')
|
||||
expect(tabs.at(-1)!.text()).toBe('签收完成(3)')
|
||||
expect(tabs.map((tab) => tab.text())).not.toContain('派送完成')
|
||||
expect(tabs.map((tab) => tab.text())).not.toContain('已完成')
|
||||
await tabs[1]!.trigger('click')
|
||||
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(getPlatformUnifiedOrderStatusCounts).toHaveBeenCalledTimes(1)
|
||||
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), inboundCode: ' IN-9 ', loaded: 'false' })
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(getPlatformUnifiedOrderStatusCounts).toHaveBeenCalledTimes(2)
|
||||
const params = vi.mocked(getPlatformUnifiedOrderStatusCounts).mock.lastCall![0]
|
||||
expect(params).toMatchObject({ inboundCode: 'IN-9', loaded: false })
|
||||
expect(params).not.toHaveProperty('mainStatus')
|
||||
expect(params).not.toHaveProperty('page')
|
||||
expect(params).not.toHaveProperty('size')
|
||||
toolbar.vm.$emit('reset')
|
||||
await flushPromises()
|
||||
expect(getPlatformUnifiedOrderStatusCounts).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('ignores older count responses and hides stale counts during loading and failures with retry', async () => {
|
||||
let resolveOld!: (value: { counts: { PENDING_REVIEW: number } }) => void
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve }))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="tablist"]').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.find('.order-status-count').exists()).toBe(false)
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValueOnce({ counts: { PENDING_REVIEW: 7 } })
|
||||
wrapper.getComponent({ name: 'QueryToolbar' }).vm.$emit('search')
|
||||
await flushPromises()
|
||||
resolveOld({ counts: { PENDING_REVIEW: 99 } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[role="tab"]')[1]!.text()).toBe('待审核(7)')
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockRejectedValueOnce(new Error('统计不可用'))
|
||||
wrapper.getComponent({ name: 'QueryToolbar' }).vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.order-status-count').exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(true)
|
||||
expect(wrapper.find('.order-status-feedback').text()).toContain('重试数量')
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValueOnce({ counts: { PENDING_REVIEW: 8, MEASUREMENT_REVIEW: 3 } })
|
||||
await wrapper.findAll('button').find((button) => button.text() === '重试数量')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[role="tab"]')[1]!.text()).toBe('待审核(8)')
|
||||
expect(wrapper.findAll('[role="tab"]')[3]!.text()).toBe('测量数据审核(3)')
|
||||
})
|
||||
|
||||
it('loads once initially and once for query, reset, status tab and pagination', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
@@ -62,9 +62,9 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('exposes the product main statuses including dropship palletizing', () => {
|
||||
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
|
||||
'待审核', '待测量数据', '一件代发打托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成',
|
||||
'待审核', '待测量数据', '待客户确认', '已确认数据', '待上传资料', '一件代发打托', '待分柜', '装柜中', '装柜完成', '出口报关', '已开船/待清关', '待拆柜', '待排车', '出库单', '派送中', '签收完成',
|
||||
])
|
||||
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
|
||||
for (const legacyLabel of ['差异处理中', '进入分柜']) {
|
||||
expect(source).not.toContain(legacyLabel)
|
||||
}
|
||||
})
|
||||
@@ -629,7 +629,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('feePanelRevision.value += 1')
|
||||
expect(source).not.toContain('<h3 class="fee-section-title">头程自动初算</h3>')
|
||||
expect(source).toContain('其他费用明细')
|
||||
expect(source).toContain('@changed="loadDetail(true)"')
|
||||
expect(source).toContain('@changed="loadDetail(true); loadStatusCounts()"')
|
||||
expect(source).toContain('firstLegTransportModeLabel(detail.order.order?.transportMode)')
|
||||
expect(source).toContain('firstLegPickupMethodLabel(detail.order.order?.pickupMethod)')
|
||||
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
getPlatformUnifiedOrderBatchApprovalPreview,
|
||||
updatePlatformUnifiedOrderRemarks,
|
||||
listPlatformUnifiedOrders,
|
||||
getPlatformUnifiedOrderStatusCounts,
|
||||
type UnifiedOrderStatusCountQuery,
|
||||
type UnifiedOrderStatusCounts,
|
||||
updatePlatformUnifiedOrderPlanning,
|
||||
unifiedOrderStatusOptions,
|
||||
type UnifiedOrderDetailResource,
|
||||
@@ -121,6 +124,10 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const statusCounts = ref<UnifiedOrderStatusCounts['counts']>({})
|
||||
const statusCountsLoading = ref(false)
|
||||
const statusCountsError = ref<ApiErrorDetails | null>(null)
|
||||
let statusCountsRequestSerial = 0
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const legacyImportOpen = ref(false)
|
||||
const legacyImportFile = ref<File | null>(null)
|
||||
@@ -714,11 +721,16 @@ function optionalBoolean(value: string | null | undefined) {
|
||||
|
||||
function selectStatus(status: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
|
||||
if (selectedTab.value === status) return
|
||||
const leavingMeasurementReview = measurementReviewSelected.value
|
||||
measurementReviewSelected.value = status === 'MEASUREMENT_REVIEW'
|
||||
if (measurementReviewSelected.value) selectedOrders.value = []
|
||||
if (status === 'MEASUREMENT_REVIEW') return
|
||||
if (status === 'MEASUREMENT_REVIEW') {
|
||||
void loadStatusCounts()
|
||||
return
|
||||
}
|
||||
selectedStatus.value = status
|
||||
if (page.value === 1) void loadOrders()
|
||||
if (leavingMeasurementReview) void loadStatusCounts()
|
||||
if (page.value === 1) void loadOrders(false)
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
@@ -786,6 +798,7 @@ async function savePlanning(row: UnifiedOrderResource) {
|
||||
row.estimatedVolumeCbm = estimatedVolumeCbm
|
||||
draft.savedExpectedArrivalAt = draft.expectedArrivalAt
|
||||
draft.savedEstimatedVolumeCbm = estimatedVolumeCbm
|
||||
void loadStatusCounts()
|
||||
ElMessage.success('订单运营预估已更新')
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '订单运营预估保存失败')
|
||||
@@ -821,6 +834,7 @@ const canEditDeclaration = computed(() => Boolean(
|
||||
))
|
||||
|
||||
async function handleDeclarationUpdated() {
|
||||
void loadStatusCounts()
|
||||
activeDetailTab.value = 'basic'
|
||||
await loadDetail(true)
|
||||
}
|
||||
@@ -1089,6 +1103,7 @@ async function updateMeasurementCalculationMode(row: Record<string, unknown> | n
|
||||
measurementSectionModes.outer = mode
|
||||
measurementLoadedKey.value = ''
|
||||
ElMessage.success(`${row ? '当前数据' : '全部测量数据'}已保存并绑定计算规则,体积已重新计算`)
|
||||
void loadStatusCounts()
|
||||
return true
|
||||
} catch (error) {
|
||||
measurementLoadedKey.value = ''
|
||||
@@ -1141,6 +1156,7 @@ async function saveWarehouseOuterDimensions(row: Record<string, unknown>) {
|
||||
if (refreshed) measurementDetail.value = refreshed
|
||||
measurementLoadedKey.value = ''
|
||||
ElMessage.success(`序号 ${row.itemNo || '—'} 的外箱规已更新,相关数值已重新计算`)
|
||||
void loadStatusCounts()
|
||||
} catch (error) {
|
||||
measurementLoadedKey.value = ''
|
||||
await loadMeasurementDetails()
|
||||
@@ -1583,7 +1599,50 @@ async function loadWarehouses() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
function orderCountFilters(): UnifiedOrderStatusCountQuery {
|
||||
const activeFilters = appliedFilters.value
|
||||
const range = activeFilters.createdRange
|
||||
return {
|
||||
tenantId: activeFilters.tenantId || undefined,
|
||||
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
|
||||
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
|
||||
inboundCode: trimmed(activeFilters.inboundCode),
|
||||
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
|
||||
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
|
||||
channelName: trimmed(activeFilters.channelName),
|
||||
containerNo: trimmed(activeFilters.containerNo),
|
||||
cabinetNo: trimmed(activeFilters.cabinetNo),
|
||||
sailingDate: activeFilters.sailingDate || undefined,
|
||||
loaded: optionalBoolean(activeFilters.loaded),
|
||||
fullContainer: optionalBoolean(activeFilters.fullContainer),
|
||||
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
|
||||
fbaCode: trimmed(activeFilters.fbaCode),
|
||||
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
|
||||
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatusCounts() {
|
||||
const serial = ++statusCountsRequestSerial
|
||||
statusCounts.value = {}
|
||||
statusCountsLoading.value = true
|
||||
statusCountsError.value = null
|
||||
try {
|
||||
const result = await getPlatformUnifiedOrderStatusCounts(orderCountFilters())
|
||||
if (serial === statusCountsRequestSerial) statusCounts.value = result.counts
|
||||
} catch (error) {
|
||||
if (serial === statusCountsRequestSerial) statusCountsError.value = getApiErrorDetails(error, '订单页签数量加载失败')
|
||||
} finally {
|
||||
if (serial === statusCountsRequestSerial) statusCountsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function tabCount(value: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
|
||||
return value ? statusCounts.value[value] : undefined
|
||||
}
|
||||
|
||||
async function loadOrders(refreshCounts = true) {
|
||||
if (refreshCounts) void loadStatusCounts()
|
||||
if (measurementReviewSelected.value) return
|
||||
selectedApprovalOrders.value = []
|
||||
selectedOrders.value = []
|
||||
@@ -1592,26 +1651,9 @@ async function loadOrders() {
|
||||
coLoadError.value = null
|
||||
Object.keys(planningDrafts).forEach((key) => delete planningDrafts[key])
|
||||
try {
|
||||
const activeFilters = appliedFilters.value
|
||||
const range = activeFilters.createdRange
|
||||
const result = await listPlatformUnifiedOrders({
|
||||
tenantId: activeFilters.tenantId || undefined,
|
||||
...orderCountFilters(),
|
||||
mainStatus: selectedStatus.value || undefined,
|
||||
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
|
||||
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
|
||||
inboundCode: trimmed(activeFilters.inboundCode),
|
||||
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
|
||||
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
|
||||
channelName: trimmed(activeFilters.channelName),
|
||||
containerNo: trimmed(activeFilters.containerNo),
|
||||
cabinetNo: trimmed(activeFilters.cabinetNo),
|
||||
sailingDate: activeFilters.sailingDate || undefined,
|
||||
loaded: optionalBoolean(activeFilters.loaded),
|
||||
fullContainer: optionalBoolean(activeFilters.fullContainer),
|
||||
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
|
||||
fbaCode: trimmed(activeFilters.fbaCode),
|
||||
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
|
||||
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
@@ -1628,17 +1670,19 @@ async function loadOrders() {
|
||||
|
||||
function search() {
|
||||
appliedFilters.value = cloneFilters(filters.value)
|
||||
void loadStatusCounts()
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
void loadOrders()
|
||||
void loadOrders(false)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filters.value = emptyFilters()
|
||||
appliedFilters.value = emptyFilters()
|
||||
if (page.value === 1) void loadOrders()
|
||||
void loadStatusCounts()
|
||||
if (page.value === 1) void loadOrders(false)
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
@@ -1806,6 +1850,7 @@ async function executeConfirmAndSendFee() {
|
||||
automaticFee.value = { feeStatus: snapshot.feeStatus, totalAmount: Number(snapshot.totalAmount || 0) }
|
||||
feePanelRevision.value += 1
|
||||
ElMessage.success('计费量和单价已确认并发送给客户')
|
||||
void loadStatusCounts()
|
||||
feeConfirmOpen.value = false
|
||||
feeConfirmAttempt = null
|
||||
await loadDetail(true)
|
||||
@@ -2298,7 +2343,7 @@ async function loadDetail(force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadOrders() })
|
||||
watch([page, pageSize], () => { void loadOrders(false) })
|
||||
watch(businessInboundCode, (value) => {
|
||||
detailAssociationRequestSerial += 1
|
||||
resolvedDetailTarget.value = null
|
||||
@@ -2321,12 +2366,16 @@ watch(detailRouteKey, (value) => {
|
||||
watch(measurementPreviewOpen, (open) => {
|
||||
if (!open) releaseMeasurementPreview()
|
||||
})
|
||||
watch(smartLoadingOpen, (open, wasOpen) => {
|
||||
if (wasOpen && !open) void loadOrders()
|
||||
})
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadWarehouses()
|
||||
if (!isDetailRoute.value) void loadOrders()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
statusCountsRequestSerial += 1
|
||||
detailAssociationRequestSerial += 1
|
||||
measurementRequestSerial += 1
|
||||
holdRequestSerial += 1
|
||||
@@ -2531,7 +2580,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
:inbound-id="normalizedInboundId"
|
||||
:destination-type="detail.summary.destinationType"
|
||||
@summary="otherFeeSummary = $event"
|
||||
@changed="loadDetail(true)"
|
||||
@changed="loadDetail(true); loadStatusCounts()"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -2728,9 +2777,9 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
|
||||
<template v-else>
|
||||
<div v-if="measurementReviewSelected" id="unified-orders-results" role="tabpanel">
|
||||
<InboundTaskList mode="review" title="订单列表">
|
||||
<InboundTaskList mode="review" title="订单列表" @changed="loadStatusCounts">
|
||||
<template #status-tabs>
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态" :aria-busy="statusCountsLoading">
|
||||
<button
|
||||
v-for="(tab, index) in statusTabs"
|
||||
:key="tab.value || 'ALL'"
|
||||
@@ -2742,7 +2791,13 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
|
||||
@click="selectStatus(tab.value)"
|
||||
@keydown="handleTabKeydown($event, index)"
|
||||
>{{ tab.label }}</button>
|
||||
><span>{{ tab.label }}</span><span v-if="tabCount(tab.value) !== undefined" class="order-status-count">({{ tabCount(tab.value) }})</span></button>
|
||||
</div>
|
||||
<div v-if="statusCountsLoading" class="order-status-feedback" role="status">页签数量加载中…</div>
|
||||
<div v-else-if="statusCountsError" class="order-status-feedback" role="status">
|
||||
<span>{{ statusCountsError.message }}</span>
|
||||
<el-button link type="primary" @click="loadStatusCounts">重试数量</el-button>
|
||||
<ApiErrorMeta :details="statusCountsError" />
|
||||
</div>
|
||||
</template>
|
||||
</InboundTaskList>
|
||||
@@ -2757,7 +2812,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
<template #toolbar-start><h1>订单列表</h1></template>
|
||||
<template #filters>
|
||||
<div class="orders-query-panel integrated-query-group">
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态" :aria-busy="statusCountsLoading">
|
||||
<button
|
||||
v-for="(tab, index) in statusTabs"
|
||||
:key="tab.value || 'ALL'"
|
||||
@@ -2769,9 +2824,15 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
|
||||
@click="selectStatus(tab.value)"
|
||||
@keydown="handleTabKeydown($event, index)"
|
||||
>{{ tab.label }}</button>
|
||||
><span>{{ tab.label }}</span><span v-if="tabCount(tab.value) !== undefined" class="order-status-count">({{ tabCount(tab.value) }})</span></button>
|
||||
</div>
|
||||
|
||||
<div v-if="statusCountsLoading" class="order-status-feedback" role="status">页签数量加载中…</div>
|
||||
<div v-else-if="statusCountsError" class="order-status-feedback" role="status">
|
||||
<span>{{ statusCountsError.message }}</span>
|
||||
<el-button link type="primary" @click="loadStatusCounts">重试数量</el-button>
|
||||
<ApiErrorMeta :details="statusCountsError" />
|
||||
</div>
|
||||
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
|
||||
<template #actions>
|
||||
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
||||
@@ -2920,7 +2981,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
</section>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" @confirm="createCoLoadGroup">
|
||||
<AppDialog class="admin-density-dialog" v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" width="min(720px, calc(100vw - 28px))" @confirm="createCoLoadGroup">
|
||||
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票须属于同一国内仓、同一渠道,并全量进入同一柜;目的仓或目的港可以不同。资格由服务端按完整入仓片段校验。`" />
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">所选入仓码</h3>
|
||||
@@ -2958,7 +3019,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
/>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="noteOpen" :title="orderDialogTitle('编辑订单备注', noteOrder?.inboundCode)" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
|
||||
<AppDialog class="admin-density-dialog" v-model="noteOpen" :title="orderDialogTitle('编辑订单备注', noteOrder?.inboundCode)" width="min(880px, calc(100vw - 28px))" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
|
||||
<el-form label-position="top" class="order-remarks-form">
|
||||
<el-form-item label="客户备注(只读)"><el-input :model-value="noteOrder?.customerRemark || '—'" disabled /></el-form-item>
|
||||
<el-form-item label="内部备注"><el-input v-model="noteForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
|
||||
@@ -2967,12 +3028,12 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="holdDialogOpen" :title="orderDialogTitle(holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货', detail?.summary.inboundCode)" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<AppDialog class="admin-density-dialog" v-model="holdDialogOpen" :title="orderDialogTitle(holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货', detail?.summary.inboundCode)" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(720px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<el-alert :title="holdAction === 'HOLD' ? '扣货后服务端将阻止后续实物履约操作。' : '解除后是否可继续履约仍以服务端状态为准。'" :type="holdAction === 'HOLD' ? 'warning' : 'info'" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><el-form-item label="关联文件(选填)"><el-upload :key="holdEvidenceUploadKey" :auto-upload="false" :limit="1" accept=".txt,.pdf,.jpg,.jpeg,.png,text/plain,application/pdf,image/jpeg,image/png" :disabled="holdSaving" :on-change="selectHoldEvidenceFile" :on-remove="removeHoldEvidenceFile"><el-button :disabled="holdSaving"><FileUp :size="15" />选择文件</el-button><template #tip><div class="el-upload__tip">仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB;服务端将校验文件真实内容。</div></template></el-upload></el-form-item><LoadErrorState v-if="holdEvidenceError" compact title="关联文件处理失败" :message="holdEvidenceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="holdEvidenceError" /></template></LoadErrorState></el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="approvalOpen" :title="orderDialogTitle('审核订单', approvalOrder?.inboundCode)" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<AppDialog class="admin-density-dialog" v-model="approvalOpen" :title="orderDialogTitle('审核订单', approvalOrder?.inboundCode)" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" width="min(720px, calc(100vw - 28px))" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<section v-if="approvalOrder" class="density-dialog-section" data-tone="summary">
|
||||
<h3 class="density-dialog-section__title">订单审核资料</h3>
|
||||
<dl class="approval-facts">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* Order-specific sections. Shared density lives in styles/adminDensity.css. */
|
||||
.unified-orders-page .order-status-count { font-variant-numeric: tabular-nums; }
|
||||
.unified-orders-page .order-status-feedback { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.unified-orders-page .inbound-fee-panel .fee-summary {
|
||||
grid-template-columns: minmax(230px, 1.3fr) repeat(4, minmax(150px, 1fr));
|
||||
border-top: 0;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Admin 仓储资源模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/warehouse-resources/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 模块按子域分目录:`locations/`(库位)、`pallets/`(托盘),各自有页面与 API;先定子域再看文件。
|
||||
- 后端分两处:国内库位/托盘归 `warehouse/`,海外库位/托盘归 `warehouse-overseas-service/`;按当前页面仓库类型判断读哪个。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 库位列表、编码、类型与启停 | `locations/pages/LocationsView.vue` | `locations/api/platformLocations.ts`;`warehouse/`(国内)、`warehouse-overseas-service/`(海外) |
|
||||
| 托盘列表、状态与批量操作 | `pallets/pages/PalletsView.vue` | `pallets/api/platformPallets.ts`;同上 |
|
||||
| 托盘/库位打印入口 | 托盘行操作 | 打印模板归 `print/` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 海外库位与托盘是公共资源(`customer_id=0`),平台接口不接收目标客户参数;资源锁仍按真实客户隔离。
|
||||
- `A0/SHARED_OVERFLOW`、`VIRTUAL` 由服务端事实保护,不能按普通库位编辑、停用或删除。
|
||||
- 可预留托盘必须 `IDLE` 且绑定库位为 `VIRTUAL + ACTIVE` 或真实库位 `IDLE`;前端只展示服务端 `reservable` 与阻断原因。
|
||||
- 托盘标不得回退到 100×100 mm 箱唛模板;模板只在 `print/` 维护。
|
||||
- 本模块只做资源主数据维护,不做排车、出库或派送的资源分配。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/warehouse-resources/<目标测试>`;接口变化同时核对对应服务的资源合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -162,7 +162,7 @@ describe('LocationsView', () => {
|
||||
it('uses the prototype-sized centered editor without a detail drawer or section cards', () => {
|
||||
expect(locationsSource).not.toContain('AppDrawer')
|
||||
expect(locationsSource).not.toContain('FormSection')
|
||||
expect(locationsSource).toContain('width="min(560px, calc(100vw - 28px))"')
|
||||
expect(locationsSource).toContain('width="min(880px, calc(100vw - 28px))"')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog .el-dialog__body { min-height: 0; overflow-y: auto;')
|
||||
expect(locationsSource).toContain('.editor-field-grid { grid-template-columns: minmax(0, 1fr); }')
|
||||
})
|
||||
|
||||
@@ -600,7 +600,7 @@ onMounted(() => {
|
||||
<AppDialog
|
||||
v-model="editorOpen"
|
||||
:title="editorMode === 'edit' && form.locationCode ? `${editorTitle} · ${form.locationCode}` : editorTitle"
|
||||
width="min(560px, calc(100vw - 28px))"
|
||||
width="min(880px, calc(100vw - 28px))"
|
||||
class="location-editor-dialog admin-density-dialog"
|
||||
:confirm-loading="saving"
|
||||
:close-on-click-modal="false"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 仓库主档模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/warehouses/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/WarehousesView.vue`、`api/platformWarehouses.ts`、`types.ts`;仓库地址、能力与状态都在同一页维护。
|
||||
- 后端唯一权威是 `warehouse/`(`PlatformWarehouseController`、`WarehouseApplicationService.java`);本页不区分国内/海外仓,类型由服务端字段决定。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 仓库列表、类型、状态与地址 | `pages/WarehousesView.vue` | `api/platformWarehouses.ts`;`warehouse/` |
|
||||
| 地址与服务能力维护 | 详情与编辑弹框 | `api/platformWarehouses.ts`;`warehouse/` |
|
||||
| 海外仓合作仓 | 同页面,类型选择 | `warehouse/PlatformWarehouseController.java` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 仓库 ID、类型、状态、地址与能力只在本模块维护;海外资源服务只保存 `warehouseId` 引用,不复制主档。
|
||||
- 仓库是平台公共主数据(`tenant_id=0`),不绑定客户;客户筛选不得缩小仓库候选。
|
||||
- 停用仓库前确认在途订单、预占与资源锁;停用不回溯历史单据。
|
||||
- 海外收货仓必须是 `OVERSEAS + ACTIVE + RECEIVING`,FBA 目的仓必须是 `FBA`,不得互相顶替。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/warehouses/<目标测试>`;字段变化同时核对 `warehouse/` 的 `warehouse-v1.yaml`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -48,6 +48,7 @@ const overseasConsumers = [
|
||||
const overseasBusinessConsumers = [
|
||||
'overseas-business/pages/PurchaseOrdersView.vue', 'overseas-business/pages/PurchaseOrderDetailView.vue',
|
||||
'overseas-business/pages/PurchaseOrderEditView.vue', 'overseas-business/pages/TransportOrdersView.vue',
|
||||
'overseas-business/pages/TruckOrderDetailView.vue',
|
||||
'overseas-business/pages/TransportOrderFormView.vue', 'overseas-business/components/ProcurementNodeActions.vue', 'overseas-business/components/ProcurementPhysicalFields.vue', 'overseas-business/components/ProcurementTransportFields.vue', 'overseas-business/components/ProcurementFinanceFields.vue', 'overseas-business/components/ProcurementChargeFields.vue', 'overseas-business/components/ProcurementControlFields.vue',
|
||||
]
|
||||
const customerServiceConsumers = [
|
||||
@@ -177,6 +178,15 @@ const cssRoot = (css: string) => {
|
||||
}
|
||||
|
||||
describe('approved Admin density ownership', () => {
|
||||
it('keeps declaration order fields on the continuous cell-editor baseline', () => {
|
||||
const source = read('modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
|
||||
const orderFields = source.slice(source.indexOf('<el-form label-position="left"'), source.indexOf('</el-form>') + '</el-form>'.length)
|
||||
expect(orderFields).toContain('class="order-fields density-form"')
|
||||
expect(orderFields).toContain('density-table density-table--editable')
|
||||
expect(orderFields).not.toMatch(/<el-(?:row|col)\b/)
|
||||
expect(orderFields).toContain('<span aria-label="渠道编码">')
|
||||
expect(orderFields).not.toContain('<el-input :model-value="form.channelCode"')
|
||||
})
|
||||
it('shares the measurement palette through explicit dialog sections without changing other dialog bodies', () => {
|
||||
const tokenValues: Record<string, string> = {}
|
||||
cssRoot(tokens).walkDecls(decl => { tokenValues[decl.prop] = decl.value })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.spec.tsx"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user