fix(dropship): generate pallet numbers atomically and extend full regressions

This commit is contained in:
2026-09-09 12:19:16 +08:00
100 changed files with 2889 additions and 234 deletions
+4 -2
View File
@@ -25,9 +25,11 @@
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | | 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | | 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 |
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 | | 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 | | 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 |
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | | 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
@@ -52,11 +54,11 @@
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 尾程订单控制与柜级入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,详情返回全柜数据;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等。单票完整入库计划进入 `InboundPlanService.java`,暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | | 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
| 海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 | | 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 |
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | | 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按“托盘中的入仓单库存”选择,合托后的同一托盘允许包含多张入仓单但冻结明细必须保持原入仓单归属。每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结;PDA 扫箱唛、照片复核完成后释放原库存,再沿既有排车出库。Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按“托盘中的入仓单库存”选择,合托后的同一托盘允许包含多张入仓单但冻结明细必须保持原入仓单归属。每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结;PDA 扫箱唛、照片复核完成后释放原库存,再沿既有排车出库。Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕,出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕,出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
+5 -2
View File
@@ -230,12 +230,15 @@ SELECT
## 海外排车、出库与派送权限 ## 海外排车、出库与派送权限
- 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。 - 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建/处理和客户回访列表/编辑十五项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。 - 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态/资料管理、异常订单列表/创建/处理和客户回访列表/编辑十六项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
- 派送七类资料上传、替换、解绑和保存统一使用 `platform:overseas-warehouse:delivery:documents`,读取和下载仍使用 `platform:overseas-warehouse:delivery:list`;默认授予 `PLATFORM_ADMIN`、`PLATFORM_CUSTOM_ADMIN`、`WAREHOUSE_OPERATOR`。
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。 - 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
- `WAREHOUSE_OPERATOR` 获得十五项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。 - `WAREHOUSE_OPERATOR` 获得十六项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
## 海外排车与出库结构升级 ## 海外排车与出库结构升级
- 派送三类资料当前基线包含 `ll_delivery_order.bol_no/documents_json` 与 `ll_delivery_document_revision`;海外固定入口和 Lastleg 模块基线同步维护这些定义。仅保存装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)三个类别,摘要按类别计数。资料历史不可变,文件对象由 File 按真实客户与派送单绑定;不按 BOL 文本复用文件,不生成旧类别迁移或清理脚本。
- 海外仓出库与派送数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。 - 海外仓出库与派送数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。
- 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。 - 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。 - 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
@@ -5088,6 +5088,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0), CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
CONSTRAINT chk_container_booking_iso6346 CHECK CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'), (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'),
CONSTRAINT chk_container_booking_pickup_code CHECK CONSTRAINT chk_container_booking_pickup_code CHECK
@@ -5311,7 +5312,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CREATE TABLE IF NOT EXISTS fl_container_document ( CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
@@ -5331,8 +5332,8 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
CONSTRAINT chk_container_document_type CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; ) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联';
CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
@@ -6096,7 +6097,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id; WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF; END IF;
END$$ END$$
@@ -6112,7 +6113,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id; WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF; END IF;
END$$ END$$
@@ -12659,7 +12660,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', '船节点异常发生与解除不可变事实'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', '船节点异常发生与解除不可变事实');
CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'); CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表');
CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划');
CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关与DO文件关联');
CALL queue_table_comment('baiyue_firstleg', 'fl_container_fee_detail', '柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_fee_detail', '柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance');
CALL queue_table_comment('baiyue_firstleg', 'fl_customer_charge_rule', '客户头程计费规则版本'); CALL queue_table_comment('baiyue_firstleg', 'fl_customer_charge_rule', '客户头程计费规则版本');
CALL queue_table_comment('baiyue_firstleg', 'fl_customer_dimension_config', '客户固定四箱规尺寸配置'); CALL queue_table_comment('baiyue_firstleg', 'fl_customer_dimension_config', '客户固定四箱规尺寸配置');
@@ -13379,7 +13380,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_name', '装柜仓库名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_name', '装柜仓库名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'container_booking_id', '货柜预订ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'container_booking_id', '货柜预订ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'created_at', '创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_id', 'File服务文件ID,不建立跨库外键'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_id', 'File服务文件ID,不建立跨库外键');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_name', '文件名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_name', '文件名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_url', '文件访问地址快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_url', '文件访问地址快照');
@@ -16242,7 +16243,7 @@ BEGIN
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
-- 货柜目的港清关与DO/PU采用轻量文件事实;出口报关由customs-v1整柜聚合唯一承载。 -- 货柜目的港清关与DO采用轻量文件事实;PU为货柜主表数字码;出口报关由customs-v1整柜聚合唯一承载。
USE baiyue_firstleg; USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column; DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column;
DELIMITER $$ DELIMITER $$
@@ -16262,6 +16263,32 @@ CALL phase3_simple_customs_add_column('completed_by_name', "VARCHAR(64) NULL COM
CALL phase3_simple_customs_add_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name"); CALL phase3_simple_customs_add_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name");
DROP PROCEDURE phase3_simple_customs_add_column; DROP PROCEDURE phase3_simple_customs_add_column;
DROP PROCEDURE IF EXISTS phase3_pickup_code_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_pickup_code_add_column(IN p_name VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking'
AND column_name=p_name) THEN
SET @ddl=CONCAT('ALTER TABLE fl_container_booking ADD COLUMN ',p_name,' ',p_definition);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase3_pickup_code_add_column('pickup_code', "VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER actual_warehouse_arrival_at");
CALL phase3_pickup_code_add_column('pickup_code_updated_by', "BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID' AFTER pickup_code");
CALL phase3_pickup_code_add_column('pickup_code_updated_by_name', "VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照' AFTER pickup_code_updated_by");
CALL phase3_pickup_code_add_column('pickup_code_updated_at', "DATETIME(3) NULL COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name");
DROP PROCEDURE phase3_pickup_code_add_column;
SET @has_pickup_code_check=(SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking'
AND constraint_name='chk_container_booking_pickup_code' AND constraint_type='CHECK');
SET @ddl=IF(@has_pickup_code_check=0,
"ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')",
'SELECT 1');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @has_simple_customs_check = (SELECT COUNT(*) FROM information_schema.table_constraints SET @has_simple_customs_check = (SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_document' WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_document'
AND constraint_name='chk_container_document_type' AND constraint_type='CHECK'); AND constraint_name='chk_container_document_type' AND constraint_type='CHECK');
@@ -16269,7 +16296,7 @@ SET @ddl = IF(@has_simple_customs_check=0, 'SELECT 1',
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type'); 'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')); CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER'));
DROP TRIGGER IF EXISTS trg_container_document_before_insert; DROP TRIGGER IF EXISTS trg_container_document_before_insert;
DELIMITER $$ DELIMITER $$
@@ -16277,7 +16304,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe
BEGIN BEGIN
DECLARE current_plan_status VARCHAR(24); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id;
IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增';
END IF; END IF;
END$$ END$$
@@ -16288,7 +16315,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe
BEGIN BEGIN
DECLARE current_plan_status VARCHAR(24); DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id;
IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改';
END IF; END IF;
END$$ END$$
@@ -17037,6 +17064,7 @@ BEGIN
OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list') OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list')
OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create') OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create')
OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list') OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list')
OR (id = 5107094 AND permission_code <> 'platform:overseas-warehouse:delivery:documents')
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel') OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list') OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list')
OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create') OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create')
@@ -17056,6 +17084,7 @@ BEGIN
OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090) OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090)
OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091) OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091)
OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092) OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092)
OR (permission_code = 'platform:overseas-warehouse:delivery:documents' AND id <> 5107094)
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093) OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070) OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070)
OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071) OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071)
@@ -17092,6 +17121,7 @@ VALUES
(5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0),
(5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0),
(5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0),
(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'ACTIVE',1,0),
(5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0),
(5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0), (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0),
(5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0) (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0)
@@ -17123,6 +17153,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit') 'platform:overseas-warehouse:customer-visit:edit')
AND permission.status='ACTIVE' AND permission.deleted=0; AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -17148,6 +17179,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list'), ('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list'),
@@ -17177,6 +17209,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit') 'platform:overseas-warehouse:customer-visit:edit')
AND expected.permission_code IS NULL; AND expected.permission_code IS NULL;
@@ -17225,10 +17258,11 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit', 'platform:overseas-warehouse:customer-visit:edit',
'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:list',
'merchant:warehouse:delivery:create')) <> 17 THEN 'merchant:warehouse:delivery:create')) <> 18 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row IF (SELECT COUNT(*) FROM iam_role_permission grant_row
@@ -17249,8 +17283,9 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN 'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
END IF; END IF;
IF EXISTS (SELECT 1 FROM iam_role role IF EXISTS (SELECT 1 FROM iam_role role
@@ -17274,15 +17309,16 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN 'platform:overseas-warehouse:customer-visit:edit')) <> 16) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整';
END IF; END IF;
SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0),
COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)
INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count
FROM tmp_phase5_delivery_role_permission; FROM tmp_phase5_delivery_role_permission;
IF v_warehouse_operator_permission_count <> 15 IF v_warehouse_operator_permission_count <> 16
OR v_warehouse_tally_permission_count <> 5 THEN OR v_warehouse_tally_permission_count <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
END IF; END IF;
@@ -17304,6 +17340,7 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) THEN 'platform:overseas-warehouse:customer-visit:edit')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
+19 -1
View File
@@ -1181,7 +1181,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号', dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号',
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
waybill_no VARCHAR(128) NULL COMMENT '承运运单号', delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商', waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识',
documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照',
delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商',
pickup_method VARCHAR(64) NULL COMMENT '提货方式', loading_type VARCHAR(64) NULL COMMENT '装车类型', vehicle_type VARCHAR(64) NULL COMMENT '车辆类型', pickup_method VARCHAR(64) NULL COMMENT '提货方式', loading_type VARCHAR(64) NULL COMMENT '装车类型', vehicle_type VARCHAR(64) NULL COMMENT '车辆类型',
vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机或车队名称', vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机或车队名称',
driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', appointment_account VARCHAR(128) NULL COMMENT '预约账号', seller_name VARCHAR(128) NULL COMMENT '卖家姓名', driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', appointment_account VARCHAR(128) NULL COMMENT '预约账号', seller_name VARCHAR(128) NULL COMMENT '卖家姓名',
@@ -1202,6 +1205,21 @@ CREATE TABLE IF NOT EXISTS ll_delivery_fee (
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id) created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
) ENGINE=InnoDB COMMENT='派送单费用明细'; ) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (
id BIGINT NOT NULL COMMENT '派送资料历史ID',
customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键',
delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
version BIGINT NOT NULL COMMENT '保存成功时派送单版本',
idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希',
snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照',
operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID',
created_at DATETIME(3) NOT NULL COMMENT '资料保存时间',
PRIMARY KEY(id),
UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key),
UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version)
) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录';
CREATE TABLE IF NOT EXISTS ll_delivery_operation ( CREATE TABLE IF NOT EXISTS ll_delivery_operation (
id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称',
+2 -1
View File
@@ -19,9 +19,10 @@
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | | 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 派送单三类资料 | `src/main/resources/openapi/delivery-files-v1.yaml`;依次为装车照片 `DELIVERY_LOADING_PHOTO`、BOL文件(含签名)`DELIVERY_BOL`、签收文件(POD签收/快递签收)`DELIVERY_RECEIPT`,按客户、派送内部ID、类别隔离,任意格式单文件50MiB;Lastleg持有分类绑定、保存、替换删除和历史可见性,按非空类别计数x/3,不强制必传或识别签名;File保留不可变对象与真实createdAt |
| 下游 OCR | `client/WeightOcrClient.java` | | 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
| 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` | | 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` |
@@ -0,0 +1,32 @@
# 派送资料 File 交付及打印 BOL 核查
范围:测试工作区当前 test 分支;File/Print。未执行部署、真实 HTTP 或数据库操作。
## 能力矩阵
| 能力 | 状态与证据 |
|---|---|
| 三类上传 | 按序 LOADING_PHOTO→DELIVERY_LOADING_PHOTO(装车照片)、BOL→DELIVERY_BOL(BOL文件(含签名))、RECEIPT→DELIVERY_RECEIPT(签收文件(POD签收/快递签收));任意格式,非空且每文件≤52428800字节 |
| 所有权与元数据 | 复用 InternalBusinessFileService:tenantId=真实客户>0、businessId=deliveryOrderId内部Long十进制字符串、businessType和fileId精确过滤;同时只读ACTIVE且未删除对象 |
| 上传时间 | Metadata增加真实持久化createdAt,LocalDateTime无偏移;已有其他通用业务文件OpenAPI同步字段 |
| 下载 | 内部服务令牌必需,复合归属查询成功后读取真实对象;attachment、UTF-8文件名、no-store |
| 替换与删除 | 新上传产生独立对象并保留旧内容;当前/历史引用、原子保存、版本和可见性归Lastleg。File未新增物理删除入口 |
| 类别计数 | Lastleg每类0至20文件,totalCategoryCount=3,计数按已绑定非空类别x/3,不按文件数计数、不要求全部必传;无新增完成门禁、签名识别或电子签名;File不复制绑定或状态 |
| 数据库 | 现有business_type VARCHAR(32)、business_id VARCHAR(64)、created_at足够,无结构变更或SQL执行 |
## 打印 BOL 现状
判定:Print通用渲染能力已实现,派送BOL业务打印链路未实现/待业务契约确认,未作环境验收。
- `print/src/main/java/com/baiyue/print/controller/InternalPdfController.java` 提供内部 inbound-marks、freight-a4-documents 两个入口,校验内部令牌,返回真实PDF、页数、Content-Disposition和no-store。
- `print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java` 仅定义海运托运书和提单确认件A4模板;没有派送BOL专用模板。
- `print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java` 实际生成字节并用PDFBox解析页数、尺寸与业务字段。
- 本轮定向检索 `lastleg-service/src/main/java/com/baiyue/lastleg/delivery/{controller,service}` 未发现调用Print的派送BOL生成链;当前BOL号和三类资料绑定不能视为生成打印能力。已上传BOL的鉴权下载也不等于生成派送BOL。
- 派送专属打印权限、状态门禁、字段映射、模板是否复用和输出入口尚不能由现有File/Print证明,本次未新增打印方案。其余五个按钮由Admin/后端16核查。
## 验证
- 2026-09-09 11:21(UTC+8),`mvn -pl file -am test`:BUILD SUCCESS,File 195项,0失败/错误/跳过。DeliveryBusinessFileTest 14项覆盖三类各自不可变上传、真实时间、50MiB边界和完整查询隔离,以及五个已移除类型的拒绝;DeliveryFileContractTest 1项锁定顺序、名称、映射及运行枚举一致性。仅本轮三类结果作为当前验收证据。
- `mvn -pl print -am -Dtest=FreightA4PdfServiceGenerationTest,InternalPdfControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`:10项通过,含PDFBox真实解析。没有把它作为派送业务打印已实现的证据。
- AGENTS索引合同未通过:现有柜费分摊金额守恒、整组操作契约两项索引缺失;不属于本次File变更。
- Lastleg绑定/替换删除后的用户可见性需由后端16专项测试及后续获准环境验收确认。
@@ -7,8 +7,11 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory { enum BusinessFileCategory {
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OCEAN_BOOKING_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), OCEAN_BOOKING_DOCUMENT(false, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -50,6 +53,9 @@ enum BusinessFileCategory {
} }
void validateOwner(long tenantId) { void validateOwner(long tenantId) {
if (this == OCEAN_BOOKING_DOCUMENT && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "订舱附件必须归属平台");
}
if (customerOwned && tenantId <= 0) { if (customerOwned && tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户"); throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
} }
@@ -84,10 +84,11 @@ public class InternalBusinessFileService {
private static Metadata metadata(FileObjectEntity value) { private static Metadata metadata(FileObjectEntity value) {
return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER", return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER",
value.getTenantId(), value.getBusinessType(), value.getBusinessId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes()); value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes(), value.getCreatedAt());
} }
public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {} String fileName, String sha256, String contentType, Long sizeBytes,
LocalDateTime createdAt) {}
public record Download(String fileName, String contentType, byte[] content) {} public record Download(String fileName, String contentType, byte[] content) {}
} }
@@ -118,6 +118,7 @@ components:
sha256: {type: string} sha256: {type: string}
contentType: {type: string} contentType: {type: string}
sizeBytes: {type: integer, format: int64, maximum: 52428800} sizeBytes: {type: integer, format: int64, maximum: 52428800}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
ExportBatchRequest: ExportBatchRequest:
type: object type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
@@ -0,0 +1,102 @@
openapi: 3.0.3
info:
title: 派送单三类资料 File 内部契约
version: 1.0.0
description: >-
tenantId为派送单所属客户,businessId为deliveryOrderId内部Long的十进制字符串,不是派送单号或BOL号。
任意格式,非空单文件最多50MiB(52428800字节)。每类0至20文件由Lastleg控制,三类不强制必传。
totalCategoryCount固定3,计数为非空类别x/3,不是文件数;不增加完成门禁或签名识别。
上传只创建不可变对象,不自动绑定。Lastleg原子保存分类绑定与版本,替换/删除仅解绑并保留历史证据,
用户下载前由Lastleg检查当前引用或获准历史引用和权限,再调用File精确归属下载。不得按BOL号跨客户复用。
x-category-mapping:
LOADING_PHOTO: DELIVERY_LOADING_PHOTO
BOL: DELIVERY_BOL
RECEIPT: DELIVERY_RECEIPT
x-category-titles:
LOADING_PHOTO: 装车照片
BOL: BOL文件(含签名)
RECEIPT: 签收文件(POD签收/快递签收)
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/Token'}
- {$ref: '#/components/parameters/Tenant'}
- {$ref: '#/components/parameters/Type'}
- {$ref: '#/components/parameters/Business'}
post:
summary: 上传派送资料不可变对象
parameters:
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200': {$ref: '#/components/responses/Metadata'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/Token'}
- {$ref: '#/components/parameters/Tenant'}
- {$ref: '#/components/parameters/Type'}
- {$ref: '#/components/parameters/Business'}
- {$ref: '#/components/parameters/File'}
get:
summary: 校验客户、派送单、类别和文件ID,读取有效未删除文件元数据
responses:
'200': {$ref: '#/components/responses/Metadata'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/Token'}
- {$ref: '#/components/parameters/Tenant'}
- {$ref: '#/components/parameters/Type'}
- {$ref: '#/components/parameters/Business'}
- {$ref: '#/components/parameters/File'}
get:
summary: 按完整归属下载;内部令牌必需,不提供匿名下载
responses:
'200':
description: 二进制成功响应;失败按统一JSON code判断
headers:
Cache-Control: {schema: {type: string, enum: [no-store]}}
Content-Disposition: {schema: {type: string}, description: attachment及UTF-8原文件名}
components:
parameters:
Token: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
Tenant: {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Business: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
File: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Type:
name: businessType
in: path
required: true
schema:
type: string
enum: [DELIVERY_LOADING_PHOTO, DELIVERY_BOL, DELIVERY_RECEIPT]
responses:
Metadata:
description: 业务成功/失败使用HTTP200,成功code为字符串200;无效内部令牌拒绝
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
properties:
fileId: {type: string}
ownerScope: {type: string, enum: [CUSTOMER]}
tenantId: {type: string}
businessType: {type: string}
businessId: {type: string}
fileName: {type: string}
sha256: {type: string}
contentType: {type: string}
sizeBytes: {type: integer, format: int64, maximum: 52428800}
createdAt: {type: string, description: 持久化created_at原值,LocalDateTime格式yyyy-MM-ddTHH:mm:ss,无时区偏移}
@@ -3,9 +3,9 @@ info:
title: Ocean Booking Document Internal API title: Ocean Booking Document Internal API
version: 1.0.0 version: 1.0.0
description: >- description: >-
订舱文件归属订舱聚合,不归属拖车。固定ownerScope=CUSTOMER、tenantId=订舱customerId、 订舱文件归属订舱聚合,不归属拖车。固定ownerScope=PLATFORM、tenantId=0、
businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与 businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与
BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。 BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。客户为可选快照,有值、为空或清空均不改变文件归属,客户作用域上传读取一律拒绝。
x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING] x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]
x-lifecycle-owner: FIRSTLEG x-lifecycle-owner: FIRSTLEG
x-file-object-semantics: IMMUTABLE_CREATE_ONLY x-file-object-semantics: IMMUTABLE_CREATE_ONLY
@@ -70,8 +70,8 @@ components:
name: tenantId name: tenantId
in: path in: path
required: true required: true
description: 订舱customerId description: 平台固定0,与订舱客户快照无关
schema: {$ref: '#/components/schemas/Id'} schema: {type: string, enum: ['0']}
OceanBookingId: OceanBookingId:
name: oceanBookingId name: oceanBookingId
in: path in: path
@@ -100,10 +100,11 @@ components:
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
properties: properties:
fileId: {$ref: '#/components/schemas/Id'} fileId: {$ref: '#/components/schemas/Id'}
ownerScope: {type: string, enum: [CUSTOMER]} ownerScope: {type: string, enum: [PLATFORM]}
tenantId: tenantId:
allOf: [{$ref: '#/components/schemas/Id'}] type: string
description: 订舱customerId enum: ['0']
description: 平台固定0,与订舱客户快照无关
businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]} businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]}
businessId: businessId:
allOf: [{$ref: '#/components/schemas/Id'}] allOf: [{$ref: '#/components/schemas/Id'}]
@@ -112,3 +113,4 @@ components:
sha256: {type: string, pattern: '^[0-9a-f]{64}$'} sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string} contentType: {type: string}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 52428800} sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 52428800}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
@@ -134,3 +134,4 @@ components:
sha256: {type: string, pattern: '^[0-9a-f]{64}$'} sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]} contentType: {type: string, enum: [application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 5242880} sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 5242880}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
@@ -107,3 +107,4 @@ components:
sha256: {type: string, pattern: '^[0-9a-f]{64}$'} sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string} contentType: {type: string}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
@@ -9,13 +9,13 @@ import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingFileContractTest { class OceanBookingFileContractTest {
@Test @Test
void freezesInternalCustomerOwnedBookingDocumentContract() throws Exception { void freezesInternalPlatformOwnedBookingDocumentContract() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-booking-file-v1.yaml")); String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-booking-file-v1.yaml"));
assertThat(yaml) assertThat(yaml)
.contains("/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:") .contains("/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:")
.contains("ownerScope: {type: string, enum: [CUSTOMER]}") .contains("ownerScope: {type: string, enum: [PLATFORM]}")
.contains("description: 订舱customerId") .contains("description: 平台固定0,与订舱客户快照无关")
.contains("description: oceanBookingId") .contains("description: oceanBookingId")
.contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]") .contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]")
.contains("x-lifecycle-owner: FIRSTLEG") .contains("x-lifecycle-owner: FIRSTLEG")
@@ -24,7 +24,7 @@ class InternalBusinessFileControllerTest {
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() { @Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes()); var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes());
var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT", var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT",
"41", "clearance.pdf", "hash", "application/pdf", file.getSize()); "41", "clearance.pdf", "hash", "application/pdf", file.getSize(), java.time.LocalDateTime.of(2026, 9, 9, 12, 0));
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata); when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata); when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
@@ -59,11 +59,11 @@ class InternalBusinessFileControllerTest {
@Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() { @Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() {
String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx"; String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx";
when(service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 93L)) when(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 93L))
.thenReturn(new InternalBusinessFileService.Download(fileName, .thenReturn(new InternalBusinessFileService.Download(fileName,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4})); "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4}));
var response = controller.download("internal-token", 10001L, var response = controller.download("internal-token", 0L,
"OCEAN_BOOKING_DOCUMENT", "61", 93L); "OCEAN_BOOKING_DOCUMENT", "61", 93L);
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment"); assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
@@ -0,0 +1,98 @@
package com.baiyue.file.service;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import com.baiyue.file.controller.InternalBusinessFileController;
import com.baiyue.common.core.exception.BizException;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class DeliveryBusinessFileTest {
@TempDir Path root;
@BeforeAll static void metadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "delivery-files"), FileObjectEntity.class);
}
@ParameterizedTest
@ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"})
void immutableUploadsPreserveOldContentAndRealTimestamp(String type) {
var storage = storage();
var files = mock(FileObjectMapper.class);
var service = service(files, storage);
var ids = new AtomicLong(800);
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
FileObjectEntity row = call.getArgument(0); row.setId(ids.incrementAndGet()); return 1;
});
var first = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{0,1,2}));
var second = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{3,4}));
var captor = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
verify(files, times(2)).insert(captor.capture());
assertThat(first.fileId()).isNotEqualTo(second.fileId());
assertThat(first.createdAt()).isEqualTo(captor.getAllValues().get(0).getCreatedAt()).isNotNull();
assertThat(first.ownerScope()).isEqualTo("CUSTOMER");
assertThat(storage.read(captor.getAllValues().get(0).getObjectKey())).containsExactly(0,1,2);
assertThat(storage.read(captor.getAllValues().get(1).getObjectKey())).containsExactly(3,4);
verify(files, never()).updateById(any(FileObjectEntity.class));
}
@ParameterizedTest
@ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"})
void enforcesFiftyMibAndNonemptyBoundary(String type) throws Exception {
var storage = storage(); var category = BusinessFileCategory.require(type);
assertThatThrownBy(() -> storage.store(11,"71",new MockMultipartFile("file",new byte[0]),category)).isInstanceOf(BizException.class);
var file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false); when(file.getSize()).thenReturn(52428801L);
assertThatThrownBy(() -> storage.store(11,"71",file,category)).isInstanceOf(BizException.class).hasMessageContaining("50MB");
when(file.getSize()).thenReturn(52428800L); when(file.getContentType()).thenReturn("application/x-custom");
when(file.getOriginalFilename()).thenReturn("boundary.custom");
doAnswer(call -> { try (var out=new java.io.RandomAccessFile(((Path)call.getArgument(0)).toFile(),"rw")) { out.setLength(52428800L); } return null; }).when(file).transferTo(any(Path.class));
var stored=storage.store(11,"71",file,category);
assertThat(java.nio.file.Files.size(root.resolve(stored.objectKey()))).isEqualTo(52428800L);
}
@ParameterizedTest
@ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"})
void downloadQueriesEveryOwnershipKeyAndRejectsUnauthorizedAccess(String type) {
var files=mock(FileObjectMapper.class); var storage=mock(BusinessFileStorage.class); var service=service(files,storage);
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query=call.getArgument(0);
assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted");
assertThat(query.getParamNameValuePairs().values()).containsExactlyInAnyOrder(12L,type,"72",802L,"ACTIVE",false);
return null;
});
assertThatThrownBy(() -> service.download(12,type,"72",802)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(0,type,"71",801)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(11,type,"not-an-id",801)).isInstanceOf(BizException.class);
var controller=new InternalBusinessFileController();
ReflectionTestUtils.setField(controller,"expected","test-internal"); ReflectionTestUtils.setField(controller,"service",service);
assertThatThrownBy(() -> controller.download("bad",11,type,"71",801)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
@ParameterizedTest
@ValueSource(strings={"DELIVERY_CUSTOMER_DOCUMENT","DELIVERY_BILL_OF_LADING","DELIVERY_POD","DELIVERY_PHOTO","DELIVERY_EXPRESS_RECEIPT"})
void rejectsRemovedCategoriesWithoutAliases(String type) {
assertThatThrownBy(() -> BusinessFileCategory.require(type))
.isInstanceOf(BizException.class).hasMessageContaining("不支持的附件业务类型");
}
private BusinessFileStorage storage() {
var storage=new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root); storage.initializeRoot(); return storage;
}
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service=new InternalBusinessFileService(); ReflectionTestUtils.setField(service,"files",files); ReflectionTestUtils.setField(service,"storage",storage); return service;
}
}
@@ -0,0 +1,35 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DeliveryFileContractTest {
@Test
@SuppressWarnings("unchecked")
void freezesThreeOrderedCategoriesAndMatchesRuntimePolicy() throws Exception {
String text = Files.readString(Path.of("src/main/resources/openapi/delivery-files-v1.yaml"));
Map<String, Object> yaml = new Yaml().load(text);
Map<String, Object> info = (Map<String, Object>) yaml.get("info");
Map<String, String> mapping = (Map<String, String>) info.get("x-category-mapping");
assertThat(mapping.keySet()).containsExactly("LOADING_PHOTO", "BOL", "RECEIPT");
List<String> types = List.of("DELIVERY_LOADING_PHOTO", "DELIVERY_BOL", "DELIVERY_RECEIPT");
assertThat(mapping.values()).containsExactlyElementsOf(types);
Map<String, String> titles = (Map<String, String>) info.get("x-category-titles");
assertThat(titles.keySet()).containsExactlyElementsOf(mapping.keySet());
assertThat(titles.values()).containsExactly("装车照片", "BOL文件(含签名)", "签收文件(POD签收/快递签收)");
Map<String, Object> components = (Map<String, Object>) yaml.get("components");
Map<String, Object> parameters = (Map<String, Object>) components.get("parameters");
Map<String, Object> type = (Map<String, Object>) parameters.get("Type");
Map<String, Object> schema = (Map<String, Object>) type.get("schema");
assertThat((List<String>) schema.get("enum")).containsExactlyElementsOf(types);
assertThat(Arrays.stream(BusinessFileCategory.values()).map(Enum::name)
.filter(name -> name.startsWith("DELIVERY_")).toList()).containsExactlyElementsOf(types);
assertThat(text).contains("totalCategoryCount固定3", "非空类别x/3", "每类0至20", "不强制必传", "不增加完成门禁或签名识别");
}
}
@@ -75,46 +75,61 @@ class InternalBusinessFileServiceTest {
verify(storage).store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); verify(storage).store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO);
} }
@Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() { @Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx", MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes()); "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes());
when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking", .thenReturn(new BusinessFileStorage.Stored("business-files/0/61/booking",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
file.getSize(), "f".repeat(64), "booking.xlsx")); file.getSize(), "f".repeat(64), "booking.xlsx"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(111L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(111L); return 1;
}); });
var metadata = service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file); var metadata = service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file);
assertThat(metadata).extracting(InternalBusinessFileService.Metadata::ownerScope, assertThat(metadata).extracting(InternalBusinessFileService.Metadata::ownerScope,
InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId) InternalBusinessFileService.Metadata::businessId)
.containsExactly("CUSTOMER", 10001L, "OCEAN_BOOKING_DOCUMENT", "61"); .containsExactly("PLATFORM", 0L, "OCEAN_BOOKING_DOCUMENT", "61");
assertThatThrownBy(() -> service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)) assertThatThrownBy(() -> service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("明确客户"); .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
} }
@Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() { @Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() {
FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(10001L); FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L);
value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61"); value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61");
value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/10001/61/booking"); value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/0/61/booking");
value.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); value.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
value.setSha256("f".repeat(64)); value.setSizeBytes(8L); value.setSha256("f".repeat(64)); value.setSizeBytes(8L);
when(files.selectOne(any())).thenReturn(value, null, null, null); when(files.selectOne(any())).thenReturn(value, null, null);
assertThat(service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()) assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope())
.isEqualTo("CUSTOMER"); .isEqualTo("PLATFORM");
assertThatThrownBy(() -> service.metadata(10002L, "OCEAN_BOOKING_DOCUMENT", "61", 111L)) assertThatThrownBy(() -> service.metadata(10002L, "OCEAN_BOOKING_DOCUMENT", "61", 111L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
assertThatThrownBy(() -> service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "62", 111L)) assertThatThrownBy(() -> service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "62", 111L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L)) assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
} }
@Test void bookingFileScopeRemainsPlatformRegardlessOfOptionalCustomerSnapshot() {
FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L);
value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61");
value.setOriginalName("booking.bin"); value.setObjectKey("business-files/0/61/object");
when(files.selectOne(any())).thenReturn(value);
when(storage.read(value.getObjectKey())).thenReturn(new byte[]{1, 2});
// Customer snapshot is deliberately absent from File commands before and after clearing it.
assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()).isEqualTo("PLATFORM");
assertThat(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).content()).containsExactly(1, 2);
assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).fileId()).isEqualTo(111L);
assertThatThrownBy(() -> service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L))
.isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
verify(storage, times(1)).read(value.getObjectKey());
}
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()); MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes());
assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)) assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file))
@@ -269,7 +284,7 @@ class InternalBusinessFileServiceTest {
@Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception { @Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "booking.bin", MockMultipartFile file = new MockMultipartFile("file", "booking.bin",
"application/x-booking", new byte[]{1, 2, 3}); "application/x-booking", new byte[]{1, 2, 3});
when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object", .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object",
"application/x-booking", 3, "a".repeat(64), "booking.bin")); "application/x-booking", 3, "a".repeat(64), "booking.bin"));
AtomicLong ids = new AtomicLong(200); AtomicLong ids = new AtomicLong(200);
@@ -280,9 +295,9 @@ class InternalBusinessFileServiceTest {
var executor = Executors.newFixedThreadPool(2); var executor = Executors.newFixedThreadPool(2);
try { try {
var firstFuture = executor.submit(() -> var firstFuture = executor.submit(() ->
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
var secondFuture = executor.submit(() -> var secondFuture = executor.submit(() ->
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
var first = firstFuture.get(); var first = firstFuture.get();
var second = secondFuture.get(); var second = secondFuture.get();
@@ -81,6 +81,11 @@ public class BusinessFeeSourceEventService {
} }
if ("VOIDED".equals(source.action())) if ("VOIDED".equals(source.action()))
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
List<Map<String, Object>> versions = jdbc.queryForList(
"SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE",
system, source.sourceType(), feeId);
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion()))
throw error("FIN-EVT-008", "费用来源版本已过期");
requireSourceNotFrozen(system, source.sourceType(), feeId); requireSourceNotFrozen(system, source.sourceType(), feeId);
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'", jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
system, source.sourceType(), feeId); system, source.sourceType(), feeId);
@@ -115,6 +120,11 @@ public class BusinessFeeSourceEventService {
} }
positive(value.sourceFeeId(), "sourceFeeId"); positive(value.sourceFeeId(), "sourceFeeId");
positive(value.customerId(), "customerId"); positive(value.customerId(), "customerId");
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType()))
throw error("FIN-EVT-012", "海外柜费来源不一致");
positive(value.containerBookingId(), "containerBookingId");
}
if (!"VOIDED".equals(value.action()) && (!"PUBLISHED".equals(value.publicationStatus()) if (!"VOIDED".equals(value.action()) && (!"PUBLISHED".equals(value.publicationStatus())
|| !"ACTIVE".equals(value.sourceStatus()))) { || !"ACTIVE".equals(value.sourceStatus()))) {
throw error("FIN-EVT-015", "费用来源尚未发布或已失效"); throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
@@ -125,7 +135,7 @@ public class BusinessFeeSourceEventService {
if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name())) if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name()))
throw error("FIN-EVT-012", "应付来源供应商快照不一致"); throw error("FIN-EVT-012", "应付来源供应商快照不一致");
validateSettlement(value); validateSettlement(value);
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) } else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|| !blank(value.settlementMode()) || value.paidAt() != null || !blank(value.settlementMode()) || value.paidAt() != null
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo()) || !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) { || (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
@@ -169,7 +179,7 @@ public class BusinessFeeSourceEventService {
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(), positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()), system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()),
number(value.inboundId()), number(value.containerId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"), number(value.inboundId()), number(value.containerBookingId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"),
value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(), value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(),
json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo()); json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo());
} }
@@ -81,6 +81,12 @@ public class FinanceFeeSourceEventService {
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
} }
List<Map<String, Object>> versions = jdbc.queryForList(
"SELECT source_fee_version FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? FOR UPDATE",
snapshot.sourceType(), snapshot.feeId());
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > snapshot.feeVersion())) {
throw error("FIN-EVT-008", "费用来源版本已过期");
}
requireSourceNotFrozen(snapshot.sourceType(), snapshot.feeId()); requireSourceNotFrozen(snapshot.sourceType(), snapshot.feeId());
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='AVAILABLE'", jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
snapshot.sourceType(), snapshot.feeId()); snapshot.sourceType(), snapshot.feeId());
@@ -18,6 +18,10 @@ import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.node.ObjectNode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -82,4 +86,155 @@ class BusinessFeeSourceEventServiceTest {
null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null, null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null,
node, payloadHash); node, payloadHash);
} }
@Test
void overseasDirectPaidCostUsesPhysicalContainerAndKeepsSourceIdentity() {
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
when(suppliers.requireActive(71L, null)).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event)).isEqualTo(901L);
ArgumentCaptor<Object[]> source = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), source.capture());
assertThat(source.getValue()[0]).isEqualTo("LASTLEG");
assertThat(source.getValue()[4]).isEqualTo("OVERSEAS_CONTAINER_FEE");
assertThat(source.getValue()[14]).isEqualTo(41L);
assertThat(source.getValue()[15]).isEqualTo(61L);
ArgumentCaptor<Object[]> cost = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_cost"), cost.capture());
assertThat(cost.getValue()[9]).isEqualTo(61L);
}
@Test
void rejectsForeignCurrencyAndMissingPhysicalContainerBeforeWriting() {
assertThatThrownBy(() -> service.consume(containerEvent("USD", "61", "PUBLISHED", 1)))
.isInstanceOf(com.baiyue.finance.service.FinanceEventException.class);
assertThatThrownBy(() -> service.consume(containerEvent("CNY", null, "PUBLISHED", 1)))
.isInstanceOf(com.baiyue.finance.service.FinanceEventException.class);
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void lateOlderVersionCannotSupersedeNewerSourceOrCost() {
when(suppliers.requireActive(71L, null)).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"),
eq("OVERSEAS_CONTAINER_FEE"), eq(701L)))
.thenReturn(List.of(Map.of("source_fee_version", 2)));
assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1)))
.hasMessageContaining("版本已过期");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
}
@Test
void replayReturnsOriginalSourceWithoutDuplicatingCost() {
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
when(suppliers.requireActive(71L, null)).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId())))
.thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L)));
assertThat(service.consume(event)).isEqualTo(901L);
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
private EventEnvelope containerEvent(String currency, String containerBookingId, String action, int version) {
EventEnvelope base = eventWithoutInbound();
ObjectNode payload = base.payload().deepCopy();
payload.put("sourceType", "OVERSEAS_CONTAINER_FEE");
payload.put("businessType", "OVERSEAS_CONTAINER");
payload.put("containerId", "41");
payload.put("containerBookingId", containerBookingId);
payload.put("currency", currency);
payload.put("direction", "PAYABLE");
payload.put("supplierId", "71");
payload.put("supplierName", "供应商");
payload.put("settlementMode", "DIRECT_PAID");
payload.set("paidAt", json.valueToTree(Instant.parse("2026-09-02T08:00:00Z")));
payload.put("paymentMethod", "BANK_TRANSFER");
payload.put("paymentReferenceNo", "PAY-1");
payload.put("sourceVersion", version);
payload.putNull("action");
payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", action);
return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(),
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", version,
null, base.traceId(), "LASTLEG:FEE:701:" + version, null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@Test
void sameEventIdWithDifferentPayloadIsRejectedWithoutWrites() {
var event = payableEvent("PUBLISHED", 1);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId())))
.thenReturn(List.of(Map.of("payload_hash", "different", "status", "PROCESSED", "result_resource_id", 901L)));
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("同一事件ID");
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@Test
void sameSourceVersionWithDifferentHashCannotOverwriteCost() {
var event = payableEvent("PUBLISHED", 1);
existingVersion("different", "AVAILABLE");
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("同一来源版本");
noFinancialWrites();
}
@Test
void voidedSourceCannotBeRevivedByPublicationReplay() {
var event = payableEvent("PUBLISHED", 1);
existingVersion(event.payload().get("sourceHash").asText(), "VOIDED");
assertThat(service.consume(event)).isEqualTo(901L);
noFinancialWrites();
}
@Test
void voidTargetsOnlyMatchingOverseasCostVersion() {
var event = payableEvent("VOIDED", 1);
existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE");
assertThat(service.consume(event)).isEqualTo(901L);
verify(jdbc).update(startsWith("UPDATE fin_cost SET status='VOIDED'"),
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1));
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
}
@Test
void frozenSourceRejectsAdjustmentWithoutReplacingSourceOrCost() {
var event = payableEvent("ADJUSTED", 2);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"),
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L))).thenReturn(1);
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("禁止调整或替代");
noFinancialWrites();
}
@Test
void frozenSourceRejectsVoidWithoutChangingSourceOrCost() {
var event = payableEvent("VOIDED", 1);
existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE");
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"),
eq(Integer.class), eq(901L))).thenReturn(1);
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("禁止作废");
noFinancialWrites();
}
private EventEnvelope payableEvent(String action, int version) {
when(suppliers.requireActive(71L, null)).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
return containerEvent("CNY", "61", action, version);
}
private void existingVersion(String hash, String status) {
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"),
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1)))
.thenReturn(List.of(Map.of("id", 901L, "source_hash", hash, "status", status)));
}
private void noFinancialWrites() {
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
}
} }
@@ -284,6 +284,19 @@ class FinanceFeeSourceEventServiceTest {
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L); when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L);
} }
@Test
void lateOlderVersionDoesNotReplaceNewerSource() {
givenNewEvent();
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(
ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("CONTAINER_FEE"), eq(31L)))
.thenReturn(List.of(Map.of("source_fee_version", 2)));
assertThatThrownBy(() -> service.consume(containerEvent("late-version", "FIRSTLEG_FEE_PUBLISHED", 1, 7)))
.hasMessageContaining("版本已过期");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
}
private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) { private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) {
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload("21", null, "31", version)); return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload("21", null, "31", version));
} }
+2 -1
View File
@@ -24,7 +24,8 @@
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 对同柜同客户有效成员聚合实际计费量命中一个阶梯,再按成员计费量守恒分摊;装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | | 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | | 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
@@ -92,6 +92,15 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username())); return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/pallets/create-and-bind")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> createAndBind(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move") @PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@@ -15,6 +15,9 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {} public record ClaimRequest(@Positive long warehouseId) {}
public record CreateAndBindRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE } public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId, public record CountCartonRequest(@Positive long warehouseId,
@@ -13,6 +13,11 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */ /** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper @Mapper
public interface DropshipPalletizationMapper { public interface DropshipPalletizationMapper {
@Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE")
Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
@Insert(""" @Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at) (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
@@ -34,6 +34,7 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
@Service @Service
public class OceanBookingDocumentService { public class OceanBookingDocumentService {
public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT"; public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT";
private static final long FILE_TENANT_ID = 0L;
private static final Set<String> DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING"); private static final Set<String> DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING");
@Resource private OceanBookingRepository repository; @Resource private OceanBookingRepository repository;
@@ -54,7 +55,7 @@ public class OceanBookingDocumentService {
OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion); OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion);
OceanBookingFileClient.Metadata metadata; OceanBookingFileClient.Metadata metadata;
try { try {
var response = files.upload(internalToken, operatorId, booking.getCustomerId(), BUSINESS_TYPE, var response = files.upload(internalToken, operatorId, FILE_TENANT_ID, BUSINESS_TYPE,
Long.toString(oceanBookingId), file); Long.toString(oceanBookingId), file);
if (response == null || !response.successful() || response.data() == null) { if (response == null || !response.successful() || response.data() == null) {
throw bad("订舱文件上传失败"); throw bad("订舱文件上传失败");
@@ -115,7 +116,7 @@ public class OceanBookingDocumentService {
} }
metadata(booking, document.getFileId()); metadata(booking, document.getFileId());
try { try {
ResponseEntity<byte[]> response = files.download(internalToken, booking.getCustomerId(), BUSINESS_TYPE, ResponseEntity<byte[]> response = files.download(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
Long.toString(oceanBookingId), document.getFileId()); Long.toString(oceanBookingId), document.getFileId());
byte[] content = response.getBody(); byte[] content = response.getBody();
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) { if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) {
@@ -132,9 +133,6 @@ public class OceanBookingDocumentService {
} }
public void requireActiveValidDocument(OceanBookingEntity booking) { public void requireActiveValidDocument(OceanBookingEntity booking) {
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
throw conflict("订舱缺少客户归属,无法校验文件");
}
boolean valid = false; boolean valid = false;
for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) { for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) {
if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue; if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue;
@@ -178,9 +176,6 @@ public class OceanBookingDocumentService {
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent(); if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent();
if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读"); if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读");
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
throw bad("请先保存订舱客户后再上传文件");
}
return booking; return booking;
} }
@@ -192,7 +187,7 @@ public class OceanBookingDocumentService {
private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) { private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) {
try { try {
var response = files.metadata(internalToken, booking.getCustomerId(), BUSINESS_TYPE, var response = files.metadata(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
Long.toString(booking.getId()), fileId); Long.toString(booking.getId()), fileId);
if (response == null || !response.successful() || response.data() == null) { if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配"); throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
@@ -207,7 +202,7 @@ public class OceanBookingDocumentService {
} }
private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) { private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) {
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), booking.getCustomerId()) if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), FILE_TENANT_ID)
|| !BUSINESS_TYPE.equals(metadata.businessType()) || !BUSINESS_TYPE.equals(metadata.businessType())
|| !Long.toString(booking.getId()).equals(metadata.businessId())) { || !Long.toString(booking.getId()).equals(metadata.businessId())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配"); throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配");
@@ -50,6 +50,7 @@ public class OceanBookingService {
@Resource private WarehousePartnerClient partners; @Resource private WarehousePartnerClient partners;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private OceanBookingDocumentService documents; @Resource private OceanBookingDocumentService documents;
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -200,7 +201,7 @@ public class OceanBookingService {
private void validateDraftStructure(DraftData data) { private void validateDraftStructure(DraftData data) {
boolean hasCustomerId = data.customerId() != null; boolean hasCustomerId = data.customerId() != null;
boolean hasCustomerName = StringUtils.hasText(data.customerName()); boolean hasCustomerName = StringUtils.hasText(data.customerName());
if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空"); if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
if (data.parties() != null) { if (data.parties() != null) {
Set<String> types = new HashSet<>(); Set<String> types = new HashSet<>();
for (PartyRequest party : data.parties()) { for (PartyRequest party : data.parties()) {
@@ -221,8 +222,7 @@ public class OceanBookingService {
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties, private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> requirements) { List<OceanBookingContainerRequirementEntity> requirements) {
if (booking.getCustomerId() == null || !StringUtils.hasText(booking.getCustomerName()) if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|| !StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
@@ -258,8 +258,9 @@ public class OceanBookingService {
} }
private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName, private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName,
String carrierCode, String bookingAgentName) {} String carrierCode, String bookingAgentName, String customerName) {}
private ResolvedDraft resolveDraft(DraftData d) { private ResolvedDraft resolveDraft(DraftData d) {
String customerName = resolveCustomer(d.customerId(), d.customerName());
String warehouseName = null; String channelName = null; String warehouseName = null; String channelName = null;
if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) { if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId());
@@ -269,10 +270,10 @@ public class OceanBookingService {
var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode()); var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode());
var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode()); var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode());
return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(), return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(),
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name()); carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name(), customerName);
} }
private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) { private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) {
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); v.setCustomerId(d.customerId()); v.setCustomerName(resolved.customerName());
v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName()); v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName());
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
v.setBillOfLadingNo(trim(d.billOfLadingNo())); v.setBillOfLadingNo(trim(d.billOfLadingNo()));
@@ -288,10 +289,20 @@ public class OceanBookingService {
} }
private void validateMasterData(OceanBookingEntity booking) { private void validateMasterData(OceanBookingEntity booking) {
resolveCustomer(booking.getCustomerId(), booking.getCustomerName());
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode()); resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode()); resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode());
} }
private String resolveCustomer(Long id, String name) {
if (id == null) {
if (StringUtils.hasText(name)) throw badRequest("客户名称不能脱离客户ID提交");
return null;
}
if (id <= 0) throw badRequest("请选择有效客户");
return customers.active(id, name).tenantName();
}
private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) { private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) {
if (id == null) { if (id == null) {
if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交"); if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交");
@@ -84,7 +84,7 @@ public class ContainerFeeDetailService {
SaveRequest common = request.fee(); SaveRequest common = request.fee();
FeeItemEntity item = requireItem(common.feeItemId(), common.direction()); FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP); BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
BigDecimal allocated = request.allocations().stream().map(Allocation::amount) BigDecimal allocated = request.allocations().stream().map(value -> allocationAmount(value.amount()))
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额"); if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
Map<InboundAllocationKey, Long> ownership = new HashMap<>(); Map<InboundAllocationKey, Long> ownership = new HashMap<>();
@@ -158,7 +158,7 @@ public class ContainerFeeDetailService {
throw conflict("只有完整的当前已审核分摊组可以调整"); throw conflict("只有完整的当前已审核分摊组可以调整");
Map<Long, BigDecimal> amounts = new HashMap<>(); Map<Long, BigDecimal> amounts = new HashMap<>();
for (AllocationAmount allocation : request.allocations()) { for (AllocationAmount allocation : request.allocations()) {
if (amounts.put(allocation.feeId(), allocation.amount().setScale(2, RoundingMode.HALF_UP)) != null) if (amounts.put(allocation.feeId(), allocationAmount(allocation.amount())) != null)
throw conflict("分摊费用行不得重复"); throw conflict("分摊费用行不得重复");
} }
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet()))) if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
@@ -372,6 +372,12 @@ public class ContainerFeeDetailService {
return new BizException(ResultCode.CONFLICT, message); return new BizException(ResultCode.CONFLICT, message);
} }
private static BigDecimal allocationAmount(BigDecimal amount) {
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_UP);
if (rounded.signum() <= 0) throw conflict("分摊金额按两位小数取整后必须大于0");
return rounded;
}
private static long containerBookingId(ContainerFeeDetailEntity value) { private static long containerBookingId(ContainerFeeDetailEntity value) {
if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) { if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) {
throw conflict("柜次费用缺少真实货柜关联"); throw conflict("柜次费用缺少真实货柜关联");
@@ -21,6 +21,30 @@ import java.util.*;
@Service @Service
public class DropshipPalletizationService { public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper; @Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Transactional
public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){
if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务");
TaskRow task=requireTask(taskId,request.warehouseId());
if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
String operation=operation(request.operationId());
PalletRow replay=mapper.palletByOpenOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())
||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务");
return view(task);
}
if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘");
int expected=mapper.unboundCount(taskId);
if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码");
long palletId=IdWorker.getId();
String palletNo=documentNumbers.next("DP");
mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName);
int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName);
if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
return view(requireTask(taskId,request.warehouseId()));
}
public void createTask(InboundOrderEntity inbound) { public void createTask(InboundOrderEntity inbound) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
@@ -23,7 +23,7 @@ public final class TerminalDrayageDtos {
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion, public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull LocalDateTime actualAt, @NotNull LocalDateTime actualAt,
@Size(max = 500) String reason) {} @Size(max = 500) String remark) {}
public record Warning(String status, LocalDateTime deadlineAt, public record Warning(String status, LocalDateTime deadlineAt,
long overdueHours, long overdueDays) {} long overdueHours, long overdueDays) {}
@@ -147,14 +147,14 @@ public class TerminalDrayageService {
throw badRequest("实际提柜时间不能早于当前实际船节点时间"); throw badRequest("实际提柜时间不能早于当前实际船节点时间");
} }
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
request.reason(), operatorId, operatorName); request.remark(), operatorId, operatorName);
int fromVersion = version(value); int fromVersion = version(value);
String fromStatus = status(context, value); String fromStatus = status(context, value);
value.setActualPickupAt(request.actualAt()); value.setActualPickupAt(request.actualAt());
applyAuthoritativeDeadline(context, value); applyAuthoritativeDeadline(context, value);
persist(value, fromVersion, operatorId, operatorName); persist(value, fromVersion, operatorId, operatorName);
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
fromVersion, request.reason(), operatorId, operatorName); fromVersion, request.remark(), operatorId, operatorName);
} }
@Transactional @Transactional
@@ -174,14 +174,14 @@ public class TerminalDrayageService {
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key, advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
request.reason(), operatorId, operatorName); request.remark(), operatorId, operatorName);
int fromVersion = version(value); int fromVersion = version(value);
String fromStatus = status(context, value); String fromStatus = status(context, value);
applyAuthoritativeDeadline(context, value); applyAuthoritativeDeadline(context, value);
value.setActualEmptyReturnAt(request.actualAt()); value.setActualEmptyReturnAt(request.actualAt());
persist(value, fromVersion, operatorId, operatorName); persist(value, fromVersion, operatorId, operatorName);
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
fromVersion, request.reason(), operatorId, operatorName); fromVersion, request.remark(), operatorId, operatorName);
} }
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt, private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
@@ -1,45 +1,371 @@
openapi: 3.0.3 openapi: 3.0.3
info: { title: Firstleg Container Fee Allocation API, version: 1.0.0 } info:
title: Firstleg Container Fee API
version: 1.0.0
description: |
本契约只生产国内段费用来源(订舱、国内拖车、出口报关、海运等)。
loadingPlanId 由服务端解析到同一真实 containerBookingId;海外段费用由 Lastleg 的 OVERSEAS_CONTAINER_FEE 独立维护。
审核通过后发布不可变来源版本到 Finance,不复制海外费用或财务状态。
应付必须选择有效供应商;DIRECT_PAID 是已付快照,不再进入待结算。
当前币种仅支持 CNY,不进行跨币种加总。公共费用按柜内真实客户与订单分摊,
各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。
成功和业务错误均使用 HTTP 200 包装,业务冲突 code 为 A0409。
security: [{bearerAuth: []}]
paths: paths:
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details:
get:
operationId: listContainerFees
summary: 查询当前客户的国内段柜级费用
x-permission: firstleg:inbound:fee-detail:view
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/AccountId'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/FeeListResponse'}
post:
operationId: createContainerFee
summary: 创建单一业务归属的国内段柜级费用
x-permission: firstleg:inbound:fee-detail:edit
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/AccountId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/FeeResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups: /api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups:
post: post:
summary: 原子创建柜级公共费用分摊组 operationId: createContainerFeeAllocationGroup
description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。 summary: 原子创建国内段柜级公共费用分摊组
security: [{ bearerAuth: [] }] x-permission: firstleg:inbound:fee-detail:edit
parameters: [{ $ref: '#/components/parameters/LoadingPlanId' }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AllocationGroupRequest' } } } }
responses: { '200': { description: 按客户和业务归属生成的待审核费用行 }, '409': { description: 分摊合计或柜内真实归属不一致 } }
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/{action}:
post:
summary: 整组审核、驳回、调整或作废柜级分摊费用
description: action 仅为 approve、reject、adjust、void;单行接口拒绝处理分摊组,避免部分成功。
security: [{ bearerAuth: [] }]
parameters: parameters:
- { $ref: '#/components/parameters/LoadingPlanId' } - {$ref: '#/components/parameters/LoadingPlanId'}
- { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } } requestBody:
- { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } } required: true
responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } } content:
application/json:
schema: {$ref: '#/components/schemas/AllocationGroupRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/FeeListResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/approve:
post:
operationId: approveContainerFeeAllocationGroup
summary: 整组审核国内段柜级费用
x-permission: firstleg:inbound:fee-detail:review
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/GroupId'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/FeeListResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/reject:
post:
operationId: rejectContainerFeeAllocationGroup
summary: 整组驳回国内段柜级费用
x-permission: firstleg:inbound:fee-detail:review
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/GroupId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/RejectRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/FeeListResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/adjust:
post:
operationId: adjustContainerFeeAllocationGroup
summary: 整组调整国内段柜级费用
x-permission: firstleg:inbound:fee-detail:edit
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/GroupId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AllocationGroupAdjustRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/FeeListResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/void:
post:
operationId: voidContainerFeeAllocationGroup
summary: 整组作废国内段柜级费用
x-permission: firstleg:inbound:fee-detail:review
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/GroupId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/VersionRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/EmptyResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/approve:
post:
operationId: approveContainerFee
summary: 审核单行国内段柜级费用
x-permission: firstleg:inbound:fee-detail:review
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/FeeId'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/EmptyResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/reject:
post:
operationId: rejectContainerFee
summary: 驳回单行国内段柜级费用
x-permission: firstleg:inbound:fee-detail:review
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/FeeId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/RejectRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/EmptyResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:
post:
operationId: adjustContainerFee
summary: 调整单行国内段柜级费用
x-permission: firstleg:inbound:fee-detail:edit
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/FeeId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AdjustRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/FeeResponse'}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/void:
post:
operationId: voidContainerFee
summary: 作废单行国内段柜级费用
x-permission: firstleg:inbound:fee-detail:review
parameters:
- {$ref: '#/components/parameters/LoadingPlanId'}
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/FeeId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/VersionRequest'}
responses:
'200':
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
content:
application/json:
schema: {$ref: '#/components/schemas/EmptyResponse'}
components: components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters: parameters:
LoadingPlanId: { in: path, name: loadingPlanId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } } LoadingPlanId: {in: path, name: loadingPlanId, required: true, schema: {$ref: '#/components/schemas/Id'}}
GroupId: {in: path, name: groupId, required: true, schema: {$ref: '#/components/schemas/Id'}}
FeeId: {in: path, name: feeId, required: true, schema: {$ref: '#/components/schemas/Id'}}
AccountId: {in: query, name: accountId, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 平台选择的客户,服务端校验柜内归属}
schemas: schemas:
Id: {type: string, pattern: '^[1-9][0-9]{0,19}$'}
Amount: {type: number, format: decimal, minimum: 0, description: HALF_UP 保留两位小数}
SaveRequest:
type: object
required: [feeItemId, direction, businessDate, amount, currency]
properties:
feeItemId: {$ref: '#/components/schemas/Id'}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
businessDate: {type: string, format: date}
amount: {$ref: '#/components/schemas/Amount'}
currency: {type: string, enum: [CNY]}
supplierId: {type: string, nullable: true, description: PAYABLE 必填,必须为启用供应商}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅 PAYABLE 必填}
paidAt: {type: string, format: date-time, nullable: true}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
paymentReferenceNo: {type: string, maxLength: 128, nullable: true}
paymentVoucherFileIds:
type: array
maxItems: 20
nullable: true
items: {$ref: '#/components/schemas/Id'}
remark: {type: string, maxLength: 500, nullable: true}
description: DIRECT_PAID 必须填写付款时间、方式及流水号或当前客户凭证;RECEIVABLE 和 ON_ACCOUNT 不允许残留付款字段。
Allocation:
type: object
required: [customerId, orderId, orderNo, amount]
properties:
customerId: {$ref: '#/components/schemas/Id'}
orderId: {$ref: '#/components/schemas/Id'}
orderNo: {type: string, minLength: 1, maxLength: 64}
inboundId: {type: string, nullable: true}
inboundCode: {type: string, maxLength: 64, nullable: true}
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
description: 客户和订单必须属于当前柜;入仓ID与入仓码同时填写或同时留空。
AllocationGroupRequest: AllocationGroupRequest:
type: object type: object
required: [fee, allocations] required: [fee, allocations]
properties: properties:
fee: { type: object, description: 公共费用字段;amount为原始柜级总额 } fee: {$ref: '#/components/schemas/SaveRequest'}
allocations: allocations:
type: array type: array
minItems: 1 minItems: 1
maxItems: 200 maxItems: 200
items: items: {$ref: '#/components/schemas/Allocation'}
type: object AdjustRequest:
required: [customerId, orderId, orderNo, amount] type: object
properties: required: [expectedSourceVersion, businessDate, amount]
customerId: { type: string } properties:
orderId: { type: string } expectedSourceVersion: {type: integer, minimum: 1}
orderNo: { type: string, maxLength: 64 } businessDate: {type: string, format: date}
inboundId: { type: string, nullable: true } amount: {$ref: '#/components/schemas/Amount'}
inboundCode: { type: string, maxLength: 64, nullable: true } description: 只允许调整当前已审核有效版本,成功递增来源版本并重新待审核。
amount: { type: number, format: decimal, exclusiveMinimum: 0 } AllocationAmount:
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } type: object
required: [feeId, amount]
properties:
feeId: {$ref: '#/components/schemas/Id'}
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
AllocationGroupAdjustRequest:
type: object
required: [expectedSourceVersion, businessDate, totalAmount, allocations]
properties:
expectedSourceVersion: {type: integer, minimum: 1}
businessDate: {type: string, format: date}
totalAmount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
allocations:
type: array
minItems: 1
maxItems: 200
items: {$ref: '#/components/schemas/AllocationAmount'}
description: 必须包含整组全部费用行,逐行取两位后的合计与总额一致;成功后整组重新待审核。
VersionRequest:
type: object
required: [expectedSourceVersion]
properties:
expectedSourceVersion: {type: integer, minimum: 1}
RejectRequest:
type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 500}
FeeDetail:
type: object
properties:
id: {type: string, nullable: true}
tenantId: {type: string, nullable: true}
allocationGroupId: {type: string, nullable: true}
allocationOrderId: {type: string, nullable: true}
allocationInboundId: {type: string, nullable: true}
loadingPlanId: {type: string, nullable: true}
containerBookingId: {type: string, nullable: true}
feeItemId: {type: string, nullable: true}
supplierId: {type: string, nullable: true}
reviewerId: {type: string, nullable: true}
createdBy: {type: string, nullable: true}
updatedBy: {type: string, nullable: true}
allocationOrderNo: {type: string, nullable: true}
allocationInboundCode: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
feeItemCodeSnapshot: {type: string, nullable: true}
feeItemNameSnapshot: {type: string, nullable: true}
supplierName: {type: string, nullable: true}
supplierCategory: {type: string, nullable: true}
remark: {type: string, nullable: true}
reviewerName: {type: string, nullable: true}
reviewReason: {type: string, nullable: true}
paymentReferenceNo: {type: string, nullable: true}
paidAt: {type: string, format: date-time, nullable: true}
reviewedAt: {type: string, format: date-time, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedAt: {type: string, format: date-time, nullable: true}
amount: {$ref: '#/components/schemas/Amount'}
allocationGroupAmount: {$ref: '#/components/schemas/Amount'}
businessDate: {type: string, format: date}
currency: {type: string, enum: [CNY]}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]}
sourceStatus: {type: string, enum: [ACTIVE, VOIDED]}
sourceVersion: {type: integer, minimum: 1}
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true}
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
paymentVoucherFileIds: {type: array, nullable: true, items: {$ref: '#/components/schemas/Id'}}
FeeResponse:
type: object
required: [code, message]
properties:
code: {type: string, description: 成功为200,业务冲突为A0409}
message: {type: string}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
data: {$ref: '#/components/schemas/FeeDetail'}
timestamp: {type: string, format: date-time}
FeeListResponse:
type: object
required: [code, message]
properties:
code: {type: string, description: 成功为200,业务冲突为A0409}
message: {type: string}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
data: {type: array, items: {$ref: '#/components/schemas/FeeDetail'}}
timestamp: {type: string, format: date-time}
EmptyResponse:
type: object
required: [code, message]
properties:
code: {type: string, description: 成功为200,业务冲突为A0409}
message: {type: string}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
data: {type: object, nullable: true}
timestamp: {type: string, format: date-time}
@@ -46,6 +46,14 @@ paths:
parameters: [{$ref: '#/components/parameters/TaskId'}] parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
responses: {'200': {description: OK}} responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind:
post:
summary: 系统生成 DP 托盘码并原子绑定全部待绑箱
description: 仅当前仓库本人已领取的待组托任务可执行。没有待绑箱时不发码、不建空托。operationId 在同任务和仓库重放不重复创建;不同任务使用同幂等号拒绝。返回当前 Task。
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}}
responses: {'200': {description: Task}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
post: post:
summary: 换托,targetPalletNo 为空时退回待绑区 summary: 换托,targetPalletNo 为空时退回待绑区
@@ -150,4 +158,10 @@ components:
expectedVersion: {type: integer, minimum: 1} expectedVersion: {type: integer, minimum: 1}
operationId: {type: string, maxLength: 128} operationId: {type: string, maxLength: 128}
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'} PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
CreateAndBindRequest:
type: object
required: [warehouseId, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
operationId: {type: string, minLength: 1, maxLength: 128}
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true} NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
@@ -32,7 +32,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/release: /api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post: post:
summary: Validate and permanently release a DRAFT summary: Validate and permanently release a DRAFT
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. It never requires or creates a real container number. description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}} responses: {'200': {description: RELEASED detail}}
@@ -45,7 +45,7 @@ paths:
'200': {description: Booking document history, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentListResponse'}}}} '200': {description: Booking document history, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentListResponse'}}}}
post: post:
summary: Reference an existing File object in a DRAFT booking summary: Reference an existing File object in a DRAFT booking
description: File scope must be customerId + OCEAN_BOOKING_DOCUMENT + oceanBookingId. The booking version is incremented atomically. description: File scope must be platform tenantId=0 + OCEAN_BOOKING_DOCUMENT + oceanBookingId, independent of the optional customer snapshot. The booking version is incremented atomically.
x-permission: platform:firstleg:ocean-booking:edit x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentRequest'}}}}
@@ -162,8 +162,8 @@ components:
type: object type: object
description: Fields may be incomplete in DRAFT. RELEASE validates the complete persisted aggregate. description: Fields may be incomplete in DRAFT. RELEASE validates the complete persisted aggregate.
properties: properties:
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选启用客户ID;多客户订舱留空,空值不是公开访问授权}
customerName: {type: string, nullable: true} customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null}
channelCode: {type: string, nullable: true} channelCode: {type: string, nullable: true}
warehouseId: {type: integer, format: int64, nullable: true} warehouseId: {type: integer, format: int64, nullable: true}
billOfLadingNo: {type: string, nullable: true} billOfLadingNo: {type: string, nullable: true}
@@ -279,7 +279,7 @@ components:
requiredQuantity: {type: integer, minimum: 1} requiredQuantity: {type: integer, minimum: 1}
allocatedQuantity: {type: integer, format: int64, minimum: 0} allocatedQuantity: {type: integer, format: int64, minimum: 0}
remainingQuantity: {type: integer, format: int64, minimum: 1} remainingQuantity: {type: integer, format: int64, minimum: 1}
customerName: {type: string} customerName: {type: string, nullable: true}
warehouseName: {type: string} warehouseName: {type: string}
channelName: {type: string} channelName: {type: string}
LinkCandidatePage: LinkCandidatePage:
@@ -77,7 +77,7 @@ components:
properties: properties:
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
actualAt: {type: string, format: date-time} actualAt: {type: string, format: date-time}
reason: {type: string, maxLength: 500, nullable: true} remark: {type: string, maxLength: 500, nullable: true}
Warning: Warning:
type: object type: object
required: [status, overdueHours, overdueDays] required: [status, overdueHours, overdueDays]
@@ -0,0 +1,93 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerFeeOpenApiContractTest {
@Test
@SuppressWarnings("unchecked")
void coversEveryControllerOperationPermissionAndResponse() throws Exception {
Map<String, Object> api = read();
Map<String, Map<String, Map<String, Object>>> paths = (Map<String, Map<String, Map<String, Object>>>) api.get("paths");
String base = PlatformContainerFeeDetailController.class.getAnnotation(RequestMapping.class).value()[0];
int operationCount = 0;
for (var method : PlatformContainerFeeDetailController.class.getDeclaredMethods()) {
var get = method.getAnnotation(GetMapping.class);
var post = method.getAnnotation(PostMapping.class);
if (get == null && post == null) continue;
String[] suffix = get != null ? get.value() : post.value();
String path = base + (suffix.length == 0 ? "" : suffix[0]);
assertThat(paths).containsKey(path);
var operation = paths.get(path).get(get != null ? "get" : "post");
assertThat(operation).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + operation.get("x-permission") + "')");
var responses = (Map<String, Object>) operation.get("responses");
assertThat((Map<String, Object>) responses.get("200")).containsKey("content");
operationCount++;
}
assertThat(paths.values().stream().mapToInt(Map::size).sum()).isEqualTo(operationCount);
validateReferences(api, api);
}
@Test
@SuppressWarnings("unchecked")
void requestAndResponseSchemasMatchJavaFields() throws Exception {
var components = (Map<String, Object>) read().get("components");
var schemas = (Map<String, Map<String, Object>>) components.get("schemas");
for (Class<?> dto : List.of(ContainerFeeDetailDtos.SaveRequest.class,
ContainerFeeDetailDtos.AdjustRequest.class, ContainerFeeDetailDtos.Allocation.class,
ContainerFeeDetailDtos.AllocationGroupRequest.class, ContainerFeeDetailDtos.AllocationAmount.class,
ContainerFeeDetailDtos.AllocationGroupAdjustRequest.class, ContainerFeeDetailDtos.VersionRequest.class,
ContainerFeeDetailDtos.RejectRequest.class)) {
Set<String> fields = Arrays.stream(dto.getRecordComponents()).map(value -> value.getName()).collect(Collectors.toSet());
var properties = (Map<String, Object>) schemas.get(dto.getSimpleName()).get("properties");
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(fields);
}
Set<String> responseFields = Arrays.stream(ContainerFeeDetailEntity.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> field.getName()).collect(Collectors.toSet());
var properties = (Map<String, Object>) schemas.get("FeeDetail").get("properties");
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(responseFields);
Set<String> envelopeFields = Arrays.stream(com.baiyue.common.core.api.ApiResponse.class.getRecordComponents())
.map(component -> component.getName()).collect(Collectors.toSet());
for (String name : List.of("FeeResponse", "FeeListResponse", "EmptyResponse")) {
var envelope = (Map<String, Object>) schemas.get(name).get("properties");
assertThat(envelope.keySet()).containsExactlyInAnyOrderElementsOf(envelopeFields);
}
}
private static Map<String, Object> read() throws Exception {
return new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/container-fees-v1.yaml")));
}
private static void validateReferences(Object value, Map<String, Object> root) {
if (value instanceof Map<?, ?> map) {
if (map.get("$ref") instanceof String ref) {
assertThat(ref).startsWith("#/");
Object target = root;
for (String segment : ref.substring(2).split("/")) target = ((Map<?, ?>) target).get(segment);
assertThat(target).as(ref).isNotNull();
}
map.values().forEach(child -> validateReferences(child, root));
} else if (value instanceof List<?> list) {
list.forEach(child -> validateReferences(child, root));
}
}
}
@@ -39,6 +39,8 @@ class OceanBookingMapperContractTest {
.flatMap(annotation -> Arrays.stream(annotation.value())) .flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + '\n' + right); .reduce("", (left, right) -> left + '\n' + right);
assertThat(sql) assertThat(sql)
.contains("customer_id=#{value.customerId},customer_name=#{value.customerName}")
.doesNotContain("customer_id=COALESCE", "customer_name=COALESCE")
.contains("status=#{fromStatus} AND version=#{expectedVersion}") .contains("status=#{fromStatus} AND version=#{expectedVersion}")
.contains("status='DRAFT' AND version=#{version}") .contains("status='DRAFT' AND version=#{version}")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
@@ -40,11 +40,12 @@ class OceanBookingDocumentServiceTest {
@Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception { @Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception {
OceanBookingEntity booking = draft(2); OceanBookingEntity booking = draft(2);
booking.setCustomerId(null);
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes()); MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes());
when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString())) when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(true, false); .thenReturn(true, false);
when(repository.findForUpdate(10L)).thenReturn(booking); when(repository.findForUpdate(10L)).thenReturn(booking);
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file)) when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", file))
.thenReturn(ApiResponse.success(metadata(91L))); .thenReturn(ApiResponse.success(metadata(91L)));
when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1); when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString())) when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
@@ -75,9 +76,9 @@ class OceanBookingDocumentServiceTest {
when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
.thenReturn(true); .thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1)); when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1));
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation)) when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
.thenReturn(ApiResponse.success(metadata(91L))); .thenReturn(ApiResponse.success(metadata(91L)));
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill)) when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
.thenReturn(ApiResponse.success(metadata(92L))); .thenReturn(ApiResponse.success(metadata(92L)));
when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1); when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
@@ -101,7 +102,7 @@ class OceanBookingDocumentServiceTest {
@Test void attachRejectsCrossScopeFileBeforePersisting() { @Test void attachRejectsCrossScopeFileBeforePersisting() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(1)); when(repository.findForUpdate(10L)).thenReturn(draft(1));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L)) when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L, .thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L,
"OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L))); "OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L)));
@@ -144,12 +145,12 @@ class OceanBookingDocumentServiceTest {
document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L); document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L);
document.setDocumentType("BILL_OF_LADING"); document.setValid(true); document.setDocumentType("BILL_OF_LADING"); document.setValid(true);
when(repository.activeDocuments(10L)).thenReturn(List.of(document)); when(repository.activeDocuments(10L)).thenReturn(List.of(document));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L)) when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(ApiResponse.success(metadata(91L))); .thenReturn(ApiResponse.success(metadata(91L)));
service.requireActiveValidDocument(booking); service.requireActiveValidDocument(booking);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
} }
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() { @Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
@@ -161,8 +162,8 @@ class OceanBookingDocumentServiceTest {
assertThatThrownBy(() -> service.requireActiveValidDocument(booking)) assertThatThrownBy(() -> service.requireActiveValidDocument(booking))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件"); .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L); verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString()); verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
} }
@@ -173,13 +174,48 @@ class OceanBookingDocumentServiceTest {
return document; return document;
} }
@Test void changingOrClearingCustomerKeepsPlatformFileDownloadAndReleaseScope() {
OceanBookingEntity booking = draft(3);
OceanBookingDocumentEntity document = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
document.setFileName("booking.pdf");
when(repository.find(10L)).thenReturn(booking);
when(repository.document(10L, 20L)).thenReturn(document);
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(ApiResponse.success(metadata(91L)));
when(files.download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(org.springframework.http.ResponseEntity.ok(new byte[]{1, 2, 3}));
for (Long customer : java.util.Arrays.asList(9L, 19L, null)) {
booking.setCustomerId(customer);
service.requireActiveValidDocument(booking);
assertThat(service.download(10L, 20L).content()).containsExactly(1, 2, 3);
}
verify(files, times(6)).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
verify(files, times(3)).download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
@Test void platformFileFromAnotherBookingIsRejected() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
OceanBookingEntity booking = draft(1);
booking.setCustomerId(null);
when(repository.findForUpdate(10L)).thenReturn(booking);
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 0L,
"OCEAN_BOOKING_DOCUMENT", "99", "x.pdf", "hash", "application/pdf", 3L)));
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"other-booking", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
}
private static OceanBookingEntity draft(int version) { private static OceanBookingEntity draft(int version) {
OceanBookingEntity booking = new OceanBookingEntity(); OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version); booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
return booking; return booking;
} }
private static OceanBookingFileClient.Metadata metadata(long fileId) { private static OceanBookingFileClient.Metadata metadata(long fileId) {
return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10", return new OceanBookingFileClient.Metadata(fileId, 0L, "OCEAN_BOOKING_DOCUMENT", "10",
"booking.pdf", "hash", "application/pdf", 3L); "booking.pdf", "hash", "application/pdf", 3L);
} }
} }
@@ -40,6 +40,7 @@ class OceanBookingServiceTest {
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners; @Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
@Mock private FirstlegDocumentNumberGenerator documentNumbers; @Mock private FirstlegDocumentNumberGenerator documentNumbers;
@Mock private OceanBookingDocumentService documents; @Mock private OceanBookingDocumentService documents;
@Mock private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
@InjectMocks private OceanBookingService service; @InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@@ -47,6 +48,8 @@ class OceanBookingServiceTest {
void setUp() throws Exception { void setUp() throws Exception {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules(); objectMapper = new ObjectMapper().findAndRegisterModules();
lenient().when(customers.active(eq(100L), any())).thenReturn(
new com.baiyue.firstleg.client.IamCustomerSnapshotClient.Customer(100L, "C100", "客户", "ACTIVE", null, null));
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001"); lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
var field = OceanBookingService.class.getDeclaredField("objectMapper"); var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.setAccessible(true);
@@ -76,6 +79,70 @@ class OceanBookingServiceTest {
verifyNoInteractions(priceBooks, suppliers, warehouses); verifyNoInteractions(priceBooks, suppliers, warehouses);
} }
@Test
void customerlessCreateSaveAndReleaseRetainsOtherRequirements() throws Exception {
saveAndReleaseWithoutCustomer(false);
}
@Test
void clearingExistingCustomerPersistsNullAndAllowsRelease() throws Exception {
saveAndReleaseWithoutCustomer(true);
}
private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception {
stubMasterData();
when(suppliers.booking(81L, "订舱代理")).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
var tree = objectMapper.valueToTree(completeDraft());
((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName");
DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class);
AtomicReference<OceanBookingEntity> stored = stubCreatedDraft();
var created = service.create(9L, "admin", "optional-create",
new CreateRequest(0, initialCustomer ? completeDraft() : emptyCustomer));
long id = created.oceanBooking().getId();
when(repository.updateDraft(any(), eq(0), eq(9L), eq("admin"))).thenAnswer(invocation -> {
OceanBookingEntity value = invocation.getArgument(0);
assertThat(value.getCustomerId()).isNull();
assertThat(value.getCustomerName()).isNull();
value.setVersion(1);
return 1;
});
service.updateDraft(id, 9L, "admin", "optional-save", new UpdateRequest(0, emptyCustomer));
when(repository.parties(id)).thenReturn(parties());
when(repository.requirements(id)).thenReturn(List.of(requirement()));
when(repository.transition(id, "DRAFT", "RELEASED", 1, 9L, "admin")).thenAnswer(invocation -> {
stored.get().setStatus("RELEASED"); stored.get().setVersion(2); return 1;
});
var result = service.release(id, 9L, "admin", "optional-release", new ReleaseRequest(1, "确认"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
assertThat(result.oceanBooking().getCustomerId()).isNull();
assertThat(result.oceanBooking().getCustomerName()).isNull();
verify(documents).requireActiveValidDocument(stored.get());
verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any());
}
@Test
void invalidSpecifiedCustomerIsRejectedBeforeCreatingDraft() {
when(customers.active(100L, "客户")).thenThrow(new BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
assertThatThrownBy(() -> service.create(9L, "admin", "bad-customer", new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
verify(repository, never()).insert(any(OceanBookingEntity.class));
}
@Test
void releaseRevalidatesSpecifiedCustomer() {
when(repository.find(1L)).thenReturn(completeBooking("DRAFT", 3));
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(customers.active(100L, "客户")).thenThrow(new BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "inactive-customer", new ReleaseRequest(3, null)))
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test @Test
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() { void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
stubMasterData(); stubMasterData();
@@ -86,6 +153,9 @@ class OceanBookingServiceTest {
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运"); assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓"); assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理"); assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
assertThat(inserted.get().getCustomerId()).isEqualTo(100L);
assertThat(inserted.get().getCustomerName()).isEqualTo("客户");
verify(customers).active(100L, "客户");
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture()); verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
@@ -265,6 +265,48 @@ class ContainerFeeDetailServiceTest {
.doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals); .doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals);
} }
@Test
void rejectsAllocationWhenPersistedRoundedRowsWouldExceedTotal() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("0.01"), "CNY", null, null);
var request = new AllocationGroupRequest(fee, List.of(
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.005")),
new Allocation(10002L, 502L, "FL-502", null, null, new BigDecimal("0.005"))));
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
.isInstanceOf(BizException.class).hasMessageContaining("分摊合计必须等于费用总额");
verify(repository, never()).insert(any());
}
@Test
void rejectsAllocationThatRoundsToZero() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("0.00"), "CNY", null, null);
var request = new AllocationGroupRequest(fee, List.of(
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.001"))));
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
verify(repository, never()).insert(any());
}
@Test
void rejectsGroupAdjustmentThatRoundsAnAllocationToZero() {
ownedPlan();
when(repository.listGroup(41L, 81L)).thenReturn(List.of(approvedFee(1)));
var request = new ContainerFeeDetailDtos.AllocationGroupAdjustRequest(1,
LocalDate.of(2026, 8, 20), new BigDecimal("0.001"), List.of(
new ContainerFeeDetailDtos.AllocationAmount(51L, new BigDecimal("0.001"))));
assertThatThrownBy(() -> service.adjustAllocationGroup(41L, 81L, 9L, request))
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), anyInt(), any(), any(), anyLong(), any());
}
@Test @Test
void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() { void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() {
LoadingPlanEntity plan = new LoadingPlanEntity(); LoadingPlanEntity plan = new LoadingPlanEntity();
@@ -16,10 +16,54 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class DropshipPalletizationServiceTest { class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class); private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService(); private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); } DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); }
@Test void generatesAndBindsOnlyWhenPendingCartonsExist(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001");
when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2);
when(mapper.adjustBound(10L,7L,2)).thenReturn(1);
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三");
verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三"));
verify(numbers,times(1)).next("DP");
}
@Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱");
verifyNoInteractions(numbers);
verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
}
@Test void replaysAfterCompletionWithoutGeneratingAnotherPallet(){
TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT");
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(completed);
when(mapper.palletByOpenOperation("replay")).thenReturn(pallet());
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"),7L,"张三");
verifyNoInteractions(numbers); verify(mapper,never()).unboundCount(anyLong());
}
@Test void rejectsOtherOperatorAndCrossTaskReplay(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay");
assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员");
PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other);
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("不同任务");
verifyNoInteractions(numbers);
}
@Test void rejectsUnknownWarehouseAndChangedBindCount(){
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new");
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库");
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002");
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化");
verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt());
}
@Test void resolvesUncountedCartonBeforeQuantityConfirmation(){ @Test void resolvesUncountedCartonBeforeQuantityConfirmation(){
TaskRow task=task(); LabelRow label=label(); TaskRow task=task(); LabelRow label=label();
@@ -9,6 +9,19 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageOpenApiContractTest { class TerminalDrayageOpenApiContractTest {
@Test
void actualTimeRequestBindsRemarkFromPublicJson() throws Exception {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var request = mapper.readValue("""
{"expectedVersion":3,"actualAt":"2026-09-04T09:00:00","remark":"已还柜"}
""", com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest.class);
assertThat(request.remark()).isEqualTo("已还柜");
assertThat(request.expectedVersion()).isEqualTo(3);
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
String schema = yaml.substring(yaml.indexOf(" ActualTimeRequest:"), yaml.indexOf(" Warning:"));
assertThat(schema).contains("remark:", "expectedVersion:", "actualAt:").doesNotContain("reason:");
}
@Test @Test
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception { void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
assertPermission("views", "platform:firstleg:terminal-drayage:list"); assertPermission("views", "platform:firstleg:terminal-drayage:list");
@@ -84,6 +84,65 @@ class ContainerWorkbenchServiceTest {
verifyNoInteractions(carrierSchedules); verifyNoInteractions(carrierSchedules);
} }
@Test
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
verifyRealEmptyReturnProjection(true, true);
}
@Test
void realOverseasListAndDetailRemoveBothActionsWithoutPermission() throws Exception {
verifyRealEmptyReturnProjection(false, true);
}
@Test
void unloadedListWithoutDrayageAggregateDoesNotOfferUnexecutableReturn() throws Exception {
verifyRealEmptyReturnProjection(true, false);
}
private void verifyRealEmptyReturnProjection(boolean hasPermission, boolean hasAggregate) throws Exception {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json);
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context(
11L, "MSCU1234567", 21L, "ACTIVE", container.getUnloadingCompletedAt());
var row = new com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity();
row.setId(51L);
row.setContainerBookingId(11L);
row.setVersion(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0));
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
when(repository.find(11L)).thenReturn(container);
when(repository.departed(11L)).thenReturn(true);
when(mapper.contexts(List.of(11L))).thenReturn(List.of(context));
when(mapper.context(11L)).thenReturn(context);
when(mapper.findAll(List.of(11L))).thenReturn(hasAggregate ? List.of(row) : List.of());
when(mapper.find(11L)).thenReturn(hasAggregate ? row : null);
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(facts("PICKED_UP")));
Set<String> permissions = hasPermission
? Set.of("platform:firstleg:terminal-drayage:list", "platform:firstleg:terminal-drayage:empty-return:confirm")
: Set.of("platform:firstleg:terminal-drayage:list");
var page = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
var listed = page.records().get(0);
var detail = service.overseasDetail(11L, permissions);
List<String> expected = hasPermission && hasAggregate ? List.of("RECORD_EMPTY_RETURN") : List.of();
assertThat(listed.workbenchStage()).isEqualTo("UNLOADED");
assertThat(listed.terminalDrayage().status()).isEqualTo("UNLOADED");
assertThat(listed.allowedActions()).isEqualTo(expected);
assertThat(listed.terminalDrayage().allowedActions()).isEqualTo(expected);
assertThat(detail.allowedActions()).isEqualTo(listed.allowedActions());
assertThat(detail.terminalDrayage().allowedActions()).isEqualTo(listed.terminalDrayage().allowedActions());
var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page)));
assertThat(serialized.at("/data/records/0/allowedActions")).isEqualTo(json.valueToTree(expected));
assertThat(serialized.at("/data/records/0/terminalDrayage/allowedActions")).isEqualTo(json.valueToTree(expected));
verify(mapper).findAll(List.of(11L));
verify(mapper).contexts(List.of(11L));
}
@Test @Test
void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() { void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() {
ContainerBookingEntity container = container(); ContainerBookingEntity container = container();
@@ -177,6 +177,23 @@ class TerminalDrayageServiceTest {
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
} }
@Test
void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() {
Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0));
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.context(11L)).thenReturn(context);
when(mapper.find(11L)).thenReturn(row);
var raw = service.detail(11L);
assertThat(raw.status()).isEqualTo("UNLOADED");
assertThat(service.authorize(raw,
Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions())
.containsExactly("RECORD_EMPTY_RETURN");
assertThat(service.authorize(raw,
Set.of("platform:firstleg:terminal-drayage:list")).allowedActions()).isEmpty();
}
@Test @Test
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() { void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
when(mapper.contexts(List.of(11L))).thenReturn(List.of( when(mapper.contexts(List.of(11L))).thenReturn(List.of(
@@ -276,6 +293,9 @@ class TerminalDrayageServiceTest {
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class); ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(node.getValue().reason()).isEqualTo("已还柜");
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
assertThat(result.version()).isEqualTo(4);
} }
private Context context(String node, LocalDateTime unloadedAt) { private Context context(String node, LocalDateTime unloadedAt) {
@@ -15,6 +15,7 @@ BEGIN
OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list') OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list')
OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create') OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create')
OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list') OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list')
OR (id = 5107094 AND permission_code <> 'platform:overseas-warehouse:delivery:documents')
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel') OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list') OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list')
OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create') OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create')
@@ -34,6 +35,7 @@ BEGIN
OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090) OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090)
OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091) OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091)
OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092) OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092)
OR (permission_code = 'platform:overseas-warehouse:delivery:documents' AND id <> 5107094)
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093) OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070) OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070)
OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071) OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071)
@@ -70,6 +72,7 @@ VALUES
(5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0),
(5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0),
(5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0),
(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'ACTIVE',1,0),
(5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0),
(5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0), (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0),
(5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0) (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0)
@@ -101,6 +104,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit') 'platform:overseas-warehouse:customer-visit:edit')
AND permission.status='ACTIVE' AND permission.deleted=0; AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -126,6 +130,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list'), ('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list'),
@@ -155,6 +160,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit') 'platform:overseas-warehouse:customer-visit:edit')
AND expected.permission_code IS NULL; AND expected.permission_code IS NULL;
@@ -203,10 +209,11 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit', 'platform:overseas-warehouse:customer-visit:edit',
'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:list',
'merchant:warehouse:delivery:create')) <> 17 THEN 'merchant:warehouse:delivery:create')) <> 18 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row IF (SELECT COUNT(*) FROM iam_role_permission grant_row
@@ -227,8 +234,9 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN 'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
END IF; END IF;
IF EXISTS (SELECT 1 FROM iam_role role IF EXISTS (SELECT 1 FROM iam_role role
@@ -252,15 +260,16 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN 'platform:overseas-warehouse:customer-visit:edit')) <> 16) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整';
END IF; END IF;
SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0),
COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)
INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count
FROM tmp_phase5_delivery_role_permission; FROM tmp_phase5_delivery_role_permission;
IF v_warehouse_operator_permission_count <> 15 IF v_warehouse_operator_permission_count <> 16
OR v_warehouse_tally_permission_count <> 5 THEN OR v_warehouse_tally_permission_count <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
END IF; END IF;
@@ -282,6 +291,7 @@ BEGIN
'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:delivery:documents',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) THEN 'platform:overseas-warehouse:customer-visit:edit')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
+1 -1
View File
@@ -124,7 +124,7 @@ paths:
get: get:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 查询当前用户有效权限 summary: 查询当前用户有效权限
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。列表权限包含详情和只读辅助接口,写操作独立授权。 description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送七类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。
responses: responses:
'200': '200':
description: 当前平台成员权限查询成功 description: 当前平台成员权限查询成功
@@ -29,6 +29,8 @@ class IamOpenApiContractTest {
.contains("platform:overseas-warehouse:delivery:list") .contains("platform:overseas-warehouse:delivery:list")
.contains("platform:overseas-warehouse:delivery:return") .contains("platform:overseas-warehouse:delivery:return")
.contains("platform:overseas-warehouse:delivery:status") .contains("platform:overseas-warehouse:delivery:status")
.contains("platform:overseas-warehouse:delivery:documents")
.contains("读取和下载仍使用 `platform:overseas-warehouse:delivery:list`")
.contains("platform:overseas-warehouse:exception-order:list") .contains("platform:overseas-warehouse:exception-order:list")
.contains("platform:overseas-warehouse:exception-order:create") .contains("platform:overseas-warehouse:exception-order:create")
.contains("platform:overseas-warehouse:exception-order:process") .contains("platform:overseas-warehouse:exception-order:process")
@@ -28,6 +28,7 @@ class Phase5DeliveryPermissionSqlContractTest {
"platform:overseas-warehouse:exception-order:process", "platform:overseas-warehouse:exception-order:process",
"platform:overseas-warehouse:delivery:return", "platform:overseas-warehouse:delivery:return",
"platform:overseas-warehouse:delivery:status", "platform:overseas-warehouse:delivery:status",
"platform:overseas-warehouse:delivery:documents",
"platform:overseas-warehouse:customer-visit:list", "platform:overseas-warehouse:customer-visit:list",
"platform:overseas-warehouse:customer-visit:edit", "platform:overseas-warehouse:customer-visit:edit",
"merchant:warehouse:delivery:list", "merchant:warehouse:delivery:list",
@@ -58,6 +59,7 @@ class Phase5DeliveryPermissionSqlContractTest {
"'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'",
"'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'", "'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'",
"'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'", "'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'",
"(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST'",
"'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'", "'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'",
"'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT'", "'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT'",
"'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET'"); "'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET'");
@@ -80,6 +82,7 @@ class Phase5DeliveryPermissionSqlContractTest {
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process')",
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return')",
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status')",
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents')",
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit')",
"('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list')",
"('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list')",
@@ -95,6 +98,7 @@ class Phase5DeliveryPermissionSqlContractTest {
"('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:process')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:process')",
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:return')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:return')",
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:status')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:status')",
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:documents')",
"('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:edit')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:edit')",
"('OVERSEAS_WAREHOUSE_PDA_OPERATOR','platform:overseas-warehouse:", "('OVERSEAS_WAREHOUSE_PDA_OPERATOR','platform:overseas-warehouse:",
"('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:"); "('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:");
@@ -110,7 +114,7 @@ class Phase5DeliveryPermissionSqlContractTest {
"SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)",
"COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)",
"INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count",
"IF v_warehouse_operator_permission_count <> 15", "IF v_warehouse_operator_permission_count <> 16",
"OR v_warehouse_tally_permission_count <> 5 THEN", "OR v_warehouse_tally_permission_count <> 5 THEN",
"IF EXISTS (SELECT 1 FROM iam_role role", "IF EXISTS (SELECT 1 FROM iam_role role",
"AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", "AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'",
@@ -164,7 +168,7 @@ class Phase5DeliveryPermissionSqlContractTest {
"海外排车、出库与派送权限", "海外排车、出库与派送权限",
"测试和部署只执行固定入口,不再单独执行模块脚本", "测试和部署只执行固定入口,不再单独执行模块脚本",
"排车列表/编辑/取消/转派送", "排车列表/编辑/取消/转派送",
"异常订单列表/创建/处理和客户回访列表/编辑十五项权限", "异常订单列表/创建/处理和客户回访列表/编辑十六项权限",
"merchant:warehouse:delivery:list", "merchant:warehouse:delivery:list",
"merchant:warehouse:delivery:create", "merchant:warehouse:delivery:create",
"PDA 岗位不得获得上述后台权限"); "PDA 岗位不得获得上述后台权限");
@@ -37,12 +37,12 @@ class Phase5DeliveryPermissionSqlExecutionTest {
assertThat(customAdministratorViolations(connection)).isEqualTo(1); assertThat(customAdministratorViolations(connection)).isEqualTo(1);
executeGrant(connection); executeGrant(connection);
assertThat(grantCount(connection, 11L)).isEqualTo(15); assertThat(grantCount(connection, 11L)).isEqualTo(16);
assertThat(customAdministratorViolations(connection)).isZero(); assertThat(customAdministratorViolations(connection)).isZero();
executeGrant(connection); executeGrant(connection);
assertThat(grantCount(connection, 11L)).isEqualTo(15); assertThat(grantCount(connection, 11L)).isEqualTo(16);
assertThat(totalGrantCount(connection)).isEqualTo(15); assertThat(totalGrantCount(connection)).isEqualTo(16);
} }
} }
@@ -51,14 +51,14 @@ class Phase5DeliveryPermissionSqlExecutionTest {
try (Connection connection = database()) { try (Connection connection = database()) {
insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN");
insertRole(connection, 22L, "Platform_Custom_Admin"); insertRole(connection, 22L, "Platform_Custom_Admin");
grantFirstPermissions(connection, 21L, 15); grantFirstPermissions(connection, 21L, 16);
grantFirstPermissions(connection, 22L, 14); grantFirstPermissions(connection, 22L, 15);
assertThat(customAdministratorViolations(connection)).isEqualTo(1); assertThat(customAdministratorViolations(connection)).isEqualTo(1);
executeGrant(connection); executeGrant(connection);
assertThat(grantCount(connection, 21L)).isEqualTo(15); assertThat(grantCount(connection, 21L)).isEqualTo(16);
assertThat(grantCount(connection, 22L)).isEqualTo(15); assertThat(grantCount(connection, 22L)).isEqualTo(16);
assertThat(customAdministratorViolations(connection)).isZero(); assertThat(customAdministratorViolations(connection)).isZero();
} }
} }
@@ -68,16 +68,50 @@ class Phase5DeliveryPermissionSqlExecutionTest {
try (Connection connection = database()) { try (Connection connection = database()) {
insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN");
insertRole(connection, 32L, "platform_custom_admin"); insertRole(connection, 32L, "platform_custom_admin");
grantFirstPermissions(connection, 31L, 15); grantFirstPermissions(connection, 31L, 16);
grantFirstPermissions(connection, 32L, 15); grantFirstPermissions(connection, 32L, 16);
assertThat(customAdministratorViolations(connection)).isZero(); assertThat(customAdministratorViolations(connection)).isZero();
executeGrant(connection); executeGrant(connection);
executeGrant(connection); executeGrant(connection);
assertThat(grantCount(connection, 31L)).isEqualTo(15); assertThat(grantCount(connection, 31L)).isEqualTo(16);
assertThat(grantCount(connection, 32L)).isEqualTo(15); assertThat(grantCount(connection, 32L)).isEqualTo(16);
assertThat(totalGrantCount(connection)).isEqualTo(30); assertThat(totalGrantCount(connection)).isEqualTo(32);
}
}
@Test
void administratorGrantDoesNotEscalateAReadOnlyRoleOrDisabledAdministrator() throws Exception {
try (Connection connection = database()) {
insertRole(connection, 41L, "PLATFORM_ADMIN");
insertRole(connection, 42L, "WAREHOUSE_TALLY");
insertRole(connection, 43L, "PLATFORM_CUSTOM_ADMIN");
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("UPDATE iam_role SET status='INACTIVE' WHERE id=43");
statement.executeUpdate("""
INSERT INTO iam_role_permission
SELECT 0, 42, id, CURRENT_TIMESTAMP FROM iam_permission
WHERE permission_code='platform:overseas-warehouse:delivery:list'
""");
}
executeGrant(connection);
executeGrant(connection);
assertThat(grantCount(connection, 41L)).isEqualTo(16);
assertThat(grantCount(connection, 42L)).isEqualTo(1);
assertThat(grantCount(connection, 43L)).isZero();
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("""
SELECT role_id FROM iam_role_permission grant_row
JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE permission.permission_code='platform:overseas-warehouse:delivery:documents'
""")) {
assertThat(result.next()).isTrue();
assertThat(result.getLong(1)).isEqualTo(41L);
assertThat(result.next()).isFalse();
}
} }
} }
@@ -182,7 +216,7 @@ class Phase5DeliveryPermissionSqlExecutionTest {
AND (SELECT COUNT(*) FROM iam_role_permission grant_row AND (SELECT COUNT(*) FROM iam_role_permission grant_row
JOIN iam_permission permission ON permission.id=grant_row.permission_id JOIN iam_permission permission ON permission.id=grant_row.permission_id
WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id
AND permission.permission_code IN (%s)) <> 15 AND permission.permission_code IN (%s)) <> 16
""".formatted(placeholders); """.formatted(placeholders);
try (PreparedStatement query = connection.prepareStatement(sql)) { try (PreparedStatement query = connection.prepareStatement(sql)) {
int index = 1; int index = 1;
@@ -209,7 +243,7 @@ class Phase5DeliveryPermissionSqlExecutionTest {
LinkedHashSet<String> codes = new LinkedHashSet<>(); LinkedHashSet<String> codes = new LinkedHashSet<>();
String grant = administratorGrantSql(); String grant = administratorGrantSql();
PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add); PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add);
assertThat(codes).hasSize(15); assertThat(codes).hasSize(16);
return codes; return codes;
} }
+4 -3
View File
@@ -12,7 +12,7 @@
| 功能 | 入口 | | 功能 | 入口 |
|---|---| |---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改,生成资格只由 `allPlansReady/pendingPlanCount` 表达;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
@@ -23,10 +23,10 @@
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | | IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 |
| 入仓计划分页、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 | | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | | 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
@@ -43,6 +43,7 @@
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 | | 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 |
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载,不增加派送完成门禁或签名识别 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
@@ -0,0 +1,48 @@
# 派送节点交付与六按钮现状核查
范围:测试项目当前 `test` 分支;后端16派送业务。仅源码和离线测试,不代表真实环境已部署或人工验收通过。
## 状态错误结论
用户请求仅提交 `status=DELIVERING, remark=123`,缺少必传 `expectedVersion`,因此返回参数错误。后端 `CustomerVisitRequests.DeliveryStatus` 保留版本必填;列表/详情 `DeliveryRow` 已返回 `version/allowedActions`。当前 Admin 的编辑状态选项将 `DELIVERING` 标为“派送中”,`DELIVERED` 标为“派送完成”,提交代码遗漏版本;不能据用户口述把 DELIVERING 改成完成。
状态命令保持 `{status,expectedVersion,remark}`,不新增幂等头。`START_DELIVERY→DELIVERING` 记录首次派送时间;`COMPLETE_DELIVERY→DELIVERED` 记录签收时间、更新尾程子单状态并生成待回访。相同状态重放成功,不重复状态写入;非法回退及版本冲突拒绝。未新增资料齐全、签名识别或电子签署门禁。
## 三类资料最终契约
完整机器契约:`src/main/resources/openapi/delivery-documents-v1.yaml`。七类方案已撤销,旧类别不接受。
| 顺序与业务类别 | 显示名称 | File类型 |
|---|---|---|
| LOADING_PHOTO | 装车照片 | DELIVERY_LOADING_PHOTO |
| BOL | BOL文件(含签名) | DELIVERY_BOL |
| RECEIPT | 签收文件(POD签收/快递签收) | DELIVERY_RECEIPT |
每类0至20个文件,沿用 File 任意格式且单文件非空、不超过50MiB规则,按有已保存文件的类别计数0/3至3/3。POD与快递签收合为同一类别,文件数量不改变类别计数。
- 读取/下载权限:`platform:overseas-warehouse:delivery:list`;上传/保存权限:`platform:overseas-warehouse:delivery:documents`。
- GET/PUT `/delivery-orders/{deliveryNo}/documents?customerId=...`;PUT全量保存 `{expectedVersion,idempotencyKey,bolNo,documents:[{category,fileIds}]}`,遗漏类别清空。
- 同一派送锁内先检查版本与全部文件归属,再原子写当前快照、增加派送版本、写不可变历史及操作日志。文件保存使用 File 的真实文件名、格式、大小、创建时间,不信任前端元数据。
- 上传不绑定、不修改版本或摘要。未保存/取消弹框不落绑定。替换和删除仅改当前引用,已保存历史和物理对象保留。
- 当前下载仅允许当前绑定文件;指定 `revisionVersion` 仅可下载该派送单对应历史版本已绑定文件。两者均复核客户、派送内部ID、类别和fileId。
- BOL号独立保存,未建立与其他单据的权威关联,不按相同BOL文本或运单号复用其他文件。
- 列表和详情行新增 `bolNo/documentSummary`,摘要从本地保存快照读取,不逐文件调用File。
## 六按钮现状
| 按钮 | 当前实现与权限 | 判定与边界 |
|---|---|---|
| 编辑节点 | `CustomerVisitController` 的 PUT `/delivery-orders/{deliveryNo}/status`、POST `/complete`;`delivery:status`;版本与状态机 | 已实现状态命令,前端漏版本交Admin修复。当前策略只校验派送状态,未联查出库实物门禁;若“编辑节点”意为补录节点时间或修改完成前置规则,需进一步确认,不自动实现 |
| 打印BOL | 派送Controller未发现生成打印接口;Print现有海运提单确认件不能直接证明派送BOL实现 | 未实现派送专属生成链。上传已签名BOL及下载不等于生成打印;模板、字段、签名来源需确认 |
| 详情 | GET `/delivery-orders/{deliveryNo}?customerId=...`,`delivery:list`,返回派送行/货物/费用/操作历史 | 已实现,真实环境待验收 |
| 取消 | 通用状态仅允许WAIT_DELIVERY→CANCELLED,更新派送状态/版本/日志。另出库 `/outbound-orders/{outboundNo}/cancel` 属出库专项,校验未执行并取消关联派送、PDA任务和明细 | 部分实现。单改派送状态不等于撤销履约关联;出库取消还会将尾程主状态写RECEIVED,已报测试总控。需确认派送取消与出库/排车的业务关系,不能擅自互相替代 |
| 编辑车队 | 派送创建保存carrier/driver/vehicle快照,未发现派送单独立维护接口 | 未实现;可改状态、是否联动排车和来源主档规则需确认 |
| 标记退货 | `ExceptionOrderController` `/return-inbound-options`、`/mark-return`;`delivery:return`。从原出库明细选择,冻结可退上限,幂等生成异常;异常处理使用version/幂等与证据 | 已实现后端。退回海外仓必须先按来源恢复实收库存,再同事务完成异常并置派送RETURNED;不能直接改派送状态代替库存。Admin旧自由SKU入参交其收敛 |
## 验证与部署前提
- 本次定向69项通过:包含资料版本/重放/异请求冲突、客户/单据/类别/fileId错配、文件计数与保存后重读、未保存下载拒绝、历史解绑下载、旧枚举拒绝、嵌套校验、权限分离、状态开始/完成/重放、现有出库/退货和SQL合同;只读用户的详情/重放/历史不返回管理动作,按当前授权而非历史权限裁剪。
- Mapper扫描通过。AGENTS索引检查有两项既存柜费契约缺口:金额守恒、单行拒绝处理分摊组,未修改该域。
- 模块基线和获准的海外统一入口同步 `ll_delivery_order.bol_no/documents_json`、`ll_delivery_document_revision`。只维护当前初始化定义,没有旧类别迁移或清理脚本;既有运行库应用方式交测试总控按环境授权处理。
- 单元测试验证保存前全部校验无部分写入和快照重读;真实MySQL事务故障注入、跨服务真实上传下载、页面刷新与人工截图尚未验收。
- 配套最终三类File提交 `81b72992` 和IAM权限 `cde8acf1` 已进入 `test`;不能使用早期七类File提交作为最终类别合同。
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents")
public class DeliveryDocumentController {
@Resource private DeliveryDocumentService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId) {
scope(user, customerId); return ApiResponse.success(authorized(service.detail(customerId, deliveryNo)));
}
@PutMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId, @Valid @RequestBody Save request) {
scope(user, customerId); return ApiResponse.success(authorized(service.save(customerId, deliveryNo, operator(user), request)));
}
@PostMapping(value="/{category}/files", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')")
public ApiResponse<File> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId,
@PathVariable DeliveryDocumentCategory category, @RequestPart("file") MultipartFile file) {
scope(user, customerId); return ApiResponse.success(service.upload(customerId, deliveryNo, category, operator(user), file));
}
@GetMapping("/history")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId) {
scope(user, customerId); return ApiResponse.success(service.history(customerId, deliveryNo).stream()
.map(h -> new History(h.version(), h.operatorId(), h.savedAt(), authorized(h.snapshot()))).toList());
}
@GetMapping("/{category}/files/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId,
@PathVariable DeliveryDocumentCategory category, @PathVariable long fileId,
@RequestParam(required=false) Long revisionVersion) {
scope(user, customerId);
DeliveryDocumentService.Download downloaded = service.download(customerId, deliveryNo, category, fileId, revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString())
.body(downloaded.content());
}
private static Detail authorized(Detail detail) {
var authentication = SecurityContextHolder.getContext().getAuthentication();
boolean canManage = authentication != null && authentication.isAuthenticated()
&& authentication.getAuthorities().stream().anyMatch(a -> OverseasPermissions.DELIVERY_DOCUMENTS.equals(a.getAuthority()));
return new Detail(detail.deliveryNo(), detail.customerId(), detail.version(), detail.bolNo(),
detail.uploadedCategoryCount(), detail.totalCategoryCount(), detail.categories(),
canManage ? detail.allowedActions() : List.of(), detail.replayed());
}
private static void scope(AuthenticatedUser user, String customerId) {
if (user == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可管理派送资料");
try { if (Long.parseLong(customerId) <= 0) throw new NumberFormatException(); }
catch (NumberFormatException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请指定有效客户"); }
}
private static String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : user.userId().toString(); }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.delivery.dto;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
import java.util.List;
public final class DeliveryDocumentDtos {
private DeliveryDocumentDtos() { }
public record Save(@NotNull @Min(0) Long expectedVersion,
@NotBlank @Size(max=128) String idempotencyKey,
@Size(max=128) String bolNo,
@NotNull @Size(max=3) List<@NotNull @Valid Binding> documents) { }
public record Binding(@NotNull DeliveryDocumentCategory category,
@NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { }
public record File(Long fileId, String fileName, String contentType, long sizeBytes,
LocalDateTime uploadedAt) { }
public record Category(DeliveryDocumentCategory category, String status, int fileCount, List<File> files) { }
public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { }
public record Summary(int uploadedCategoryCount, int totalCategoryCount, List<CategorySummary> categories) { }
public record Detail(String deliveryNo, String customerId, long version, String bolNo,
int uploadedCategoryCount, int totalCategoryCount, List<Category> categories,
List<String> allowedActions, boolean replayed) { }
public record History(long version, String operatorId, LocalDateTime savedAt, Detail snapshot) { }
}
@@ -24,7 +24,8 @@ public final class DeliveryResponses {
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
LocalDateTime expectedArrivalAt, LocalDateTime signedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
String remark, LocalDateTime createdAt, long version, List<String> allowedActions) {} String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
String bolNo, DeliveryDocumentDtos.Summary documentSummary) {}
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo, String receivingAddress, String shipmentNo, String fbaTrackingNo,
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.delivery.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_delivery_document_revision")
public class DeliveryDocumentRevision {
@TableId(type=IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long deliveryOrderId;
private Long version;
private String idempotencyKey;
private String requestHash;
private String snapshotJson;
private String operatorId;
private LocalDateTime createdAt;
}
@@ -25,6 +25,8 @@ public class DeliveryOrder {
private String customerName; private String customerName;
private String status; private String status;
private String waybillNo; private String waybillNo;
private String bolNo;
private String documentsJson;
private String deliveryMethod; private String deliveryMethod;
private String carrierName; private String carrierName;
private String pickupMethod; private String pickupMethod;
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.delivery.gateway;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import org.springframework.web.multipart.MultipartFile;
public interface DeliveryDocumentFileGateway {
File upload(String customerId, long deliveryId, DeliveryDocumentCategory category, String operatorId, MultipartFile file);
File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId);
byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId);
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.delivery.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import jakarta.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Objects;
@Component
public class HttpDeliveryDocumentFileGateway implements DeliveryDocumentFileGateway {
private static final String PATH = "/internal/file/v1/business-files/{customer}/{type}/{delivery}";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile());
}
@Override public File upload(String customerId, long deliveryId, DeliveryDocumentCategory category,
String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > 52428800L)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "派送资料须为非空且不超过50MiB的文件");
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取派送资料", e); }
Envelope response = client.post().uri(PATH, customerId, category.businessType(), deliveryId)
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
.body(Envelope.class);
return verified(response, customerId, deliveryId, category, null);
}
@Override public File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) {
Envelope response = client.get().uri(PATH + "/{file}", customerId, category.businessType(), deliveryId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
.body(Envelope.class);
return verified(response, customerId, deliveryId, category, fileId);
}
@Override public byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) {
return client.get().uri(PATH + "/{file}/content", customerId, category.businessType(), deliveryId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
.body(byte[].class);
}
private static File verified(Envelope response, String customerId, long deliveryId,
DeliveryDocumentCategory category, Long expectedFileId) {
if (response == null) throw failure(HttpStatus.SERVICE_UNAVAILABLE);
if (!"200".equals(response.code())) throw new ResponseStatusException(
response.code() != null && response.code().startsWith("A04") ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"派送资料文件不可用或归属校验失败");
Metadata file = response.data();
if (file == null || file.fileId() == null || file.fileId() <= 0 || file.createdAt() == null
|| file.fileName() == null || file.sizeBytes() == null || file.sizeBytes() <= 0
|| file.sizeBytes() > 52428800L || !Objects.equals(String.valueOf(file.tenantId()), customerId)
|| !category.businessType().equals(file.businessType()) || !String.valueOf(deliveryId).equals(file.businessId())
|| (expectedFileId != null && !expectedFileId.equals(file.fileId())))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件不属于当前客户、派送单或资料类别");
return new File(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes(), file.createdAt());
}
private static ResponseStatusException failure(HttpStatusCode status) {
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"派送资料文件请求失败");
}
private record Envelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes, LocalDateTime createdAt) { }
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryDocumentRevision;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DeliveryDocumentRevisionMapper extends BaseMapper<DeliveryDocumentRevision> {
@Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} AND idempotency_key=#{key}")
DeliveryDocumentRevision findCommand(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} ORDER BY version DESC")
List<DeliveryDocumentRevision> history(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.delivery.model;
public enum DeliveryDocumentCategory {
LOADING_PHOTO("DELIVERY_LOADING_PHOTO"), BOL("DELIVERY_BOL"), RECEIPT("DELIVERY_RECEIPT");
private final String businessType;
DeliveryDocumentCategory(String businessType) { this.businessType = businessType; }
public String businessType() { return businessType; }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.delivery.model;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
/** Saved metadata only; listing never calls File and never counts pending uploads. */
public final class DeliveryDocumentSnapshot {
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
private DeliveryDocumentSnapshot() { }
public static List<Category> categories(String json) {
if (json == null || json.isBlank()) return Arrays.stream(DeliveryDocumentCategory.values())
.map(category -> new Category(category, "MISSING", 0, List.of())).toList();
try { return JSON.readValue(json, new TypeReference<List<Category>>() { }); }
catch (JsonProcessingException e) { throw new IllegalStateException("派送资料快照损坏", e); }
}
public static Summary summary(String json) {
List<Category> categories = categories(json);
return new Summary((int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3,
categories.stream().map(c -> new CategorySummary(c.category(), c.status(), c.fileCount())).toList());
}
}
@@ -0,0 +1,128 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.model.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class DeliveryDocumentService {
@Resource private DeliveryOrderMapper orders;
@Resource private DeliveryDocumentRevisionMapper revisions;
@Resource private DeliveryOperationMapper operations;
@Resource private DeliveryDocumentFileGateway files;
@Resource private ObjectMapper objectMapper;
public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); }
public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category,
String operatorId, MultipartFile file) {
DeliveryOrder order = require(customerId, deliveryNo);
return files.upload(customerId, order.getId(), category, operatorId, file);
}
/** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */
@Transactional
public Detail save(String customerId, String deliveryNo, String operatorId, Save request) {
DeliveryOrder order = orders.lockByNo(customerId, deliveryNo);
if (order == null) throw missing();
String hash = hash(request);
DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey());
if (replay != null) {
if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同");
Detail saved = read(replay.getSnapshotJson());
return new Detail(saved.deliveryNo(), saved.customerId(), saved.version(), saved.bolNo(),
saved.uploadedCategoryCount(), 3, saved.categories(), saved.allowedActions(), true);
}
if (!Objects.equals(version(order), request.expectedVersion())) throw conflict("派送单版本已变化,请刷新后重试");
Map<DeliveryDocumentCategory, List<DeliveryDocumentDtos.File>> bindings = new EnumMap<>(DeliveryDocumentCategory.class);
Set<Long> unique = new HashSet<>();
for (Binding binding : request.documents()) {
if (bindings.containsKey(binding.category())) throw bad("资料类别不能重复");
List<DeliveryDocumentDtos.File> metadata = new ArrayList<>();
for (Long fileId : binding.fileIds()) {
if (!unique.add(fileId)) throw bad("同一文件不能重复绑定");
metadata.add(files.metadata(customerId, order.getId(), binding.category(), fileId));
}
bindings.put(binding.category(), List.copyOf(metadata));
}
List<Category> categories = Arrays.stream(DeliveryDocumentCategory.values()).map(category -> {
List<DeliveryDocumentDtos.File> values = bindings.getOrDefault(category, List.of());
return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values);
}).toList();
LocalDateTime now = LocalDateTime.now();
order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim());
order.setDocumentsJson(write(categories));
order.setVersion(version(order) + 1);
order.setUpdatedAt(now);
orders.updateById(order);
Detail result = detail(order, false);
DeliveryDocumentRevision revision = new DeliveryDocumentRevision();
revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion());
revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result));
revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision);
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId);
operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类");
operation.setCreatedAt(now); operations.insert(operation);
return result;
}
public List<History> history(String customerId, String deliveryNo) {
DeliveryOrder order = require(customerId, deliveryNo);
return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(),
revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList();
}
public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) {
DeliveryOrder order = require(customerId, deliveryNo);
Detail snapshot = detail(order, false);
if (revisionVersion != null) {
snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion()))
.findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing);
}
DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category)
.flatMap(c -> c.files().stream()).filter(f -> f.fileId() == fileId).findFirst().orElseThrow(DeliveryDocumentService::missing);
files.metadata(customerId, order.getId(), category, fileId);
return new Download(file, files.download(customerId, order.getId(), category, fileId));
}
public record Download(DeliveryDocumentDtos.File file, byte[] content) { }
private DeliveryOrder require(String customerId, String deliveryNo) {
DeliveryOrder order = orders.findByNo(customerId, deliveryNo);
if (order == null) throw missing();
return order;
}
private Detail detail(DeliveryOrder order, boolean replayed) {
List<Category> categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson());
return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(),
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
}
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); }
}
private Detail read(String value) {
try { return objectMapper.readValue(value, Detail.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); }
}
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
}
@@ -638,7 +638,7 @@ public class DeliveryTransferService {
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
value(candidate.getAvailablePackages())); value(candidate.getAvailablePackages()));
} }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
@@ -22,6 +22,7 @@ public final class OverseasPermissions {
public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list"; public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list";
public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return"; public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return";
public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status"; public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status";
public static final String DELIVERY_DOCUMENTS = "platform:overseas-warehouse:delivery:documents";
public static final String DISPATCH_LIST = "platform:overseas-warehouse:dispatch:list"; public static final String DISPATCH_LIST = "platform:overseas-warehouse:dispatch:list";
public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit"; public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit";
public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel"; public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel";
@@ -260,6 +260,8 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
waybill_no VARCHAR(128) NULL COMMENT '承运运单号', waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识',
documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照',
delivery_method VARCHAR(32) NULL COMMENT '派送方式', delivery_method VARCHAR(32) NULL COMMENT '派送方式',
carrier_name VARCHAR(255) NULL COMMENT '派送承运商', carrier_name VARCHAR(255) NULL COMMENT '派送承运商',
pickup_method VARCHAR(64) NULL COMMENT '提货方式', pickup_method VARCHAR(64) NULL COMMENT '提货方式',
@@ -306,6 +308,21 @@ CREATE TABLE IF NOT EXISTS ll_delivery_fee (
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id) created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
) ENGINE=InnoDB COMMENT='派送单费用明细'; ) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (
id BIGINT NOT NULL COMMENT '派送资料历史ID',
customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键',
delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
version BIGINT NOT NULL COMMENT '保存成功时派送单版本',
idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希',
snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照',
operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID',
created_at DATETIME(3) NOT NULL COMMENT '资料保存时间',
PRIMARY KEY(id),
UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key),
UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version)
) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录';
CREATE TABLE IF NOT EXISTS ll_delivery_operation ( CREATE TABLE IF NOT EXISTS ll_delivery_operation (
id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称',
@@ -0,0 +1,180 @@
openapi: 3.0.3
info:
title: 派送单三类资料
version: 1.0.0
description: |
所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。
GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。
状态 PUT /delivery-orders/{deliveryNo}/status 请求保持 {status,expectedVersion,remark},expectedVersion 必传。
START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。
资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。
security: [{ bearerAuth: [] }]
paths:
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
get:
summary: 查询已保存三类资料
description: 权限 platform:overseas-warehouse:delivery:list;空资料仍返回全部三类MISSING。上传未保存不计入摘要。
responses:
'200': { description: 当前快照, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
put:
summary: 原子保存全部类别绑定与BOL号
description: |
权限 platform:overseas-warehouse:delivery:documents。documents为全量快照,遗漏类别清空;Admin须提交完整三类。
同一派送单锁下校验expectedVersion、全部文件客户/派送单/类别归属后一起提交,任一失败不写任何绑定。
保存增加派送version,与状态修改共享版本;幂等键按客户+派送单限定,同键同请求重放原结果、异请求冲突。
替换/删除通过修改fileIds并保存表达,只解除当前绑定,历史对象和版本快照不可变保留。
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/Save' } } }
responses:
'200': { description: 保存结果或统一业务错误, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
- { $ref: '#/components/parameters/Category' }
post:
summary: 上传一个待绑定文件
description: |
权限 platform:overseas-warehouse:delivery:documents。FormData唯一file,无额外幂等头。
File沿用ARBITRARY任意格式、非空且不超过52428800字节(50MiB),每类别最多20个文件由保存校验。
上传仅产生不可变对象,不修改派送version或摘要;取消弹框/未保存不落绑定,不物理删除File对象。
requestBody:
required: true
content:
multipart/form-data:
schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
responses:
'200':
description: 单文件权威元数据
content:
application/json:
schema:
allOf:
- { $ref: '#/components/schemas/Envelope' }
- type: object
properties: { data: { $ref: '#/components/schemas/File' } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/history:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
get:
summary: 查看不可变资料保存历史
description: 权限 platform:overseas-warehouse:delivery:list;版本倒序,仅当前客户和派送单。
responses:
'200':
description: 历史快照
content:
application/json:
schema:
allOf:
- { $ref: '#/components/schemas/Envelope' }
- type: object
properties:
data:
type: array
items:
type: object
properties:
version: { type: integer, format: int64 }
operatorId: { type: string }
savedAt: { type: string, format: date-time }
snapshot: { $ref: '#/components/schemas/Detail' }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files/{fileId}/download:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
- { $ref: '#/components/parameters/Category' }
- { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { in: query, name: revisionVersion, required: false, schema: { type: integer, format: int64, minimum: 1 } }
get:
summary: 下载当前或指定保存版本已绑定文件
description: 权限 platform:overseas-warehouse:delivery:list;省略revisionVersion只允许当前绑定。历史版本仍严格校验客户/单据/类别。未保存或跨单文件拒绝;附件响应no-store/nosniff。
responses:
'200': { description: 文件内容, content: { application/octet-stream: { schema: { type: string, format: binary } } } }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
DeliveryNo: { in: path, name: deliveryNo, required: true, schema: { type: string } }
CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } }
schemas:
CategoryCode:
type: string
enum: [LOADING_PHOTO, BOL, RECEIPT]
description: 依次为装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)。BOL签名由用户提供,不进行OCR或电子签署验证。
Save:
type: object
required: [expectedVersion, idempotencyKey, documents]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
bolNo: { type: string, nullable: true, maxLength: 128, description: 不作为文件复用或归属匹配依据;空值清空 }
documents:
type: array
maxItems: 3
items:
type: object
required: [category, fileIds]
properties:
category: { $ref: '#/components/schemas/CategoryCode' }
fileIds: { type: array, maxItems: 20, uniqueItems: true, items: { type: string, pattern: '^[1-9][0-9]*$' } }
File:
type: object
required: [fileId, fileName, sizeBytes, uploadedAt]
properties:
fileId: { type: string, pattern: '^[1-9][0-9]*$' }
fileName: { type: string }
contentType: { type: string, nullable: true }
sizeBytes: { type: integer, format: int64, minimum: 1, maximum: 52428800 }
uploadedAt: { type: string, format: date-time, description: File持久化的真实创建时间 }
CategorySummary:
type: object
required: [category, status, fileCount]
properties:
category: { $ref: '#/components/schemas/CategoryCode' }
status: { type: string, enum: [UPLOADED, MISSING] }
fileCount: { type: integer, minimum: 0, maximum: 20 }
DocumentSummary:
type: object
required: [uploadedCategoryCount, totalCategoryCount, categories]
properties:
uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 }
totalCategoryCount: { type: integer, enum: [3] }
categories: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/CategorySummary' } }
Detail:
type: object
required: [deliveryNo, customerId, version, uploadedCategoryCount, totalCategoryCount, categories, allowedActions, replayed]
properties:
deliveryNo: { type: string }
customerId: { type: string }
version: { type: integer, format: int64 }
bolNo: { type: string, nullable: true }
uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 }
totalCategoryCount: { type: integer, enum: [3] }
categories:
type: array
minItems: 3
maxItems: 3
items:
allOf:
- { $ref: '#/components/schemas/CategorySummary' }
- type: object
required: [files]
properties: { files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } } }
allowedActions: { type: array, items: { type: string, enum: [MANAGE_DOCUMENTS] }, description: 返回时按当前认证授权裁剪;只读用户为空,历史和幂等重放也不能沿用旧权限 }
replayed: { type: boolean }
Envelope:
type: object
properties:
code: { type: string }
message: { type: string }
DetailResponse:
allOf:
- { $ref: '#/components/schemas/Envelope' }
- type: object
properties: { data: { $ref: '#/components/schemas/Detail' } }
@@ -1,6 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Lastleg Overseas Business API title: Lastleg Overseas Business API
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
version: 1.0.0 version: 1.0.0
paths: paths:
/api/platform/v1/last-leg/inventory/balances/{balanceId}: /api/platform/v1/last-leg/inventory/balances/{balanceId}:
@@ -107,6 +107,30 @@ class CustomerVisitServiceTest {
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
} }
@Test
void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() {
DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
var started = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 0L, "开始派送"));
assertThat(started.deliveryStatus()).isEqualTo("DELIVERING");
assertThat(delivery.getVersion()).isEqualTo(1);
assertThat(delivery.getDispatchedAt()).isNotNull();
assertThat(delivery.getSignedAt()).isNull();
verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class));
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var completed = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED");
assertThat(delivery.getSignedAt()).isNotNull();
assertThat(delivery.getVersion()).isEqualTo(2);
assertThat(completed.visitId()).isEqualTo(33);
assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue();
verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery);
}
private DeliveryOrder delivery(String status) { private DeliveryOrder delivery(String status) {
DeliveryOrder delivery = new DeliveryOrder(); DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(11L); delivery.setId(11L);
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.controller.DeliveryDocumentController;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.*;
import java.nio.file.*;
import static org.assertj.core.api.Assertions.*;
class DeliveryDocumentContractTest {
@Test void missingVersionNestedNullIdsAndTooManyFilesAreRejected() {
try(var factory=Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
assertThat(validator.validate(new CustomerVisitRequests.DeliveryStatus("DELIVERING",null,"123")))
.anyMatch(v->v.getPropertyPath().toString().equals("expectedVersion"));
assertThat(validator.validate(new Save(null,"key",null,List.of()))).isNotEmpty();
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
Arrays.asList((Long)null)))))).isNotEmpty();
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
java.util.stream.LongStream.rangeClosed(1,21).boxed().toList()))))).isNotEmpty();
}
}
@Test void removedCategoriesAreNotAcceptedAndOnlyThreeRemainInOrder() {
assertThat(DeliveryDocumentCategory.values()).containsExactly(DeliveryDocumentCategory.LOADING_PHOTO,
DeliveryDocumentCategory.BOL, DeliveryDocumentCategory.RECEIPT);
for(String category:List.of("POD","EXPRESS_RECEIPT","CUSTOMER_DOCUMENT","BILL_OF_LADING","DELIVERY_PHOTO")) {
assertThatThrownBy(()->new ObjectMapper().readValue("\""+category+"\"",DeliveryDocumentCategory.class))
.isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class);
}
}
@Test void readDownloadAndWritePermissionsAreSeparate() {
for(var method:DeliveryDocumentController.class.getDeclaredMethods()) {
if(!Set.of("detail","history","download","save","upload").contains(method.getName())) continue;
String suffix=Set.of("save","upload").contains(method.getName())?"documents":"list";
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:overseas-warehouse:delivery:"+suffix+"')");
}
}
@Test void baselineAndOverseasEntryContainSameImmutableRevisionTable() throws Exception {
String baseline=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
String overseas=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
String marker="CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (";
String a=baseline.substring(baseline.indexOf(marker),baseline.indexOf(";",baseline.indexOf(marker))+1);
String b=overseas.substring(overseas.indexOf(marker),overseas.indexOf(";",overseas.indexOf(marker))+1);
assertThat(a.replaceAll("\\s+","")).isEqualTo(b.replaceAll("\\s+",""));
assertThat(a).contains("uk_delivery_document_command","uk_delivery_document_version");
}
}
@@ -66,6 +66,7 @@ class DeliveryLongIdJsonContractTest {
DELIVERY_ID, DELIVERY_ID,
null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of()); 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null));
} }
} }
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DeliveryDocumentControllerTest {
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test void readonlyUserGetsNoWriteActionsInCurrentReplayOrHistory() throws Exception { verifyActions(false); }
@Test void currentWritePermissionRetainsManagementAction() throws Exception { verifyActions(true); }
private void verifyActions(boolean writer) throws Exception {
var service=mock(DeliveryDocumentService.class);
var controller=new DeliveryDocumentController(); ReflectionTestUtils.setField(controller,"service",service);
var user=new AuthenticatedUser(9L,0L,null,null,"reader","PLATFORM","WEB",Set.of());
var authorities=writer?List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_DOCUMENTS)):
List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_LIST));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,authorities));
var snapshot=new Detail("DL-1","100",1,null,0,3,List.of(),List.of("MANAGE_DOCUMENTS"),true);
when(service.detail("100","DL-1")).thenReturn(snapshot);
when(service.history("100","DL-1")).thenReturn(List.of(new History(1,"9",LocalDateTime.now(),snapshot)));
var request=new Save(0L,"key",null,List.of());
when(service.save("100","DL-1","9",request)).thenReturn(snapshot);
var json=new ObjectMapper().findAndRegisterModules();
assertThat(json.valueToTree(controller.detail(user,"DL-1","100")).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
assertThat(json.valueToTree(controller.save(user,"DL-1","100",request)).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
assertThat(json.valueToTree(controller.history(user,"DL-1","100")).at("/data/0/snapshot/allowedActions").size()).isEqualTo(writer?1:0);
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.delivery.gateway;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
class HttpDeliveryDocumentFileGatewayTest {
HttpDeliveryDocumentFileGateway gateway;
MockRestServiceServer server;
@BeforeEach void setUp() {
var builder=RestClient.builder().baseUrl("http://file.test");
server=MockRestServiceServer.bindTo(builder).build();
gateway=new HttpDeliveryDocumentFileGateway();
ReflectionTestUtils.setField(gateway,"client",builder.build());
ReflectionTestUtils.setField(gateway,"internalToken","test-service-token");
}
@Test void validatesExactOwnershipAndUsesFileCreationTime() {
expect(metadata("100","10","DELIVERY_BOL","11"));
var file=gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11);
assertThat(file.fileId()).isEqualTo(11);
assertThat(file.uploadedAt().toString()).isEqualTo("2026-09-09T08:00");
server.verify();
}
@ParameterizedTest
@ValueSource(strings={"customer","delivery","category","file"})
void rejectsCrossCustomerCrossDeliveryWrongCategoryAndWrongFile(String mismatch) {
expect(metadata(mismatch.equals("customer")?"200":"100",mismatch.equals("delivery")?"20":"10",
mismatch.equals("category")?"DELIVERY_RECEIPT":"DELIVERY_BOL",mismatch.equals("file")?"12":"11"));
assertThatThrownBy(()->gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11))
.hasMessageContaining("不属于");
server.verify();
}
private void expect(String json) {
server.expect(requestTo("http://file.test/internal/file/v1/business-files/100/DELIVERY_BOL/10/11"))
.andExpect(header("X-Internal-Service-Token","test-service-token"))
.andRespond(withSuccess(json,MediaType.APPLICATION_JSON));
}
private String metadata(String customer,String delivery,String type,String file) {
return "{\"code\":\"200\",\"data\":{\"fileId\":"+file+",\"tenantId\":"+customer+
",\"businessType\":\""+type+"\",\"businessId\":\""+delivery+"\",\"fileName\":\"test.pdf\","+
"\"contentType\":\"application/pdf\",\"sizeBytes\":10,\"createdAt\":\"2026-09-09T08:00:00\"}}";
}
}
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.model.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*;
@ExtendWith(MockitoExtension.class)
class DeliveryDocumentServiceTest {
@Mock DeliveryOrderMapper orders;
@Mock DeliveryDocumentRevisionMapper revisions;
@Mock DeliveryOperationMapper operations;
@Mock DeliveryDocumentFileGateway files;
DeliveryDocumentService service;
DeliveryOrder order;
@BeforeEach void setUp() {
service = new DeliveryDocumentService();
ReflectionTestUtils.setField(service,"orders",orders);
ReflectionTestUtils.setField(service,"revisions",revisions);
ReflectionTestUtils.setField(service,"operations",operations);
ReflectionTestUtils.setField(service,"files",files);
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100");
order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED");
}
@Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
var result=service.save("100","DL-1","9",request(0,"save",List.of(new Binding(BOL,List.of(11L,12L)))));
assertThat(result.uploadedCategoryCount()).isEqualTo(1);
assertThat(result.categories()).hasSize(3);
assertThat(result.categories().get(1).fileCount()).isEqualTo(2);
assertThat(result.version()).isEqualTo(1);
assertThat(result.categories().get(1).files().get(0).uploadedAt()).isEqualTo(file(11).uploadedAt());
when(orders.findByNo("100","DL-1")).thenReturn(order);
assertThat(service.detail("100","DL-1")).isEqualTo(result);
assertThat(DeliveryDocumentSnapshot.summary(order.getDocumentsJson()).uploadedCategoryCount()).isEqualTo(1);
verify(revisions).insert(any(DeliveryDocumentRevision.class)); verify(operations).insert(any(DeliveryOperation.class));
}
@Test void staleVersionDoesNotValidateFilesOrWrite() {
order.setVersion(2L); when(orders.lockByNo("100","DL-1")).thenReturn(order);
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"stale",List.of())))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verifyNoInteractions(files,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
}
@Test void anyInvalidOwnershipRejectsWholeSnapshotBeforeMutation() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
when(files.metadata("100",10,RECEIPT,12)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"归属错误"));
assertThatThrownBy(()->service.save("100","DL-1","9",request(0,"bad",List.of(
new Binding(BOL,List.of(11L)),new Binding(RECEIPT,List.of(12L)))))).hasMessageContaining("归属");
assertThat(order.getDocumentsJson()).isNull(); assertThat(order.getVersion()).isZero();
verify(orders,never()).updateById(any(DeliveryOrder.class)); verifyNoInteractions(operations);
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
}
@Test void exactReplayDoesNotWriteOrRevalidateAndChangedPayloadConflicts() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
var request=request(0,"same",List.of());
var first=service.save("100","DL-1","9",request);
ArgumentCaptor<DeliveryDocumentRevision> saved=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
verify(revisions).insert(saved.capture());
when(revisions.findCommand("100",10,"same")).thenReturn(saved.getValue());
assertThat(service.save("100","DL-1","9",request).replayed()).isTrue();
assertThat(first.version()).isEqualTo(1);
verify(orders,times(1)).updateById(any(DeliveryOrder.class));
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"same",List.of())))
.hasMessageContaining("幂等键");
}
@Test void replacementRemovalPreservesHistoryAndCurrentDownloadRejectsRemovedFile() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
service.save("100","DL-1","9",request(0,"first",List.of(new Binding(BOL,List.of(11L)))));
ArgumentCaptor<DeliveryDocumentRevision> first=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
verify(revisions).insert(first.capture());
assertThat(service.save("100","DL-1","9",request(1,"clear",List.of())).uploadedCategoryCount()).isZero();
when(orders.findByNo("100","DL-1")).thenReturn(order);
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
when(revisions.history("100",10)).thenReturn(List.of(first.getValue()));
when(files.download("100",10,BOL,11)).thenReturn(new byte[]{1});
assertThat(service.download("100","DL-1",BOL,11,1L).content()).containsExactly((byte)1);
}
@Test void uploadWithoutSaveNeverChangesSummaryAndUnboundFileCannotDownload() {
when(orders.findByNo("100","DL-1")).thenReturn(order);
var input=new MockMultipartFile("file","a.pdf","application/pdf",new byte[]{1});
when(files.upload("100",10,BOL,"9",input)).thenReturn(file(11));
assertThat(service.upload("100","DL-1",BOL,"9",input).fileId()).isEqualTo(11);
assertThat(service.detail("100","DL-1").uploadedCategoryCount()).isZero();
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(revisions,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
}
@Test void anotherCustomerOrDeliveryCannotLoadOrBindFiles() {
assertThatThrownBy(()->service.detail("200","DL-1")).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->service.save("100","DL-2","9",request(0,"wrong",List.of())))
.isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(files,revisions,operations);
}
@Test void allThreeCategoriesCountToThreeRegardlessOfFileMultiplicity() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,LOADING_PHOTO,11)).thenReturn(file(11));
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13));
var result=service.save("100","DL-1","9",request(0,"all",List.of(
new Binding(LOADING_PHOTO,List.of(11L)),new Binding(BOL,List.of(12L)),new Binding(RECEIPT,List.of(13L)))));
assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3);
assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED"));
}
private Save request(long version,String key,List<Binding> bindings) { return new Save(version,key,"BOL-1",bindings); }
private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); }
}
+2 -1
View File
@@ -12,6 +12,7 @@
| 目标 | 入口 | | 目标 | 入口 |
|---|---| |---|---|
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` | | 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
| 平台批量审核预览 | `controller/PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`service/BatchOrderReviewPreviewService.java`、`dto/BatchOrderReviewPreviewResponse.java`;按目标客户和请求顺序一次返回订单、中文货物摘要、渠道、目的仓及逐项 `canApprove/blockReason`,实现固定两次批量查询,禁止前端逐票读取详情 |
| 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` | | 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` |
| Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序,不读取或修复 Firstleg 投影 | | Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序,不读取或修复 Firstleg 投影 |
| 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` | | 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` |
@@ -23,7 +24,7 @@
| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | | 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` |
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 | | 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对国内货柜履约需求确认表与开工门禁 | | 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对国内货柜履约需求确认表与开工门禁 |
| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java` 向 Firstleg 提供箱唛生成的 SKU 快照来源;`CargoLabelBinding.skuNo` 对 `ONE_CLICK_DROPSHIP` 为强制字段。Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java`,变更两端 DTO 必须同步。 | | 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java` 向 Firstleg 批量提供箱唛生成的 SKU、尺寸与货件快照;`CargoLabelBinding.skuNo` 对 `ONE_CLICK_DROPSHIP` 为强制字段,`shipmentId` 只取对应货物行,不回退订单顶层字段。Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java`,变更两端 DTO 必须同步。 |
| 智能装柜待审核候选内部查询 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java`,契约 `src/main/resources/openapi/order-v1.yaml` | | 智能装柜待审核候选内部查询 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java`,契约 `src/main/resources/openapi/order-v1.yaml` |
| 快照/Outbox | `service/`、`repository/`、`event/` | | 快照/Outbox | `service/`、`repository/`、`event/` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
+4
View File
@@ -37,6 +37,10 @@ export async function run(context) {
## 可用上下文 ## 可用上下文
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。
- `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。 - `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。 - `requestBinary(method, path)`:文件下载,不把二进制写入报告。
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。 - `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
@@ -224,9 +224,9 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
if (!requirement?.id) return null if (!requirement?.id) return null
for (let index = 0; index < chains.length; index += 1) { for (let index = 0; index < chains.length; index += 1) {
const chain = chains[index] const chain = chains[index]
const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`) const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${chain.containerBookingId}`)
const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse, const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse,
evidence(detailResponse), OPTIONS) evidence(detailResponse), OPTIONS)?.container
const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`) const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`)
const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, { const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), containerNo, oceanBookingId, requirementId: id(requirement.id), containerNo,
@@ -1,9 +1,52 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { containerWorkbenchGaps } from './phase3-container-loading.mjs'
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8') const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
test('workbench DTO rejects the old flat entity and incomplete action/state projections', () => {
const container = { id: '71', cabinetNo: 'CB-20260909-01', version: 0 }
const row = { container, workbenchStage: 'WAIT_LOADING', statusSummary: {}, allowedActions: [] }
assert.deepEqual(containerWorkbenchGaps(row), [])
assert.ok(containerWorkbenchGaps(container).includes('container.id'))
for (const field of ['container', 'workbenchStage', 'statusSummary', 'allowedActions']) {
const invalid = { ...row }
delete invalid[field]
assert.ok(containerWorkbenchGaps(invalid).length > 0, `missing ${field} must fail`)
}
assert.ok(containerWorkbenchGaps({ ...row, container: { ...container, version: null } }).includes('container.version'))
})
test('Admin read routes match controller workbench endpoints and default HTTP regression exercises the page DTO', () => {
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java')
const admin = read('../../../apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts')
assert.match(controller, /@GetMapping\("\/domestic"\)/)
assert.match(controller, /@GetMapping\("\/domestic\/\{id\}"\)/)
assert.match(controller, /ApiResponse<PageResult<WorkbenchItem>>/)
assert.match(admin, /listContainers\([^\n]*workbench: ContainerWorkbenchKind = 'DOMESTIC'/)
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}`'))
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`'))
const loading = read('./phase3-container-loading.mjs')
assert.ok(loading.includes('size=20&scope=ACTIVE&status='))
assert.ok(loading.includes('containerWorkbenchGaps(containerDetailResponse.body?.data)'))
assert.ok(loading.includes('records(page).every((item) => containerWorkbenchGaps(item).length === 0)'))
assert.ok(!loading.includes('value?.container || value'))
})
test('manual and coload consumers read domestic WorkbenchItem rather than the removed flat routes', () => {
for (const name of ['manual-loading-complete-fixture', 'manual-order-splitting-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'coload-groups']) {
const source = read(`./${name}.mjs`)
assert.ok(source.includes("context.request('GET', `${CONTAINERS}/domestic/${"), name)
assert.ok(!source.includes("context.request('GET', `${CONTAINERS}/${"), name)
assert.match(source, /OPTIONS\)\?\.container/, name)
}
const states = read('./manual-state-loading-pda.mjs')
assert.ok(states.includes('`${CONTAINERS}/domestic?scope=ACTIVE&status='))
assert.ok(states.includes('item.container?.planStatus === status'))
})
test('domestic container fixtures use the current workbench detail route', () => { test('domestic container fixtures use the current workbench detail route', () => {
const containerLoading = read('./phase3-container-loading.mjs') const containerLoading = read('./phase3-container-loading.mjs')
assert.ok(containerLoading.includes("context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)")) assert.ok(containerLoading.includes("context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)"))
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { completeDropshipPalletization } from './inbound-measurement.mjs'
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
@@ -102,7 +103,10 @@ test('dropship measurement completes the formal palletization workflow before sm
assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/) assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/)
assert.match(source, /cartons\/resolve/) assert.match(source, /cartons\/resolve/)
assert.match(source, /cartons\/count/) assert.match(source, /cartons\/count/)
assert.match(source, /pallets\/bind-pending/) assert.match(source, /pallets\/create-and-bind/)
assert.match(source, /系统发码重放不新增托盘且业务码和计数稳定/)
assert.match(source, /拒绝创建没有空托或计数漂移/)
assert.doesNotMatch(source, /palletHash|palletDate/)
assert.match(source, /pallets\/\$\{palletId\}\/seal/) assert.match(source, /pallets\/\$\{palletId\}\/seal/)
assert.match(source, /重复清点操作按operationId幂等返回/) assert.match(source, /重复清点操作按operationId幂等返回/)
assert.doesNotMatch(source, /pallets\/open/) assert.doesNotMatch(source, /pallets\/open/)
@@ -117,3 +121,73 @@ test('confirmed package count reconciles an uncertain transport result before re
assert.match(source, /Number\(detail\?\.version\) > Number\(body\.measurementVersion\)/) assert.match(source, /Number\(detail\?\.version\) > Number\(body\.measurementVersion\)/)
assert.match(source, /await updateConfirmedPackageCount\(context, measurementPath/) assert.match(source, /await updateConfirmedPackageCount\(context, measurementPath/)
}) })
async function exerciseSystemPalletScenario(fault = '') {
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [] }
const seenCounts = new Map()
const seenCreates = new Set()
const checks = []
const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) } })
const context = {
marker: 'fixture-system-pallet',
apiSuccess(_module, name, result) {
assert.equal(result.body.code, '200', name)
return result.body.data
},
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
async request(method, path, body) {
if (path.includes('/pending?')) return response([task])
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, expectedSkuQuantity: 10 })
if (path.endsWith('/cartons/count')) {
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
task.countedCartons += 1
const targetPallet = task.pallets.find(pallet => pallet.palletNo === body.palletNo)
if (body.palletNo) { task.boundCartons += 1; targetPallet.cartonCount += 1; targetPallet.version += 1 }
else task.unboundCartons += 1
const result = response({ ...task, carton: { boxCode: body.boxCode, expectedSkuQuantity: 10, actualSkuQuantity: 10,
discrepancyStatus: 'MATCH' }, pallet: body.palletNo ? targetPallet : null })
seenCounts.set(body.operationId, result)
return result
}
if (path.endsWith('/create-and-bind')) {
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'warehouseId'])
if (seenCreates.has(body.operationId)) {
if (fault === 'duplicate-replay') task.pallets.push({ ...task.pallets[0], palletId: '999', palletNo: 'DP-260909-0999' })
return response(task)
}
if (!task.unboundCartons) {
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'OPEN', version: 0 })
return response(null, 'A0409')
}
seenCreates.add(body.operationId)
task.pallets.push({ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: task.unboundCartons, status: 'OPEN', version: 0 })
task.boundCartons += task.unboundCartons
task.unboundCartons = 0
return response(task)
}
if (path.endsWith('/84/seal')) {
task.taskStatus = 'COMPLETED'
task.pallets.forEach(pallet => { pallet.status = 'SEALED'; pallet.version += 1 })
return response(task)
}
throw new Error(`Unexpected scenario request: ${method} ${path}`)
},
}
await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
return checks
}
test('system-issued pallet HTTP scenario covers count, replay, empty rejection and completed replay', async () => {
const checks = await exerciseSystemPalletScenario()
assert.ok(checks.length >= 15)
assert.deepEqual(checks.filter(check => !check.passed), [])
})
test('system-issued pallet HTTP regression detects duplicate replay and orphan empty pallets', async () => {
const replayChecks = await exerciseSystemPalletScenario('duplicate-replay')
assert.ok(replayChecks.some(check => check.name.includes('系统发码重放不新增托盘') && !check.passed))
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
assert.ok(emptyChecks.some(check => check.name.includes('拒绝创建没有空托或计数漂移') && !check.passed))
})
@@ -113,7 +113,7 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta
return { response, measurement, attempts } return { response, measurement, attempts }
} }
async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) { export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
const pendingResponse = await context.request('GET', const pendingResponse = await context.request('GET',
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`) `/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`)
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse, const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
@@ -135,15 +135,31 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
`taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS) `taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS)
const totalCartons = Number(measurement?.actualPackageCount) const totalCartons = Number(measurement?.actualPackageCount)
const palletDate = new Date().toISOString().slice(2, 10).replace(/-/g, '') const createPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/create-and-bind`
let palletHash = 0 const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
for (const character of `${context.marker}:${taskId}`) { const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) }
palletHash = (Math.imul(palletHash, 31) + character.charCodeAt(0)) >>> 0 let palletNo = ''
}
const palletNo = `DP-${palletDate}-${String(palletHash % 10_000).padStart(4, '0')}`
let countedCartons = 0 let countedCartons = 0
let pallet = null let pallet = null
const palletSnapshot = (value) => JSON.stringify({
counted: value?.countedCartons, bound: value?.boundCartons, unbound: value?.unboundCartons,
pallets: value?.pallets?.map((item) => ({ id: asId(item.palletId), no: item.palletNo,
count: item.cartonCount, status: item.status, version: item.version })),
})
async function rejectEmptyCreation(before, suffix) {
const response = await context.request('POST', createPath,
{ ...createPayload, operationId: `${context.marker}-dropship-empty-${suffix}`.slice(0, 128) })
context.assert(MODULE, `无待绑箱创建托盘被拒绝-${suffix}`, response,
response.http === 200 && String(response.body?.code) === 'A0409',
`HTTP=${response.http}, code=${response.body?.code || 'missing'}`, OPTIONS)
const afterResponse = await context.request('GET', taskPath)
const after = context.apiSuccess(MODULE, `拒绝创建后回查任务-${suffix}`, afterResponse, '', OPTIONS)
context.assert(MODULE, `拒绝创建没有空托或计数漂移-${suffix}`, afterResponse,
palletSnapshot(after) === palletSnapshot(before), `pallets=${after?.pallets?.length ?? 'missing'}`, OPTIONS)
}
await rejectEmptyCreation(claimed, 'before-count')
for (let index = 0; index < totalCartons; index += 1) { for (let index = 0; index < totalCartons; index += 1) {
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}` const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
const resolveResponse = await context.request('GET', const resolveResponse = await context.request('GET',
@@ -189,18 +205,21 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons), && Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS) `counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
const bindResponse = await context.request('POST', const bindResponse = await context.request('POST', createPath, createPayload)
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, { const bound = context.apiSuccess(MODULE, '系统发码创建托盘并绑定全部待组托箱', bindResponse,
warehouseId, palletNo, `taskId=${taskId}`, OPTIONS)
operationId: `${context.marker}-dropship-bind-pending`.slice(0, 128), pallet = bound?.pallets?.find((item) => Number(item.cartonCount) === 1) || null
}) palletNo = pallet?.palletNo || ''
const bound = context.apiSuccess(MODULE, '扫描托盘并绑定全部待组托箱', bindResponse,
`taskId=${taskId}, palletNo=${palletNo}`, OPTIONS)
pallet = bound?.pallets?.find((item) => item?.palletNo === palletNo) || null
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse, context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1 Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
&& pallet?.status === 'OPEN', && pallet?.status === 'OPEN' && /^DP-\d{6}-\d{4}$/.test(palletNo)
&& bound?.pallets?.length === 1,
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS) `unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
const createReplayResponse = await context.request('POST', createPath, createPayload)
const createReplay = context.apiSuccess(MODULE, '系统发码同operationId重放', createReplayResponse, '', OPTIONS)
context.assert(MODULE, '系统发码重放不新增托盘且业务码和计数稳定', createReplayResponse,
palletSnapshot(createReplay) === palletSnapshot(bound), `palletNo=${palletNo}`, OPTIONS)
await rejectEmptyCreation(bound, 'after-bind')
} else { } else {
pallet = counted?.pallet || pallet pallet = counted?.pallet || pallet
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse, context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
@@ -229,6 +248,11 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
&& Number(sealed?.unboundCartons) === 0 && Number(sealed?.unboundCartons) === 0
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'), && sealed?.pallets?.every((item) => item?.status === 'SEALED'),
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS) `taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
const sealedReplayResponse = await context.request('POST', createPath, createPayload)
const sealedReplay = context.apiSuccess(MODULE, '封托完成后系统发码原操作仍可重放', sealedReplayResponse, '', OPTIONS)
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
return sealed return sealed
} }
@@ -86,8 +86,8 @@ export async function run(context) {
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), && !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS) `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`) const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(source.containerBookingId)}`)
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS) const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)?.container
const bookingResponse = container?.oceanBookingId const bookingResponse = container?.oceanBookingId
? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`) ? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`)
: containerResponse : containerResponse
@@ -41,8 +41,8 @@ export async function run(context) {
OPTIONS) OPTIONS)
if (!bookingId) return if (!bookingId) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`) const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(bookingId)}`)
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS) const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse, context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse,
id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId) id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId)
&& container?.channelCode === manifest.channelCode, && container?.channelCode === manifest.channelCode,
@@ -46,8 +46,8 @@ export async function run(context) {
OPTIONS) OPTIONS)
if (!plan || beforeItems.length === 0) return if (!plan || beforeItems.length === 0) return
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS) const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse, context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null && container?.containerNo == null && container?.oceanBookingId == null
@@ -47,8 +47,8 @@ export async function run(context) {
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`, `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
OPTIONS) OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS) const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)?.container
const oceanBookingId = id(container?.oceanBookingId) const oceanBookingId = id(container?.oceanBookingId)
const realContainerNo = String(container?.containerNo || '') const realContainerNo = String(container?.containerNo || '')
context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse, context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse,
@@ -47,8 +47,8 @@ export async function run(context) {
context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse, context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse,
rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS) rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS) const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse, context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse,
id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null
&& id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode, && id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode,
@@ -87,8 +87,8 @@ export async function run(context) {
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`, `HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`,
OPTIONS) OPTIONS)
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS) const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)?.container
context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse, context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse,
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
&& container?.containerNo == null && container?.oceanBookingId == null, && container?.containerNo == null && container?.oceanBookingId == null,
@@ -316,10 +316,10 @@ async function complete(context, chain, label) {
} }
async function verifyContainer(context, status, fixture) { async function verifyContainer(context, status, fixture) {
const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`) const response = await context.request('GET', `${CONTAINERS}/domestic?scope=ACTIVE&status=${status}&page=1&size=100`)
const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS) const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS)
context.assert(MODULE, `${status} 货柜样本保留`, response, context.assert(MODULE, `${status} 货柜样本保留`, response,
records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status), records(page).some((item) => id(item.container?.id) === fixture.id && item.container?.planStatus === status),
`id=${fixture.id}`, OPTIONS) `id=${fixture.id}`, OPTIONS)
} }
@@ -22,7 +22,18 @@ function failure(response) {
|| (response?.http === 200 && codeOf(response) !== '200') || (response?.http === 200 && codeOf(response) !== '200')
} }
function records(page) { return Array.isArray(page?.records) ? page.records : [] } function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function containerRecord(value) { return value?.container || value } function containerRecord(value) { return value?.container }
export function containerWorkbenchGaps(value) {
return [
!value?.container?.id && 'container.id',
!value?.container?.cabinetNo && 'container.cabinetNo',
!Number.isInteger(value?.container?.version) && 'container.version',
!value?.workbenchStage && 'workbenchStage',
(!value?.statusSummary || typeof value.statusSummary !== 'object') && 'statusSummary',
!Array.isArray(value?.allowedActions) && 'allowedActions',
].filter(Boolean)
}
async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) { async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) {
let response = null let response = null
@@ -262,6 +273,18 @@ export async function run(context) {
const containerDetailResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`) const containerDetailResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
const containerDetail = containerRecord(context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS)) const containerDetail = containerRecord(context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS))
const workbenchGaps = containerWorkbenchGaps(containerDetailResponse.body?.data)
context.assert(MODULE, '货柜详情返回正式工作台 DTO 而非旧扁平实体', containerDetailResponse,
workbenchGaps.length === 0, `missing=${workbenchGaps.join('|') || 'none'}`, OPTIONS)
const pageResponse = await context.request('GET',
`${DOMESTIC_CONTAINERS}?page=1&size=20&scope=ACTIVE&status=${encodeURIComponent(containerDetail?.planStatus || '')}&keyword=${encodeURIComponent(containerDetail?.cabinetNo || '')}`)
const page = context.apiSuccess(MODULE, '按货柜页面筛选参数查询本轮模拟柜', pageResponse, '', OPTIONS)
const ownRows = records(page).filter((item) => id(item?.container?.id) === containerId)
context.assert(MODULE, '货柜页面列表返回唯一模拟柜和工作台动作状态', pageResponse,
ownRows.length === 1 && records(page).every((item) => containerWorkbenchGaps(item).length === 0)
&& ownRows[0]?.container?.cabinetNo === containerDetail?.cabinetNo
&& ownRows[0]?.container?.planStatus === containerDetail?.planStatus,
`matches=${ownRows.length}, rows=${records(page).length}`, OPTIONS)
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse, context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode (!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode) || containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
@@ -475,7 +498,7 @@ export async function run(context) {
const activeContainersResponse = await context.request('GET', const activeContainersResponse = await context.request('GET',
`${DOMESTIC_CONTAINERS}?page=1&size=50&scope=ACTIVE&keyword=${encodeURIComponent(realContainerNo)}`) `${DOMESTIC_CONTAINERS}?page=1&size=50&scope=ACTIVE&keyword=${encodeURIComponent(realContainerNo)}`)
const activeContainers = context.apiSuccess(MODULE, '关联后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS) const activeContainers = context.apiSuccess(MODULE, '关联后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item.containerNo === realContainerNo) const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item?.containerNo === realContainerNo)
context.assert(MODULE, '关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份', activeContainersResponse, context.assert(MODULE, '关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份', activeContainersResponse,
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId, activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId,
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}`, OPTIONS) `activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}`, OPTIONS)
@@ -592,7 +615,7 @@ export async function run(context) {
if (!taskId || !pickItem?.id) return if (!taskId || !pickItem?.id) return
const releasedContainerResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`) const releasedContainerResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS) const releasedContainer = containerRecord(context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS))
const stableIdentity = { const stableIdentity = {
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId), taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
@@ -26,7 +26,8 @@ const matrix = read('../../../docs/国内货柜履约/货柜管理全链验收
test('container acceptance reads the canonical domestic workbench route', () => { test('container acceptance reads the canonical domestic workbench route', () => {
assert.match(containerLoadingAcceptance, /const DOMESTIC_CONTAINERS = `\$\{CONTAINERS\}\/domestic`/) assert.match(containerLoadingAcceptance, /const DOMESTIC_CONTAINERS = `\$\{CONTAINERS\}\/domestic`/)
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \|\| value \}/) assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \}/)
assert.doesNotMatch(containerLoadingAcceptance, /value\?\.container \|\| value/)
assert.match(containerLoadingAcceptance, /DOMESTIC_CONTAINERS\}\?page=1&size=50&scope=ACTIVE&keyword=/) assert.match(containerLoadingAcceptance, /DOMESTIC_CONTAINERS\}\?page=1&size=50&scope=ACTIVE&keyword=/)
assert.doesNotMatch(containerLoadingAcceptance, /request\('GET', `\$\{CONTAINERS\}\/\$\{containerId\}`\)/) assert.doesNotMatch(containerLoadingAcceptance, /request\('GET', `\$\{CONTAINERS\}\/\$\{containerId\}`\)/)
assert.match(phase3LoadingAcceptance, /\/api\/platform\/v1\/containers\/domestic\/\$\{containerBookingId\}/) assert.match(phase3LoadingAcceptance, /\/api\/platform\/v1\/containers\/domestic\/\$\{containerBookingId\}/)
@@ -25,7 +25,7 @@ function failure(response) {
return [400, 401, 403, 404, 409, 503].includes(response?.http) return [400, 401, 403, 404, 409, 503].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200') || (response?.http === 200 && codeOf(response) !== '200')
} }
function containerRecord(value) { return value?.container || value } function containerRecord(value) { return value?.container }
async function ensureMatsonSupplier(context, records) { async function ensureMatsonSupplier(context, records) {
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME) const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
@@ -79,7 +79,8 @@ test('container API uses one stable long string identity', () => {
assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/) assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/)
assert.match(controller, /convertContainer/) assert.match(controller, /convertContainer/)
assert.match(adminApi, /sourceContainerBookingId: string/) assert.match(adminApi, /sourceContainerBookingId: string/)
assert.match(adminView, /sourceContainerBookingId: container\.sourceContainerBookingId/) assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/)
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/)
}) })
test('backend batch conversion is one transaction and never merges same-customer orders', () => { test('backend batch conversion is one transaction and never merges same-customer orders', () => {
+99 -23
View File
@@ -1,4 +1,4 @@
[CmdletBinding()] [CmdletBinding()]
param() param()
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -126,13 +126,28 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目' Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目'
Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录' Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录'
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态' # 启用状态属于各环境当前登记事实;不得用测试分支的历史任务名称/ID强制重新启用本地清空表。
Assert-Contains $testTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控' $registeredTestRows = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|'))
Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外接柜仓总控' if ($registeredTestRows.Count -eq 0) {
Assert-Contains $testTaskRegistry '| 海外接柜后端 |' '测试环境海外接柜后端专项' Assert-Contains $testTaskRegistry '不启用总控调度' '测试登记清空状态'
Assert-Contains $testTaskRegistry '| 海外接柜Admin前端 |' '测试环境海外接柜 Admin 专项' Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项' '测试登记清空明示'
Assert-Contains $testTaskRegistry '| 海外接柜Client前端 |' '测试环境海外接柜 Client 专项' Assert-Contains $testTaskRegistry '持续授权同步失效' '测试登记清空授权边界'
Assert-Contains $testTaskRegistry '| 海外接柜PDA前端 |' '测试环境海外接柜 PDA 专项' } else {
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
Assert-Contains $testTaskRegistry 'parent_task_id' '测试登记父任务字段'
Assert-Contains $testTaskRegistry 'project_id' '测试登记项目字段'
Assert-Matches $testTaskRegistry '(?m)^\|[^\r\n]*【总控】[^\r\n]*\|' '测试登记总控职责'
$registeredIds = @($registeredTestRows | ForEach-Object { $_.Groups[1].Value })
if (@($registeredIds | Select-Object -Unique).Count -ne $registeredIds.Count) {
Add-Failure '测试登记存在重复 task_id'
}
$taskLines = @($testTaskRegistry -split '\r?\n' | Where-Object { $_ -match '^\|\s*`[0-9a-f-]{36}`\s*\|' })
$controlLines = @($taskLines | Where-Object { $_.Contains('【总控】') })
if ($controlLines.Count -ne 1) { Add-Failure '测试登记必须有且仅有一个总控' }
foreach ($taskLine in $taskLines) {
Assert-Contains $taskLine '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试任务项目隔离'
}
}
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控' Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId' Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId'
@@ -157,13 +172,13 @@ foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围' Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
# 新增稳定业务能力必须从根表和目标部署单元直接命中。 # 新增稳定业务能力必须从根表和目标部署单元直接命中。
Assert-Contains $rootAgents '入库扣货、解除与凭证' '根入库扣货路由' Assert-Contains $rootAgents '入库单扣货、解除与履约门禁' '根入库扣货路由'
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由' Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
Assert-Contains $backendAgents '入库扣货、解除、履约门禁与凭证' '后端入库扣货路由' Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由'
Assert-Contains $rootAgents '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由' Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户资金账户路由'
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由' Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由' Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结与结算' '后端客户资金账户路由' Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由'
Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照' Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照'
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照' Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由' Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由'
@@ -172,7 +187,7 @@ Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根
Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由' Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由'
Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由' Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由'
Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由' Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由'
Assert-Contains $backendAgents '尾程订单控制与柜级入仓计划转换' '后端尾程订单控制路由' Assert-Contains $backendAgents '尾程订单控制、柜级预览与入仓计划转换' '后端尾程订单控制路由'
$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) $testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
$devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) $devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) { foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) {
@@ -263,6 +278,48 @@ Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBM
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先' Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先' Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先'
# 批量审核、客户专属价格簿和柜级阶梯计价已经成为稳定跨端能力,必须从根表直达且锁住服务端权威规则。
$batchReviewPreviewService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java')
$fbaPricingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/fba-pricing-v1.yaml')
$fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java')
$inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java')
$adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts')
$adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
Assert-Contains $rootAgents '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由'
Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由'
Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口'
Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI'
Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询'
Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API'
Assert-Contains $rootAgents '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由'
Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由'
Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口'
Assert-Contains $fbaPricingOpenApi '计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本' '价格簿作用域优先级'
Assert-Contains $fbaPricingOpenApi '计费量只能在系统原计费量上下10%范围内(含边界)' '计费量调整边界'
Assert-Contains $fbaPricingService 'recalculateUnlockedContainerCustomerFees' '柜级阶梯费用重算入口'
Assert-Contains $firstlegAgents '一件代发 `WAIT_PALLETIZE` 可在预装确认前试算' 'Firstleg 一件代发预装前计价边界'
Assert-Contains $fbaPricingService '"WAIT_PALLETIZE"' '一件代发托盘化状态可计价'
Assert-Contains $fbaPricingService 'preserveProcessStatusDuringPricing' '计价保留履约状态入口'
Assert-Contains $inboundFeeDetailService 'PRIVATE_LAST_MILE_DELIVERY_FEE_CODE' '私人仓尾程派送费稳定代码'
Assert-Contains $adminCarrierRatesView 'customerId' 'Admin 价格簿客户作用域'
Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java'
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
Assert-Contains $rootAgents '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
Assert-Contains $adminAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
Assert-Contains $lastlegAgents '`RETURN_STOCK_IN` 流水' 'Lastleg 退回库存流水边界'
Assert-Contains $fileAgents 'businessType=OVERSEAS_EXCEPTION_EVIDENCE' 'File 海外异常证据归属'
Assert-Contains $exceptionOrderService 'restoreReturnedInventory' '海外异常退回库存恢复调用'
Assert-Contains $exceptionOrderService 'delivery.setStatus("RETURNED")' '海外异常完成派送状态'
Assert-Contains $inventoryCommandService 'ReturnedInventoryRestoreRequest' '海外退回库存命令入口'
Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java'
# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。 # 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。
$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java') $pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java')
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java') $merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
@@ -391,7 +448,7 @@ Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI' Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI'
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI' Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI'
Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现' Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现'
Assert-Contains $terminalDrayageService 'context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现' Assert-Contains $terminalDrayageService 'context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现'
Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费' Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费'
Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现' Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现'
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现' Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
@@ -675,28 +732,47 @@ Assert-Contains $productImageController "hasAuthority('platform:order:list')" '
Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览' Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览'
Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界' Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界'
# 尾程订单按来源柜展示服务端计划进度,整柜转换每票独立计划且整批原子。 # 尾程订单按来源柜展示服务端计划进度;整柜确认必须绑定预览快照并编排真实托盘批次。
$lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') $lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml')
$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') $lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
$adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') $adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
$containerConversionCoordinator = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java')
$warehousePalletBatchService = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java')
$overseasWarehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md')
$overseasPdaReceivingAdapter = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js')
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约' Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约'
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约' Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约'
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/container-preview:' 'Lastleg 整柜计划预览契约'
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $backendAgents '分页前排除全部入仓码均已生成有效独立计划的柜' '后端柜级计划待处理筛选' Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
Assert-Contains $adminAgents '返回仍有待生成计划入仓码的柜' 'Admin 柜级计划待处理筛选' Assert-Contains $adminAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $lastlegAgents '分页前排除全部入仓码均已生成有效独立计划的柜' 'Lastleg 柜级计划待处理筛选' Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
Assert-Contains $lastlegBusinessOpenApi '仅返回仍有入仓码待生成有效独立计划的柜' 'Lastleg 柜级计划待处理 OpenAPI' Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选' Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
Assert-Contains $adminAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用' Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressSummary' 'Lastleg 柜级计划进度摘要模型' Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressSummary' 'Lastleg 柜级计划进度摘要模型'
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressDetail' 'Lastleg 柜级计划进度详情模型' Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressDetail' 'Lastleg 柜级计划进度详情模型'
Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就绪事实' Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就绪事实'
Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实' Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实'
Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口' Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口'
Assert-Contains $lastlegAgents 'ContainerInboundPlanConversionCoordinator.java' 'Lastleg 柜级计划 Saga 入口'
Assert-Contains $containerConversionCoordinator 'warehouse.ensurePalletBatch' 'Lastleg 托盘批次预留'
Assert-Contains $containerConversionCoordinator 'warehouse.confirmPalletBatch' 'Lastleg 托盘批次确认'
Assert-Contains $containerConversionCoordinator 'warehouse.compensatePalletBatch' 'Lastleg 托盘批次补偿'
Assert-Contains $warehousePalletBatchService 'Only a RESERVED pallet batch can be confirmed' '海外资源批次确认状态门禁'
Assert-Contains $overseasWarehouseAgents '同柜重放不得产生第二批资源' '海外资源批次幂等边界'
Assert-Contains $overseasPdaAgents 'assigned/mergeAllowed/urgent/mergeHint' '海外 PDA 计划标志索引'
Assert-Contains $overseasPdaAgents '不在设备端重新分配' '海外 PDA 预留托盘边界'
Assert-Contains $overseasPdaReceivingAdapter 'mergeAllowed: value.mergeAllowed === true' '海外 PDA 合托可选事实适配'
# 国内 PDA 当前以页面、API 和作业状态合同覆盖部分拣货及库位不足反馈。 # 国内 PDA 当前以页面、API 和作业状态合同覆盖部分拣货及库位不足反馈。
Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同' Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同'
@@ -723,8 +799,8 @@ Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界' Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界' Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界'
Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI' Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI'
Assert-Contains $containerFeeOpenApi '各分摊金额必须大于0且合计等于费用总额' '柜费分摊金额守恒' Assert-Contains $containerFeeOpenApi '各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。' '柜费分摊金额守恒'
Assert-Contains $containerFeeOpenApi '单行接口拒绝处理分摊组' '柜费分摊整组操作契约' Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改。' '柜费分摊整组操作契约'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口' Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口' Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口'
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验' Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
@@ -749,7 +825,7 @@ Assert-Contains $adminAgents '入库单扣货、解除、凭证与历史' 'Admin
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由' Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由'
Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口' Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口'
Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户钱包快速路由' Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户钱包快速路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信与跨区资金指令' '后端客户钱包快速路由' Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由'
Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口' Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口'
Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口' Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口'
Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界' Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界'
@@ -1090,5 +1166,5 @@ if ($failures.Count -gt 0) {
exit 1 exit 1
} }
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
exit 0 exit 0
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8')
const start = source.indexOf('$registeredTestRows =')
const end = source.indexOf("Assert-NotContains $testTaskRegistry '01a0194a", start)
assert.ok(start > 0 && end > start)
const registryContract = source.slice(start, end)
const project = 'c64729ff-6710-43c6-8ef2-d84e9666cc1e'
const root = '00000000-0000-4000-8000-000000000001'
const child = '00000000-0000-4000-8000-000000000002'
const enabled = `已重新启用测试环境总控调度\nparent_task_id project_id\n| \`${root}\` | \`${project}\` | 【总控】测试环境全流程 |\n| \`${child}\` | \`${root}\` | \`${project}\` | 【前端】专项 |`
function checkRegistry(text) {
const script = `
$ErrorActionPreference = 'Stop'
$failures = [System.Collections.Generic.List[string]]::new()
function Add-Failure([string]$message) { $script:failures.Add($message) }
function Assert-Contains([string]$text, [string]$needle, [string]$scope) { if (-not $text.Contains($needle)) { Add-Failure "$scope 缺少 $needle" } }
function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { if ($text -notmatch $pattern) { Add-Failure "$scope 缺少结构" } }
$testTaskRegistry = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(text).toString('base64')}'))
${registryContract}
Write-Output ('RESULT:' + (ConvertTo-Json -InputObject @($failures) -Compress))
`
const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
const output = result.stdout.match(/RESULT:(\[[^\r\n]*\])/)
assert.ok(output, result.stdout)
return JSON.parse(output[1])
}
test('cleared test registry stays disabled and explicitly revokes inherited authorization', () => {
assert.deepEqual(checkRegistry('不启用总控调度\n当前无已登记总控或长期专项\n持续授权同步失效'), [])
assert.ok(checkRegistry('当前无已登记总控或长期专项').length > 0)
assert.ok(checkRegistry('').length > 0)
})
test('enabled registry requires explicit activation, one controller, unique tasks and its own project', () => {
assert.deepEqual(checkRegistry(enabled), [])
assert.ok(checkRegistry(enabled.replace('已重新启用测试环境总控调度', '')).length > 0)
assert.ok(checkRegistry(enabled.replace('parent_task_id', '')).length > 0)
assert.ok(checkRegistry(enabled.replace(child, root)).length > 0)
assert.ok(checkRegistry(enabled.replace('【前端】专项', '【总控】其他')).length > 0)
assert.ok(checkRegistry(enabled.replaceAll(project, '6f874ec3-d3b2-49bd-8625-327f4d685f98')).length > 0)
})
@@ -60,6 +60,7 @@ test('builds an order payload owned by the fixed profile', () => {
senderLocation: { province: '浙江省', city: '义乌市', district: '稠城街道', postalCode: '322000' }, senderLocation: { province: '浙江省', city: '义乌市', district: '稠城街道', postalCode: '322000' },
}, },
productImageFileId: '33', productImageFileId: '33',
pickupAddressId: '55',
}) })
assert.equal(payload.receivingWarehouseId, '11') assert.equal(payload.receivingWarehouseId, '11')
assert.equal(payload.destinationWarehouseId, '22') assert.equal(payload.destinationWarehouseId, '22')
@@ -67,11 +68,14 @@ test('builds an order payload owned by the fixed profile', () => {
assert.equal(payload.customerName, '固定测试客户') assert.equal(payload.customerName, '固定测试客户')
assert.equal(payload.cargoes.length, 1) assert.equal(payload.cargoes.length, 1)
assert.equal(payload.cargoes[0].productImageFileId, '33') assert.equal(payload.cargoes[0].productImageFileId, '33')
assert.equal(payload.cargoes[0].woodenProduct, false)
assert.equal(payload.tradeTerm, 'EXW') assert.equal(payload.tradeTerm, 'EXW')
assert.equal(payload.pickupMethod, 'SELF_TRUCK') assert.equal(payload.pickupMethod, 'SELF_TRUCK')
assert.equal(payload.taxMode, 'TAX_REFUND') assert.equal(payload.taxMode, 'TAX_REFUND')
assert.equal(payload.customsInspection, true) assert.equal(payload.customsInspection, true)
assert.equal(payload.sender, null) assert.equal(payload.sender, null)
assert.equal(payload.senderAddressId, null)
assert.equal(payload.requestedPickupAt, null)
assert.equal(payload.cargoes[0].cargoName, PRODUCT_CATALOG[0].cargoName) assert.equal(payload.cargoes[0].cargoName, PRODUCT_CATALOG[0].cargoName)
assert.equal(payload.cargoes[0].packageCount, 7) assert.equal(payload.cargoes[0].packageCount, 7)
assert.equal(payload.cargoes[0].totalQuantity, PRODUCT_CATALOG[0].unitsPerPackage * 7) assert.equal(payload.cargoes[0].totalQuantity, PRODUCT_CATALOG[0].unitsPerPackage * 7)
@@ -79,6 +83,27 @@ test('builds an order payload owned by the fixed profile', () => {
assert.equal(payload.cargoes[0].declaredValue, PRODUCT_CATALOG[0].unitDeclaredValue * PRODUCT_CATALOG[0].unitsPerPackage * 7) assert.equal(payload.cargoes[0].declaredValue, PRODUCT_CATALOG[0].unitDeclaredValue * PRODUCT_CATALOG[0].unitsPerPackage * 7)
}) })
test('uses the selected customer pickup address and appointment for pickup orders', () => {
const payload = buildOrderPayload({
marker: 'PENDING-PICKUP', index: 1,
profile: { id: '44', tenantName: '固定测试客户' },
master: {
receiving: { id: '11', warehouseName: '国内仓' },
destination: { id: '22', warehouseName: 'ONT8', warehouseCode: 'ONT8', countryCode: 'US' },
channel: { channelCode: 'SEA-US', channelName: '美西海运' }, pricingTaxMode: 'TAX_INCLUDED',
},
product: PRODUCT_CATALOG[0], packageCount: 1, productImageFileId: '33', pickupAddressId: '55',
options: {
tradeTerm: 'FOB', pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION',
customsInspection: false, expectedShipDays: 7,
senderLocation: { province: '广东省', city: '深圳市', district: '南山区', postalCode: '518000' },
},
})
assert.equal(payload.sender, null)
assert.equal(payload.senderAddressId, '55')
assert.match(payload.requestedPickupAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
})
test('randomizes only supported basic-order fields', () => { test('randomizes only supported basic-order fields', () => {
for (let index = 0; index < 100; index += 1) { for (let index = 0; index < 100; index += 1) {
const options = randomOrderOptions() const options = randomOrderOptions()
+40 -14
View File
@@ -247,7 +247,39 @@ async function uploadProductImage(gateway, timeoutMs, token, marker) {
return data.fileId return data.fileId
} }
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId }) { async function ensurePickupAddress(gateway, timeoutMs, token, profile, marker) {
const listResponse = await request(gateway, timeoutMs, 'GET',
'/api/merchant/v1/orders/first-leg/addresses?addressType=PICKUP', { token })
const existing = items(requireSuccess(listResponse, 'Query pickup addresses'))
.find((address) => address?.id)
if (existing) return String(existing.id)
const location = pickRandom(SENDER_LOCATIONS)
const createResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/orders/first-leg/addresses', {
token,
body: {
addressType: 'PICKUP',
addressName: '待审核订单测试取货地址',
companyName: profile.tenantName || `固定客户-${marker}`,
contactName: '订单测试联系人',
phone: '13800138000',
email: 'pending-order@example.com',
countryCode: 'CN',
province: location.province,
city: location.city,
district: location.district,
addressLine: `${marker} 固定客户测试地址`,
postalCode: location.postalCode,
defaultAddress: true,
},
})
const addressId = String(requireSuccess(createResponse, 'Create pickup address') ?? '')
if (!addressId) throw new Error('Create pickup address did not return address ID')
return addressId
}
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId, pickupAddressId }) {
const sequence = String(index).padStart(3, '0') const sequence = String(index).padStart(3, '0')
const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64) const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64)
const referenceId = `REF-${sequence}-${marker}`.slice(0, 64) const referenceId = `REF-${sequence}-${marker}`.slice(0, 64)
@@ -282,18 +314,10 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac
amazonWarehouseCode: master.destination.warehouseCode, amazonWarehouseCode: master.destination.warehouseCode,
shipmentId, shipmentId,
referenceId, referenceId,
sender: options.pickupMethod === 'PICKUP' ? { requestedPickupAt: options.pickupMethod === 'PICKUP'
companyName, ? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null,
contactName: '订单测试联系人', senderAddressId: options.pickupMethod === 'PICKUP' ? pickupAddressId : null,
phone: '13800138000', sender: null,
email: 'pending-order@example.com',
countryCode: 'CN',
province: options.senderLocation.province,
city: options.senderLocation.city,
district: options.senderLocation.district,
addressLine: `${marker} 固定客户测试地址`,
postalCode: options.senderLocation.postalCode,
} : null,
receiver: null, receiver: null,
cargoes: [{ cargoes: [{
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64), skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
@@ -325,6 +349,7 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac
referenceId, referenceId,
upcNo: `${Date.now()}${sequence}`.slice(-12), upcNo: `${Date.now()}${sequence}`.slice(-12),
productImageFileId, productImageFileId,
woodenProduct: false,
dangerousGoods: false, dangerousGoods: false,
cargoRemark: `待审核订单货物 ${sequence} ${marker}`, cargoRemark: `待审核订单货物 ${sequence} ${marker}`,
}], }],
@@ -383,6 +408,7 @@ export async function main(argv = process.argv.slice(2)) {
const routePool = await loadPricedRoutes(gateway, timeoutMs, token) const routePool = await loadPricedRoutes(gateway, timeoutMs, token)
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker) const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
const pickupAddressId = await ensurePickupAddress(gateway, timeoutMs, token, profile, marker)
const randomizedProducts = shuffled(PRODUCT_CATALOG) const randomizedProducts = shuffled(PRODUCT_CATALOG)
const sameChannel = args['same-channel'] === true const sameChannel = args['same-channel'] === true
const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool) const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool)
@@ -392,7 +418,7 @@ export async function main(argv = process.argv.slice(2)) {
const product = randomizedProducts[(index - 1) % randomizedProducts.length] const product = randomizedProducts[(index - 1) % randomizedProducts.length]
const packageCount = randomInt(1, 31) const packageCount = randomInt(1, 31)
const options = randomOrderOptions() const options = randomOrderOptions()
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId }) const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId, pickupAddressId })
const createResponse = await request(gateway, timeoutMs, 'POST', const createResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/orders/first-leg', { '/api/merchant/v1/orders/first-leg', {
token, token,
+1 -1
View File
@@ -11,7 +11,7 @@
"services": 15 "services": 15
}, },
"openapi": { "openapi": {
"documents": 43, "documents": 45,
"coveredServices": 10, "coveredServices": 10,
"codeFirstServices": 5 "codeFirstServices": 5
}, },
+4 -1
View File
@@ -65,7 +65,10 @@ test('repository freezes the current topology and non-blocking aggregate summary
const summary = await buildQualitySummary(repositoryRoot) const summary = await buildQualitySummary(repositoryRoot)
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
assert.equal(summary.openapi.documents, 43) assert.equal(summary.openapi.documents, 45)
assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml'))
assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml'))
assert.equal(baseline.openapi.documents, summary.openapi.documents)
assert.equal(summary.openapi.coveredServices.length, 10) assert.equal(summary.openapi.coveredServices.length, 10)
assert.equal(summary.openapi.codeFirstServices.length, 5) assert.equal(summary.openapi.codeFirstServices.length, 5)
assert.equal(baseline.mode, 'report-only') assert.equal(baseline.mode, 'report-only')
@@ -157,6 +157,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'), 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '派送资料上传无幂等键,元数据按客户/派送单/类别校验,当前与历史下载只读'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java', http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java',
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '异常处理证据上传无幂等键,归属校验可重复'), 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '异常处理证据上传无幂等键,归属校验可重复'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java',
@@ -106,7 +106,7 @@ test('all 56 Feign clients have reviewed context, access and idempotency metadat
assert.deepEqual(await discoverFeignEdges(), reviewed) assert.deepEqual(await discoverFeignEdges(), reviewed)
}) })
test('all 20 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { test('all 21 raw HTTP transports have reviewed context, access and idempotency metadata', async () => {
const discovered = [] const discovered = []
for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) {
for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) {
@@ -118,7 +118,7 @@ test('all 20 raw HTTP transports have reviewed context, access and idempotency m
} }
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP')
.map(client => client.path).sort() .map(client => client.path).sort()
assert.equal(reviewed.length, 20) assert.equal(reviewed.length, 21)
assert.deepEqual(discovered.sort(), reviewed) assert.deepEqual(discovered.sort(), reviewed)
}) })
+1 -1
View File
@@ -15,7 +15,7 @@
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | | 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` | | PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` |
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;先预留,Lastleg 本地成功后确认,失败时仅补偿未确认资源 | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,只从可预留真实托盘/库位建立逐项资源锁;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |