优化
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
|
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
|
||||||
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
|
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
|
||||||
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,国内仓储资源归 `warehouse`;海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外仓库/库位/托盘资源归 `warehouse-overseas-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
|
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
|
||||||
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
|
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
|
||||||
|
|
||||||
## 服务路由(按关键词定位)
|
## 服务路由(按关键词定位)
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||||
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、订舱关联候选与柜量语义、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
||||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||||
|
|
||||||
## 全量 HTTP 验收脚本路由
|
## 全量 HTTP 验收脚本路由
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
|
||||||
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
|
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
|
||||||
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
|
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
|
||||||
|
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
|
||||||
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
||||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
|
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
|
||||||
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
|
|
||||||
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
|
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
|
||||||
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
|
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
|
||||||
|
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
|
||||||
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
|
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
|
||||||
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
|
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -29,6 +29,7 @@
|
|||||||
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
||||||
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
|
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
|
||||||
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
|
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
|
||||||
|
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
|
||||||
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
|
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
|
||||||
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||||
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
|
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
|
||||||
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||||
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
|
| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` |
|
||||||
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
|
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
|
||||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||||
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
|
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
|
||||||
@@ -58,6 +59,8 @@
|
|||||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||||
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
|
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
|
||||||
|
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||||
|
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
|
||||||
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
|
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
|
||||||
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
|
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
|
||||||
- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。
|
- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
| 托盘分配算法 | `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/**`,旧 planNo/allocationId Controller 已移除 |
|
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
|
||||||
| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
|
| 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` |
|
||||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
|
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
|
||||||
|
|||||||
+17
@@ -11,6 +11,10 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -24,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -66,6 +71,18 @@ public class ReceivingContainerController {
|
|||||||
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/current/unload-photos/files/{fileId}/download")
|
||||||
|
public ResponseEntity<byte[]> downloadCurrentPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String fileId) {
|
||||||
|
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||||
|
var download = service.downloadCurrentPhoto(identity.operatorKey(), fileId);
|
||||||
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||||
|
.contentType(org.springframework.http.MediaType.parseMediaType(download.contentType()))
|
||||||
|
.contentLength(download.content().length).body(download.content());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/current/sign")
|
@PostMapping("/current/sign")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ public final class ReceivingContainerResponses {
|
|||||||
int totalPackages, int receivedPackages, int pendingPackages,
|
int totalPackages, int receivedPackages, int pendingPackages,
|
||||||
List<Inbound> inbounds) { }
|
List<Inbound> inbounds) { }
|
||||||
|
|
||||||
public record SignedPhoto(ReceivingPhotoRole role, String fileId) { }
|
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
|
||||||
|
|
||||||
public record Inbound(String inboundCode, String destinationType,
|
public record Inbound(String inboundCode, String destinationType,
|
||||||
String overseasWarehouseId, String overseasWarehouseName,
|
String overseasWarehouseId, String overseasWarehouseName,
|
||||||
|
|||||||
+45
@@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.client.MultipartBodyBuilder;
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -85,6 +88,39 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadedFile download(String customerId, long taskId, long fileId) {
|
||||||
|
long tenantId = tenantId(customerId);
|
||||||
|
ResponseEntity<byte[]> response = client.get()
|
||||||
|
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
|
||||||
|
tenantId, BUSINESS_TYPE, taskId, fileId)
|
||||||
|
.header("X-Internal-Service-Token", internalToken)
|
||||||
|
.retrieve()
|
||||||
|
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
|
||||||
|
HttpStatusCode status = result.getStatusCode();
|
||||||
|
if (status.value() == HttpStatus.NOT_FOUND.value()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unload photo was not found");
|
||||||
|
}
|
||||||
|
if (status.value() == HttpStatus.FORBIDDEN.value()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
"Unload photo is not in current account scope");
|
||||||
|
}
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo download failed");
|
||||||
|
})
|
||||||
|
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
|
||||||
|
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Unload photo download failed");
|
||||||
|
})
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
|
||||||
|
String fileName = contentDispositionFilename(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION));
|
||||||
|
if (fileName == null || fileName.isBlank()) {
|
||||||
|
fileName = String.valueOf(fileId) + ".bin";
|
||||||
|
}
|
||||||
|
String contentType = response.getHeaders().getContentType() == null
|
||||||
|
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
|
||||||
|
return new DownloadedFile(content, fileName, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
private long tenantId(String customerId) {
|
private long tenantId(String customerId) {
|
||||||
try { return Long.parseLong(customerId); }
|
try { return Long.parseLong(customerId); }
|
||||||
catch (NumberFormatException exception) { throw new ResponseStatusException(
|
catch (NumberFormatException exception) { throw new ResponseStatusException(
|
||||||
@@ -104,6 +140,15 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
|
|||||||
response.message() == null || response.message().isBlank() ? fallback : response.message());
|
response.message() == null || response.message().isBlank() ? fallback : response.message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String contentDispositionFilename(String rawValue) {
|
||||||
|
if (rawValue == null || rawValue.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return ContentDisposition.parse(rawValue).getFilename();
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return rawValue.replace("attachment; filename=\"", "").replace("\"", "").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private record MetadataEnvelope(String code, String message, Metadata data) {}
|
private record MetadataEnvelope(String code, String message, Metadata data) {}
|
||||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||||
String fileName, String contentType, Long sizeBytes) {}
|
String fileName, String contentType, Long sizeBytes) {}
|
||||||
|
|||||||
+2
@@ -5,6 +5,8 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
public interface UnloadPhotoFileGateway {
|
public interface UnloadPhotoFileGateway {
|
||||||
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
|
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
|
||||||
void validate(String customerId, long taskId, long fileId);
|
void validate(String customerId, long taskId, long fileId);
|
||||||
|
DownloadedFile download(String customerId, long taskId, long fileId);
|
||||||
|
|
||||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||||
|
record DownloadedFile(byte[] content, String fileName, String contentType) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-2
@@ -17,6 +17,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
|||||||
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||||
@@ -58,6 +59,9 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ReceivingContainerService {
|
public class ReceivingContainerService {
|
||||||
|
private static final String PHOTO_DOWNLOAD_PATH =
|
||||||
|
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
|
||||||
|
|
||||||
@Resource private InboundPlanMapper plans;
|
@Resource private InboundPlanMapper plans;
|
||||||
@Resource private InboundPlanLineMapper lines;
|
@Resource private InboundPlanLineMapper lines;
|
||||||
@Resource private InboundPlanPalletMapper pallets;
|
@Resource private InboundPlanPalletMapper pallets;
|
||||||
@@ -198,6 +202,16 @@ public class ReceivingContainerService {
|
|||||||
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
|
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) {
|
||||||
|
ReceivingContainerSession session = requireCurrent(operatorKey, false);
|
||||||
|
long parsedFileId = positiveFileId(fileId);
|
||||||
|
if (signedPhotos(session).stream().noneMatch(photo -> fileId.equals(photo.fileId()))) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
"Unload photo is not part of the current receiving session");
|
||||||
|
}
|
||||||
|
return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId);
|
||||||
|
}
|
||||||
|
|
||||||
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
|
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
|
||||||
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
|
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
|
||||||
}
|
}
|
||||||
@@ -510,7 +524,7 @@ public class ReceivingContainerService {
|
|||||||
throw conflict("Exactly three typed unload photos are required");
|
throw conflict("Exactly three typed unload photos are required");
|
||||||
}
|
}
|
||||||
if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null");
|
if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null");
|
||||||
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
|
Set<ReceivingPhotoRole> roles = request.photos().stream()
|
||||||
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
|
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
|
||||||
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
|
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
|
||||||
Set<Long> fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId()))
|
Set<Long> fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId()))
|
||||||
@@ -520,7 +534,7 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
return request.photos().stream()
|
return request.photos().stream()
|
||||||
.sorted(Comparator.comparing(value -> value.role().ordinal()))
|
.sorted(Comparator.comparing(value -> value.role().ordinal()))
|
||||||
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
|
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId(), null)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private long positiveFileId(String value) {
|
private long positiveFileId(String value) {
|
||||||
@@ -726,6 +740,13 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
|
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
|
||||||
|
return signedPhotos(session).stream()
|
||||||
|
.map(photo -> new ReceivingContainerResponses.SignedPhoto(photo.role(), photo.fileId(),
|
||||||
|
PHOTO_DOWNLOAD_PATH.replace("{fileId}", photo.fileId())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReceivingContainerResponses.SignedPhoto> signedPhotos(ReceivingContainerSession session) {
|
||||||
if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of();
|
if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of();
|
||||||
try { return objectMapper.readValue(session.getSignPhotos(),
|
try { return objectMapper.readValue(session.getSignPhotos(),
|
||||||
new TypeReference<List<ReceivingContainerResponses.SignedPhoto>>() { }); }
|
new TypeReference<List<ReceivingContainerResponses.SignedPhoto>>() { }); }
|
||||||
|
|||||||
@@ -31,10 +31,29 @@ paths:
|
|||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
|
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: { $ref: '#/components/schemas/ReceivingPhotoApiResponse' } } } }, '409': { description: 当前柜已签收 } }
|
responses: { '200': { description: 文件标识, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPhotoApiResponse' } } } }, '409': { description: 当前柜已签收 } }
|
||||||
|
/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download:
|
||||||
|
get:
|
||||||
|
summary: 下载当前柜签收照片
|
||||||
|
description: 权限 lastleg:pda:operate;仅允许下载当前会话已签收角色绑定的照片。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: fileId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,18}$' } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 签收照片字节流;Content-Type 透传文件类型,缺失时为 application/octet-stream;Content-Disposition 为 inline;Cache-Control 为 no-store。
|
||||||
|
headers:
|
||||||
|
Content-Disposition: { schema: { type: string }, description: inline 文件名 }
|
||||||
|
Cache-Control: { schema: { type: string, example: no-store } }
|
||||||
|
content:
|
||||||
|
image/jpeg: { schema: { type: string, format: binary } }
|
||||||
|
image/png: { schema: { type: string, format: binary } }
|
||||||
|
application/octet-stream: { schema: { type: string, format: binary } }
|
||||||
|
'403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 }
|
||||||
|
'404': { description: 当前柜会话或照片不存在 }
|
||||||
/api/pda/v1/last-leg/receiving-containers/current/sign:
|
/api/pda/v1/last-leg/receiving-containers/current/sign:
|
||||||
post:
|
post:
|
||||||
summary: 签收当前柜
|
summary: 签收当前柜
|
||||||
description: 权限 lastleg:pda:operate;精确校验车牌、封签、开柜三类照片各一次及文件归属后由 WAIT_SIGN 推进 SIGNED。
|
description: 权限 lastleg:pda:operate;精确校验车牌、封签、开门三类照片各且仅 1 张及文件归属后由 WAIT_SIGN 推进 SIGNED;重拍由调用端在签收前替换对应槽位。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } }
|
||||||
responses: { '200': { description: 已签收会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 状态或照片校验失败 } }
|
responses: { '200': { description: 已签收会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 状态或照片校验失败 } }
|
||||||
@@ -624,6 +643,13 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] }
|
role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] }
|
||||||
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||||
|
ReceivingSignedSessionPhoto:
|
||||||
|
type: object
|
||||||
|
required: [role, fileId, downloadPath]
|
||||||
|
properties:
|
||||||
|
role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] }
|
||||||
|
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||||
|
downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 }
|
||||||
ReceivingBulk:
|
ReceivingBulk:
|
||||||
type: object
|
type: object
|
||||||
required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey]
|
required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey]
|
||||||
@@ -688,7 +714,7 @@ components:
|
|||||||
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
||||||
operatorName: { type: string, nullable: true }
|
operatorName: { type: string, nullable: true }
|
||||||
version: { type: integer }
|
version: { type: integer }
|
||||||
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedPhoto' } }
|
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } }
|
||||||
preparedAt: { type: string, format: date-time, nullable: true }
|
preparedAt: { type: string, format: date-time, nullable: true }
|
||||||
signedAt: { type: string, format: date-time, nullable: true }
|
signedAt: { type: string, format: date-time, nullable: true }
|
||||||
receivingAt: { type: string, format: date-time, nullable: true }
|
receivingAt: { type: string, format: date-time, nullable: true }
|
||||||
|
|||||||
+3
@@ -128,6 +128,7 @@ class ReceivingContainerContractTest {
|
|||||||
assertTrue(yaml.contains(prefix + "/current/skus/resolve:"));
|
assertTrue(yaml.contains(prefix + "/current/skus/resolve:"));
|
||||||
assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
|
assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
|
||||||
assertTrue(yaml.contains(prefix + "/current/complete:"));
|
assertTrue(yaml.contains(prefix + "/current/complete:"));
|
||||||
|
assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:"));
|
||||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}"));
|
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}"));
|
||||||
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
|
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
|
||||||
List<String> successResponses = receivingPaths.lines().map(String::trim)
|
List<String> successResponses = receivingPaths.lines().map(String::trim)
|
||||||
@@ -139,6 +140,8 @@ class ReceivingContainerContractTest {
|
|||||||
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
||||||
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
||||||
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
||||||
|
assertTrue(yaml.contains("photos: { type: array, minItems: 3, maxItems: 3"));
|
||||||
|
assertTrue(yaml.contains("required: [role, fileId, downloadPath]"));
|
||||||
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
|
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
|
||||||
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
|
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
|
||||||
assertFalse(receivingPaths.contains("photoFileIds"));
|
assertFalse(receivingPaths.contains("photoFileIds"));
|
||||||
|
|||||||
+22
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller;
|
|||||||
|
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
||||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -9,12 +10,16 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -55,6 +60,23 @@ class ReceivingContainerControllerTest {
|
|||||||
.hasMessageContaining("403");
|
.hasMessageContaining("403");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadCurrentUnloadPhotoDelegatesToService() {
|
||||||
|
var user = picker();
|
||||||
|
when(service.downloadCurrentPhoto("42", "9999")).thenReturn(
|
||||||
|
new UnloadPhotoFileGateway.DownloadedFile("abc".getBytes(), "photo.jpg", MediaType.IMAGE_JPEG_VALUE));
|
||||||
|
|
||||||
|
ResponseEntity<byte[]> response = controller.downloadCurrentPhoto(user, "9999");
|
||||||
|
|
||||||
|
assertThat(response.getStatusCodeValue()).isEqualTo(200);
|
||||||
|
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG);
|
||||||
|
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||||
|
.startsWith("inline").contains("photo.jpg");
|
||||||
|
assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store");
|
||||||
|
assertThat(response.getBody()).containsExactly("abc".getBytes());
|
||||||
|
verify(service).downloadCurrentPhoto("42", "9999");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
|
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
|
||||||
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
||||||
|
|||||||
+32
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.inboundplan.pda.gateway;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
@@ -11,8 +13,10 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||||
|
|
||||||
class HttpUnloadPhotoFileGatewayTest {
|
class HttpUnloadPhotoFileGatewayTest {
|
||||||
private HttpUnloadPhotoFileGateway gateway;
|
private HttpUnloadPhotoFileGateway gateway;
|
||||||
@@ -51,4 +55,32 @@ class HttpUnloadPhotoFileGatewayTest {
|
|||||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
|
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
|
||||||
server.verify();
|
server.verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadUsesBusinessOwnershipPathAndPreservesBlobMetadata() {
|
||||||
|
server.expect(requestTo(
|
||||||
|
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||||
|
.andExpect(header("X-Internal-Service-Token", "internal-token"))
|
||||||
|
.andRespond(withSuccess("photo-bytes".getBytes(), MediaType.IMAGE_JPEG)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=door.jpg"));
|
||||||
|
|
||||||
|
UnloadPhotoFileGateway.DownloadedFile result = gateway.download("100", 8L, 9L);
|
||||||
|
|
||||||
|
assertThat(result.content()).containsExactly("photo-bytes".getBytes());
|
||||||
|
assertThat(result.fileName()).isEqualTo("door.jpg");
|
||||||
|
assertThat(result.contentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE);
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingOwnedPhotoMapsToNotFound() {
|
||||||
|
server.expect(requestTo(
|
||||||
|
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||||
|
.andRespond(withStatus(HttpStatus.NOT_FOUND));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> gateway.download("100", 8L, 9L))
|
||||||
|
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||||
|
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -315,6 +315,11 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
ReceivingPhotoRole.DOOR_OPEN);
|
ReceivingPhotoRole.DOOR_OPEN);
|
||||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId")
|
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId")
|
||||||
.containsExactly("2092476334483931138", "2", "3");
|
.containsExactly("2092476334483931138", "2", "3");
|
||||||
|
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("downloadPath")
|
||||||
|
.containsExactly(
|
||||||
|
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2092476334483931138/download",
|
||||||
|
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2/download",
|
||||||
|
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/3/download");
|
||||||
verify(unloadPhotos).validate("100", 8L, 2092476334483931138L);
|
verify(unloadPhotos).validate("100", 8L, 2092476334483931138L);
|
||||||
verify(unloadPhotos).validate("100", 8L, 2L);
|
verify(unloadPhotos).validate("100", 8L, 2L);
|
||||||
verify(unloadPhotos).validate("100", 8L, 3L);
|
verify(unloadPhotos).validate("100", 8L, 3L);
|
||||||
@@ -356,6 +361,35 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
verifyNoInteractions(unloadPhotos);
|
verifyNoInteractions(unloadPhotos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentSignedPhotoCanBeDownloadedOnlyThroughItsOperatorCustomerAndSessionScope() {
|
||||||
|
ReceivingContainerSession current = session("CONT-A", "SIGNED");
|
||||||
|
current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"},"
|
||||||
|
+ "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"},"
|
||||||
|
+ "{\"role\":\"DOOR_OPEN\",\"fileId\":\"11\"}]");
|
||||||
|
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current);
|
||||||
|
UnloadPhotoFileGateway.DownloadedFile expected =
|
||||||
|
new UnloadPhotoFileGateway.DownloadedFile("image".getBytes(), "plate.jpg", "image/jpeg");
|
||||||
|
when(unloadPhotos.download("100", 8L, 9L)).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(service.downloadCurrentPhoto("7", "9")).isSameAs(expected);
|
||||||
|
verify(unloadPhotos).download("100", 8L, 9L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void photoOutsideCurrentSignedRoleBindingsIsForbiddenBeforeFileServiceCall() {
|
||||||
|
ReceivingContainerSession current = session("CONT-A", "SIGNED");
|
||||||
|
current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"},"
|
||||||
|
+ "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"},"
|
||||||
|
+ "{\"role\":\"DOOR_OPEN\",\"fileId\":\"11\"}]");
|
||||||
|
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.downloadCurrentPhoto("7", "12"))
|
||||||
|
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||||
|
error -> assertThat(error.getStatusCode().value()).isEqualTo(403));
|
||||||
|
verifyNoInteractions(unloadPhotos);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
|
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
|
||||||
stubCurrent("FBA", "CONT-A");
|
stubCurrent("FBA", "CONT-A");
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ node scripts/create-pending-review-orders.mjs
|
|||||||
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
|
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
|
||||||
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
|
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
|
||||||
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
|
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
|
||||||
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 |
|
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 |
|
||||||
| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 |
|
| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 |
|
||||||
| 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 |
|
| 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 |
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ test('outbound-created query supports automated or manual carrier then reaches P
|
|||||||
assert.match(nodes, /'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'/)
|
assert.match(nodes, /'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'/)
|
||||||
assert.match(nodes, /const pickupReadyIndex = STANDARD_NODES\.indexOf\('PICKUP_READY'\)/)
|
assert.match(nodes, /const pickupReadyIndex = STANDARD_NODES\.indexOf\('PICKUP_READY'\)/)
|
||||||
assert.match(nodes, /createdResources\.phase4CarrierPickupReady/)
|
assert.match(nodes, /createdResources\.phase4CarrierPickupReady/)
|
||||||
|
assert.match(nodes, /重复当前实际节点精确返回A0409/)
|
||||||
|
assert.match(nodes, /更早实际节点精确返回A0409/)
|
||||||
|
assert.match(nodes, /operation claim零残留/)
|
||||||
|
assert.match(nodes, /task、node、history零漂移/)
|
||||||
|
assert.match(nodes, /actualProgressSnapshot/)
|
||||||
assert.match(pda, /targetContainerNo[\s\S]*phase3Loading\?\.containerNo/)
|
assert.match(pda, /targetContainerNo[\s\S]*phase3Loading\?\.containerNo/)
|
||||||
assert.match(pda, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/)
|
assert.match(pda, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/)
|
||||||
assert.match(pda, /seen\.has\(targetContainerNo\)/)
|
assert.match(pda, /seen\.has\(targetContainerNo\)/)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const MARKER = 'T-PHASE4'
|
|||||||
function ok(data, requestId = 'req-ok') {
|
function ok(data, requestId = 'req-ok') {
|
||||||
return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' }
|
return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' }
|
||||||
}
|
}
|
||||||
function conflict(requestId) {
|
function conflict(requestId, message = 'conflict') {
|
||||||
return { http: 200, body: { code: 'A0409', message: 'conflict', requestId }, requestId, traceId: 'trace-conflict' }
|
return { http: 200, body: { code: 'A0409', message, requestId }, requestId, traceId: 'trace-conflict' }
|
||||||
}
|
}
|
||||||
function clone(value) { return structuredClone(value) }
|
function clone(value) { return structuredClone(value) }
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ test('missing phase3 outbound query is an explicit deployment blocker and perfor
|
|||||||
|
|
||||||
function markerOwnedContext() {
|
function markerOwnedContext() {
|
||||||
let nextId = 9007199254740994000n
|
let nextId = 9007199254740994000n
|
||||||
const calls = []; const steps = []; const idempotency = new Map()
|
const calls = []; const steps = []; const assertions = []; const idempotency = new Map()
|
||||||
const task = {
|
const task = {
|
||||||
id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID,
|
id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID,
|
||||||
containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO',
|
containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO',
|
||||||
@@ -179,6 +179,11 @@ function markerOwnedContext() {
|
|||||||
: conflict(`req-hash-${key}`)
|
: conflict(`req-hash-${key}`)
|
||||||
}
|
}
|
||||||
if (Number(body.expectedVersion) !== Number(task.version)) return conflict(`req-version-${key}`)
|
if (Number(body.expectedVersion) !== Number(task.version)) return conflict(`req-version-${key}`)
|
||||||
|
if (body.actualAt != null
|
||||||
|
&& NODE_SEQUENCES[body.standardNode] <= NODE_SEQUENCES[task.latestActualNode]) {
|
||||||
|
return conflict(`req-monotonic-${key}`,
|
||||||
|
`人工实际节点必须严格晚于当前最新实际节点:${task.latestActualNode}`)
|
||||||
|
}
|
||||||
const providerEventId = `manual-${String(nextId)}`
|
const providerEventId = `manual-${String(nextId)}`
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: String(nextId++), providerCode: 'MANUAL', providerEventId, providerNodeCode: body.standardNode,
|
id: String(nextId++), providerCode: 'MANUAL', providerEventId, providerNodeCode: body.standardNode,
|
||||||
@@ -203,13 +208,16 @@ function markerOwnedContext() {
|
|||||||
apiSuccess: (_module, _name, response) => {
|
apiSuccess: (_module, _name, response) => {
|
||||||
assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data
|
assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data
|
||||||
},
|
},
|
||||||
assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`),
|
assert: (_module, name, response, condition, evidence, options) => {
|
||||||
|
assertions.push({ name, passed: Boolean(condition), evidence, options, requestId: response?.requestId || null })
|
||||||
|
assert.ok(condition, `${name}: ${evidence}`)
|
||||||
|
},
|
||||||
addStep: (_module, name, response, passed, evidence, options) => {
|
addStep: (_module, name, response, passed, evidence, options) => {
|
||||||
steps.push({ name, passed, evidence, options, requestId: response?.requestId || null })
|
steps.push({ name, passed, evidence, options, requestId: response?.requestId || null })
|
||||||
assert.ok(passed, `${name}: ${evidence}`)
|
assert.ok(passed, `${name}: ${evidence}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return { context, task, nodes, exceptions, histories, steps }
|
return { context, task, nodes, exceptions, histories, steps, assertions }
|
||||||
}
|
}
|
||||||
|
|
||||||
test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => {
|
test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => {
|
||||||
@@ -218,11 +226,15 @@ test('marker-owned chain covers idempotency conflicts, all actual nodes, excepti
|
|||||||
await run(fixture.context)
|
await run(fixture.context)
|
||||||
|
|
||||||
const posts = fixture.context.calls.filter((item) => item.method === 'POST')
|
const posts = fixture.context.calls.filter((item) => item.method === 'POST')
|
||||||
assert.equal(posts.length, 14)
|
assert.equal(posts.length, 17)
|
||||||
assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`))
|
assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`))
|
||||||
assert.ok(posts.every((item) => item.headers['Idempotency-Key']))
|
assert.ok(posts.every((item) => item.headers['Idempotency-Key']))
|
||||||
assert.ok(posts.every((item) => Object.keys(item.body).length === 8))
|
assert.ok(posts.every((item) => Object.keys(item.body).length === 8))
|
||||||
assert.equal(fixture.steps.filter((item) => /A0409/.test(item.name)).length, 2)
|
assert.equal(fixture.steps.filter((item) => /A0409/.test(item.name)).length, 6)
|
||||||
|
assert.ok(fixture.assertions.some((item) => /重复当前实际节点A0409后task、node、history零漂移/.test(item.name)))
|
||||||
|
assert.ok(fixture.assertions.some((item) => /更早实际节点A0409后task、node、history零漂移/.test(item.name)))
|
||||||
|
assert.ok(fixture.steps.some((item) => /同级actual失败请求同键重试.*operation claim零残留/.test(item.name)))
|
||||||
|
assert.ok(fixture.steps.some((item) => /更早actual失败请求同键重试.*operation claim零残留/.test(item.name)))
|
||||||
assert.equal(fixture.task.status, 'STOPPED')
|
assert.equal(fixture.task.status, 'STOPPED')
|
||||||
assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED')
|
assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED')
|
||||||
assert.equal(fixture.task.stopReason, '已还柜')
|
assert.equal(fixture.task.stopReason, '已还柜')
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.te
|
|||||||
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
|
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
|
||||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||||
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||||
|
function monotonicRejected(response) {
|
||||||
|
return rejected(response)
|
||||||
|
&& String(response?.body?.message || '').includes('人工实际节点必须严格晚于当前最新实际节点')
|
||||||
|
&& Boolean(response?.requestId) && Boolean(response?.traceId)
|
||||||
|
}
|
||||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||||
|
|
||||||
export function markerOwnedCarrierQuery(context) {
|
export function markerOwnedCarrierQuery(context) {
|
||||||
@@ -82,6 +87,18 @@ function nodeSnapshot(detail) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actualProgressSnapshot(detail) {
|
||||||
|
const task = detail?.query?.query
|
||||||
|
return {
|
||||||
|
version: Number(task?.version), latestActualNode: task?.latestActualNode,
|
||||||
|
latestActualAt: task?.latestActualAt, nodes: nodeSnapshot(detail),
|
||||||
|
histories: (Array.isArray(detail?.histories) ? detail.histories : []).map((item) => ({
|
||||||
|
id: id(item?.id), action: item?.action, fromVersion: item?.fromVersion,
|
||||||
|
toVersion: item?.toVersion, reason: item?.reason, createdAt: item?.createdAt,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertDetail(context, response, detail, name) {
|
function assertDetail(context, response, detail, name) {
|
||||||
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
|
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
|
||||||
const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : []
|
const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : []
|
||||||
@@ -189,7 +206,34 @@ export async function runUntilPickupReady(context) {
|
|||||||
: Date.parse(`${frozenLatestActual}Z`)
|
: Date.parse(`${frozenLatestActual}Z`)
|
||||||
const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000
|
const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000
|
||||||
const pickupReadyIndex = STANDARD_NODES.indexOf('PICKUP_READY')
|
const pickupReadyIndex = STANDARD_NODES.indexOf('PICKUP_READY')
|
||||||
for (let index = 0; index <= pickupReadyIndex; index += 1) {
|
const frozenLatestNodeIndex = STANDARD_NODES.indexOf(detail?.query?.query?.latestActualNode)
|
||||||
|
context.assert(MODULE, '出仓自动实际节点作为人工补录单调起点', zeroDriftResponse,
|
||||||
|
frozenLatestNodeIndex >= 0 && frozenLatestNodeIndex < pickupReadyIndex,
|
||||||
|
`latest=${detail?.query?.query?.latestActualNode || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const sameNodeSnapshot = actualProgressSnapshot(detail)
|
||||||
|
const sameNodeBody = manualNodePayload({ expectedVersion: version,
|
||||||
|
standardNode: detail.query.query.latestActualNode, observedAt: localIso(new Date()),
|
||||||
|
actualAt: localIso(new Date(baseActual)), marker: context.marker, index: frozenLatestNodeIndex + 1 })
|
||||||
|
const sameNodeResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody,
|
||||||
|
context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) })
|
||||||
|
context.addStep(MODULE, '重复当前实际节点精确返回A0409', sameNodeResponse, monotonicRejected(sameNodeResponse),
|
||||||
|
`HTTP=${sameNodeResponse.http}, code=${codeOf(sameNodeResponse)}, message=${sameNodeResponse.body?.message || 'missing'}`, OPTIONS)
|
||||||
|
const sameNodeReplay = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody,
|
||||||
|
context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) })
|
||||||
|
context.addStep(MODULE, '同级actual失败请求同键重试仍为单调A0409且operation claim零残留',
|
||||||
|
sameNodeReplay, monotonicRejected(sameNodeReplay),
|
||||||
|
`HTTP=${sameNodeReplay.http}, code=${codeOf(sameNodeReplay)}, message=${sameNodeReplay.body?.message || 'missing'}`,
|
||||||
|
OPTIONS)
|
||||||
|
const sameNodeRead = await context.request('GET', `${BASE}/${queryId}`)
|
||||||
|
detail = context.apiSuccess(MODULE, '重复当前实际节点后回查零漂移', sameNodeRead, '', OPTIONS)
|
||||||
|
context.assert(MODULE, '重复当前实际节点A0409后task、node、history零漂移', sameNodeRead,
|
||||||
|
JSON.stringify(actualProgressSnapshot(detail)) === JSON.stringify(sameNodeSnapshot),
|
||||||
|
`version=${detail?.query?.query?.version ?? 'missing'}, latest=${detail?.query?.query?.latestActualNode || 'missing'}`,
|
||||||
|
OPTIONS)
|
||||||
|
|
||||||
|
let earlierActualChecked = false
|
||||||
|
for (let index = frozenLatestNodeIndex + 1; index <= pickupReadyIndex; index += 1) {
|
||||||
const standardNode = STANDARD_NODES[index]
|
const standardNode = STANDARD_NODES[index]
|
||||||
const actualAt = localIso(new Date(baseActual + index * 60_000))
|
const actualAt = localIso(new Date(baseActual + index * 60_000))
|
||||||
const body = manualNodePayload({ expectedVersion: version, standardNode,
|
const body = manualNodePayload({ expectedVersion: version, standardNode,
|
||||||
@@ -213,6 +257,32 @@ export async function runUntilPickupReady(context) {
|
|||||||
`status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS)
|
`status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS)
|
||||||
version = Number(updatedTask.version)
|
version = Number(updatedTask.version)
|
||||||
detail = updated
|
detail = updated
|
||||||
|
|
||||||
|
if (!earlierActualChecked) {
|
||||||
|
const progressedSnapshot = actualProgressSnapshot(detail)
|
||||||
|
const earlierNode = STANDARD_NODES[index - 1]
|
||||||
|
const earlierBody = manualNodePayload({ expectedVersion: version, standardNode: earlierNode,
|
||||||
|
observedAt: localIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
|
||||||
|
marker: context.marker, index })
|
||||||
|
const earlierResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody,
|
||||||
|
context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) })
|
||||||
|
context.addStep(MODULE, '补录更早实际节点精确返回A0409', earlierResponse, monotonicRejected(earlierResponse),
|
||||||
|
`HTTP=${earlierResponse.http}, code=${codeOf(earlierResponse)}, message=${earlierResponse.body?.message || 'missing'}`,
|
||||||
|
OPTIONS)
|
||||||
|
const earlierReplay = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody,
|
||||||
|
context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) })
|
||||||
|
context.addStep(MODULE, '更早actual失败请求同键重试仍为单调A0409且operation claim零残留',
|
||||||
|
earlierReplay, monotonicRejected(earlierReplay),
|
||||||
|
`HTTP=${earlierReplay.http}, code=${codeOf(earlierReplay)}, message=${earlierReplay.body?.message || 'missing'}`,
|
||||||
|
OPTIONS)
|
||||||
|
const earlierRead = await context.request('GET', `${BASE}/${queryId}`)
|
||||||
|
detail = context.apiSuccess(MODULE, '更早实际节点冲突后回查零漂移', earlierRead, '', OPTIONS)
|
||||||
|
context.assert(MODULE, '更早实际节点A0409后task、node、history零漂移', earlierRead,
|
||||||
|
JSON.stringify(actualProgressSnapshot(detail)) === JSON.stringify(progressedSnapshot),
|
||||||
|
`version=${detail?.query?.query?.version ?? 'missing'}, latest=${detail?.query?.query?.latestActualNode || 'missing'}`,
|
||||||
|
OPTIONS)
|
||||||
|
earlierActualChecked = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickupTask = detail.query.query
|
const pickupTask = detail.query.query
|
||||||
@@ -287,6 +357,6 @@ export async function run(context) {
|
|||||||
const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||||
context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse,
|
context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse,
|
||||||
Array.isArray(history) && history.every(validHistory)
|
Array.isArray(history) && history.every(validHistory)
|
||||||
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length + 1,
|
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length,
|
||||||
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,27 @@ test('PDA module covers the container-number receiving contract and excludes out
|
|||||||
assert.doesNotMatch(source, /awaitProducerCandidates/)
|
assert.doesNotMatch(source, /awaitProducerCandidates/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('PDA receiving lifecycle is ordered from candidate claim through photos, mode-specific receipts, pallet and complete', () => {
|
||||||
|
const orderedTokens = [
|
||||||
|
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
|
||||||
|
'uploadPhotos(context, token)', "'绑定车牌封签开柜三角色照片并签收'",
|
||||||
|
"`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
|
||||||
|
'receiveOverseas(context, token', "'回查柜内逐入仓码实收进度'",
|
||||||
|
"'完成当前柜并写入库存与卸柜事件'", "'完成后current返回404正常空态'",
|
||||||
|
]
|
||||||
|
let cursor = -1
|
||||||
|
for (const token of orderedTokens) {
|
||||||
|
const next = source.indexOf(token, cursor + 1)
|
||||||
|
assert.ok(next > cursor, `${orderedTokens[Math.max(0, orderedTokens.indexOf(token) - 1)]} -> ${token}`)
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
for (const token of [
|
||||||
|
'/current/receipts/bulk', '/current/receipts/carton',
|
||||||
|
"'FBA模式硬拒绝逐箱收货'", "'OVERSEAS模式硬拒绝批量收货'",
|
||||||
|
'PHOTO_ROLES.every', "finalStatus === 'COMPLETED'", 'completedContainers.push',
|
||||||
|
]) assert.ok(source.includes(token), token)
|
||||||
|
})
|
||||||
|
|
||||||
test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => {
|
test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => {
|
||||||
const calls = []; const gaps = []; const steps = []
|
const calls = []; const gaps = []; const steps = []
|
||||||
const context = {
|
const context = {
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ function Assert-Path([string]$relativePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Assert-PathMissing([string]$relativePath) {
|
||||||
|
$script:checked++
|
||||||
|
$absolutePath = Join-Path $workspaceRoot $relativePath
|
||||||
|
if (Test-Path -LiteralPath $absolutePath) {
|
||||||
|
Add-Failure "已移除入口仍然存在: $relativePath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Assert-MarkdownTableUnique([string]$path, [string]$heading, [int]$keyColumn, [string]$scope) {
|
function Assert-MarkdownTableUnique([string]$path, [string]$heading, [int]$keyColumn, [string]$scope) {
|
||||||
$script:checked++
|
$script:checked++
|
||||||
$lines = Get-Content -LiteralPath $path
|
$lines = Get-Content -LiteralPath $path
|
||||||
@@ -91,6 +99,7 @@ Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机
|
|||||||
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
|
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
|
||||||
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
|
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
|
||||||
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
|
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
|
||||||
|
Assert-Contains $clientAgents '不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API' 'Client 头程无兼容边界'
|
||||||
|
|
||||||
# 部署单元必须能从根索引进入。
|
# 部署单元必须能从根索引进入。
|
||||||
Get-ChildItem -LiteralPath (Join-Path $workspaceRoot 'apps') -Directory |
|
Get-ChildItem -LiteralPath (Join-Path $workspaceRoot 'apps') -Directory |
|
||||||
@@ -221,6 +230,40 @@ Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA
|
|||||||
$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports')
|
$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports')
|
||||||
Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册'
|
Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册'
|
||||||
|
|
||||||
|
# 全部仓库主档统一归国内 Warehouse;海外服务只保存 warehouseId 引用和物理资源。
|
||||||
|
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
|
||||||
|
$overseasWarehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md')
|
||||||
|
$domesticWarehouseService = Read-RequiredText (Join-Path $backendRoot 'warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java')
|
||||||
|
$domesticWarehouseController = Read-RequiredText (Join-Path $backendRoot 'warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java')
|
||||||
|
$overseasWarehouseSql = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/resources/db/warehouse.sql')
|
||||||
|
$adminIamApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/iam/api/platformIam.ts')
|
||||||
|
Assert-Contains $backendAgents '全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`' '后端仓库主档权威'
|
||||||
|
Assert-Contains $warehouseAgents '/internal/warehouses/shared-overseas' '国内 Warehouse 共享海外目录'
|
||||||
|
Assert-Contains $overseasWarehouseAgents '只保存国内仓库 ID 引用' '海外 Warehouse 引用边界'
|
||||||
|
Assert-Contains $domesticWarehouseController '@GetMapping("/shared-overseas")' '共享海外仓列表入口'
|
||||||
|
Assert-Contains $domesticWarehouseService 'hasReceivingCapability' '共享海外收货能力筛选'
|
||||||
|
Assert-Contains $adminIamApi "capabilityCode: 'RECEIVING'" 'Admin 海外收货仓候选'
|
||||||
|
Assert-NotContains $overseasWarehouseSql 'CREATE TABLE IF NOT EXISTS wh_warehouse' '海外仓库主档表'
|
||||||
|
Assert-NotContains $overseasWarehouseSql 'CREATE TABLE IF NOT EXISTS wh_warehouse_address' '海外仓库地址表'
|
||||||
|
|
||||||
|
# 国内出仓按真实货柜原子确认,船节点实际进度只允许严格向后推进。
|
||||||
|
$outboundDispatchOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml')
|
||||||
|
$carrierScheduleOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml')
|
||||||
|
$adminContainerDetail = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/pages/ContainerDetailView.vue')
|
||||||
|
Assert-Contains $outboundDispatchOpenApi 'No partial success is possible.' '整柜出仓原子性'
|
||||||
|
Assert-Contains $outboundDispatchOpenApi 'truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status' '拖车状态非门禁语义'
|
||||||
|
Assert-Contains $carrierScheduleOpenApi 'strictly greater standard-node sequence than latestActualNode' '手工实际节点单调推进'
|
||||||
|
Assert-Contains $carrierScheduleOpenApi 'estimated facts never advance actual progress' '预计节点不推进实际进度'
|
||||||
|
Assert-Contains $adminContainerDetail 'confirmDeparture' 'Admin 货柜详情确认离仓'
|
||||||
|
|
||||||
|
# 平台商品图片和海外 PDA 签收照片都必须走鉴权下载,不暴露内部存储路径。
|
||||||
|
$productImageController = Read-RequiredText (Join-Path $backendRoot 'file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java')
|
||||||
|
$receivingController = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java')
|
||||||
|
Assert-Contains $productImageController '/api/platform/v1/files/product-images' '平台商品图片入口'
|
||||||
|
Assert-Contains $productImageController "hasAuthority('platform:order:list')" '平台商品图片权限'
|
||||||
|
Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览'
|
||||||
|
Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界'
|
||||||
|
|
||||||
# Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。
|
# Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。
|
||||||
[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw
|
[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw
|
||||||
foreach ($module in $pom.project.modules.module) {
|
foreach ($module in $pom.project.modules.module) {
|
||||||
@@ -249,6 +292,7 @@ $criticalPaths = @(
|
|||||||
'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue',
|
'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue',
|
||||||
'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue',
|
'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue',
|
||||||
'apps/admin-web/src/modules/iam/api/platformIam.ts',
|
'apps/admin-web/src/modules/iam/api/platformIam.ts',
|
||||||
|
'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts',
|
||||||
'apps/client-web/src/api/overseasHttp.ts',
|
'apps/client-web/src/api/overseasHttp.ts',
|
||||||
'apps/client-web/src/modules/overseas-warehouse/routes.ts',
|
'apps/client-web/src/modules/overseas-warehouse/routes.ts',
|
||||||
'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts',
|
'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts',
|
||||||
@@ -264,6 +308,8 @@ $criticalPaths = @(
|
|||||||
'apps/pda-overseas-uniapp/src/pages.json',
|
'apps/pda-overseas-uniapp/src/pages.json',
|
||||||
'apps/pda-overseas-uniapp/src/data/overseas-workbench.js',
|
'apps/pda-overseas-uniapp/src/data/overseas-workbench.js',
|
||||||
'apps/pda-overseas-uniapp/src/pages/operation/operation.vue',
|
'apps/pda-overseas-uniapp/src/pages/operation/operation.vue',
|
||||||
|
'apps/pda-overseas-uniapp/src/stores/receiving.js',
|
||||||
|
'apps/pda-overseas-uniapp/tests/receiving-photo-ui-contract.test.mjs',
|
||||||
'apps/pda-overseas-uniapp/src/config/runtime.js',
|
'apps/pda-overseas-uniapp/src/config/runtime.js',
|
||||||
'apps/pda-overseas-uniapp/src/api/auth.js',
|
'apps/pda-overseas-uniapp/src/api/auth.js',
|
||||||
'apps/pda-overseas-uniapp/src/api/inbound.js',
|
'apps/pda-overseas-uniapp/src/api/inbound.js',
|
||||||
@@ -283,6 +329,12 @@ $criticalPaths = @(
|
|||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java',
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java',
|
||||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java',
|
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java',
|
||||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java',
|
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java',
|
||||||
|
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java',
|
||||||
|
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java',
|
||||||
|
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java',
|
||||||
|
'baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml',
|
||||||
|
'baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml',
|
||||||
|
'baiyue-firstleg/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml',
|
||||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java',
|
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java',
|
||||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java',
|
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java',
|
||||||
'baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml',
|
'baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml',
|
||||||
@@ -318,6 +370,7 @@ $criticalPaths = @(
|
|||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java',
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java',
|
||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java',
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java',
|
||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java',
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java',
|
||||||
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java',
|
||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java',
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java',
|
||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java',
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java',
|
||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java',
|
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java',
|
||||||
@@ -332,6 +385,11 @@ $criticalPaths = @(
|
|||||||
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java',
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java',
|
||||||
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java',
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java',
|
||||||
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java',
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java',
|
||||||
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java',
|
||||||
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/DomesticWarehouseDirectory.java',
|
||||||
|
'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java',
|
||||||
|
'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/dto/SharedOverseasWarehouseResponse.java',
|
||||||
|
'baiyue-firstleg/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java',
|
||||||
'baiyue-firstleg/lastleg-service/docs/lastleg-internal-modularization-baseline.md',
|
'baiyue-firstleg/lastleg-service/docs/lastleg-internal-modularization-baseline.md',
|
||||||
'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.mjs',
|
'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.mjs',
|
||||||
'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs',
|
'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs',
|
||||||
@@ -367,6 +425,16 @@ $criticalPaths = @(
|
|||||||
)
|
)
|
||||||
$criticalPaths | ForEach-Object { Assert-Path $_ }
|
$criticalPaths | ForEach-Object { Assert-Path $_ }
|
||||||
|
|
||||||
|
foreach ($removedOverseasWarehouseMasterPath in @(
|
||||||
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java',
|
||||||
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java',
|
||||||
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java',
|
||||||
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java',
|
||||||
|
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java'
|
||||||
|
)) {
|
||||||
|
Assert-PathMissing $removedOverseasWarehouseMasterPath
|
||||||
|
}
|
||||||
|
|
||||||
# 只检查明确维护的快速索引表,避免把排除规则正文中的目录名误判为业务入口。
|
# 只检查明确维护的快速索引表,避免把排除规则正文中的目录名误判为业务入口。
|
||||||
$indexedAgentFiles = @(
|
$indexedAgentFiles = @(
|
||||||
$rootAgentsPath,
|
$rootAgentsPath,
|
||||||
@@ -383,6 +451,7 @@ $indexedAgentFiles = @(
|
|||||||
(Join-Path $backendRoot 'common-web/AGENTS.md'),
|
(Join-Path $backendRoot 'common-web/AGENTS.md'),
|
||||||
(Join-Path $backendRoot 'common-openapi/AGENTS.md'),
|
(Join-Path $backendRoot 'common-openapi/AGENTS.md'),
|
||||||
(Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'),
|
(Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'),
|
||||||
|
(Join-Path $backendRoot 'warehouse/AGENTS.md'),
|
||||||
(Join-Path $backendRoot 'event-bridge-service/AGENTS.md'),
|
(Join-Path $backendRoot 'event-bridge-service/AGENTS.md'),
|
||||||
(Join-Path $backendRoot 'file/AGENTS.md'),
|
(Join-Path $backendRoot 'file/AGENTS.md'),
|
||||||
(Join-Path $backendRoot 'common-event-contract/AGENTS.md')
|
(Join-Path $backendRoot 'common-event-contract/AGENTS.md')
|
||||||
@@ -422,5 +491,5 @@ if ($failures.Count -gt 0) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
|
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| 目标 | 入口 |
|
| 目标 | 入口 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 仓库/地址/能力 | `controller/PlatformWarehouseController.java`、`MerchantWarehouseController.java`、`service/WarehouseApplicationService.java` |
|
| 仓库/地址/能力 | `controller/PlatformWarehouseController.java`、`MerchantWarehouseController.java`、`service/WarehouseApplicationService.java` |
|
||||||
|
| 公共海外收货仓内部目录 | `controller/InternalWarehouseController.java` 的 `/internal/warehouses/shared-overseas`、`/shared-overseas/{warehouseId}`,响应 `dto/SharedOverseasWarehouseResponse.java`;只返回平台公共 `OVERSEAS + ACTIVE + RECEIVING` 仓,供海外资源服务按 warehouseId 引用 |
|
||||||
| 四期海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 |
|
| 四期海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 |
|
||||||
| 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` |
|
| 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` |
|
||||||
| 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` |
|
| 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` |
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
## 修改约束
|
## 修改约束
|
||||||
|
|
||||||
- 内部仓储接口使用 `X-Internal-Service-Token`,不加入 Gateway 公网路由;资源停用/不存在遵循统一 body code。
|
- 内部仓储接口使用 `X-Internal-Service-Token`,不加入 Gateway 公网路由;资源停用/不存在遵循统一 body code。
|
||||||
|
- 仓库 ID、类型、状态、地址和服务能力在本模块唯一维护;海外服务不得复制仓库主档。共享海外收货仓目录只接受内部服务令牌,列表和单项必须使用相同的 `tenant_id=0 + OVERSEAS + ACTIVE + RECEIVING` 判定。
|
||||||
- 导入先全量校验再原子写入;调整 DTO、类型或容量规则时同步 Order/Firstleg 客户端和契约测试。
|
- 导入先全量校验再原子写入;调整 DTO、类型或容量规则时同步 Order/Firstleg 客户端和契约测试。
|
||||||
|
|
||||||
## 三期托盘、库位与 A0 规则
|
## 三期托盘、库位与 A0 规则
|
||||||
|
|||||||
Reference in New Issue
Block a user