diff --git a/AGENTS.md b/AGENTS.md index c399798f3..631c1896c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `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。 ## 服务路由(按关键词定位) @@ -84,7 +84,7 @@ - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -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`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/file/AGENTS.md b/file/AGENTS.md index 0a6a87575..1e746a882 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -12,6 +12,7 @@ | 商户/平台资质 | `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/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` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | @@ -23,6 +24,7 @@ - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 - 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。 +- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。 - 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。 - 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 36b45e079..a6ff072d0 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -29,6 +29,7 @@ | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.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` 进入,明细规格拆分留在服务端 | +| 货柜资料、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` | | 装柜完成跨区事件 | `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` | @@ -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` | | 船节点跨区交接事件 | `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | -| 拖车与出仓调度 | `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` | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | @@ -58,6 +59,8 @@ - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `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,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 海外卸柜完成事件只接受 `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 复制财务状态机。 - `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 477b6158d..b0996c996 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -25,7 +25,7 @@ | 托盘分配算法 | `inboundplan/service/PalletAllocator.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 卸柜照片文件上传与归属校验 | `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` | | 入仓后 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,不含出库和派送 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index b87e1a101..83f389c44 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -11,6 +11,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; 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.security.core.annotation.AuthenticationPrincipal; 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.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; import java.util.List; @RestController @@ -66,6 +71,18 @@ public class ReceivingContainerController { return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } + @GetMapping("/current/unload-photos/files/{fileId}/download") + public ResponseEntity 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") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index dda326fbe..f94f3f1ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -17,7 +17,7 @@ public final class ReceivingContainerResponses { int totalPackages, int receivedPackages, int pendingPackages, List inbounds) { } - public record SignedPhoto(ReceivingPhotoRole role, String fileId) { } + public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { } public record Inbound(String inboundCode, String destinationType, String overseasWarehouseId, String overseasWarehouseName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java index 6a8b0c243..f1f69210f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatus; 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.core.io.ByteArrayResource; 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 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) { try { return Long.parseLong(customerId); } catch (NumberFormatException exception) { throw new ResponseStatusException( @@ -104,6 +140,15 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { 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 Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java index b1abf3e64..dcd4d7578 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java @@ -5,6 +5,8 @@ import org.springframework.web.multipart.MultipartFile; public interface UnloadPhotoFileGateway { UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); 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 DownloadedFile(byte[] content, String fileName, String contentType) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index cab86a93b..74c938574 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -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.ReceivingContainerRequests; 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.ReceivingContainerSession; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; @@ -58,6 +59,9 @@ import java.util.stream.Collectors; @Service 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 InboundPlanLineMapper lines; @Resource private InboundPlanPalletMapper pallets; @@ -198,6 +202,16 @@ public class ReceivingContainerService { 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) { requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); } @@ -510,7 +524,7 @@ public class ReceivingContainerService { throw conflict("Exactly three typed unload photos are required"); } if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null"); - Set roles = request.photos().stream() + Set roles = request.photos().stream() .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); Set fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId())) @@ -520,7 +534,7 @@ public class ReceivingContainerService { } return request.photos().stream() .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) { @@ -726,6 +740,13 @@ public class ReceivingContainerService { } private List 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 signedPhotos(ReceivingContainerSession session) { if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of(); try { return objectMapper.readValue(session.getSignPhotos(), new TypeReference>() { }); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index b01b57ae5..a480a05b5 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -31,10 +31,29 @@ paths: security: [{ bearerAuth: [] }] 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: 当前柜已签收 } } + /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: post: summary: 签收当前柜 - description: 权限 lastleg:pda:operate;精确校验车牌、封签、开柜三类照片各一次及文件归属后由 WAIT_SIGN 推进 SIGNED。 + description: 权限 lastleg:pda:operate;精确校验车牌、封签、开门三类照片各且仅 1 张及文件归属后由 WAIT_SIGN 推进 SIGNED;重拍由调用端在签收前替换对应槽位。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } } responses: { '200': { description: 已签收会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 状态或照片校验失败 } } @@ -678,6 +697,13 @@ components: 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 } + 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: type: object required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey] @@ -742,7 +768,7 @@ components: status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } operatorName: { type: string, nullable: true } 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 } signedAt: { type: string, format: date-time, nullable: true } receivingAt: { type: string, format: date-time, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index d3d4447dc..5cb813bdd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -128,6 +128,7 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); 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}")); String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) @@ -139,6 +140,8 @@ class ReceivingContainerContractTest { assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); 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("overseasWarehouseName: { type: string")); assertFalse(receivingPaths.contains("photoFileIds")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index 3ff993075..5936cc5b2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller; import com.baiyue.common.security.AuthenticatedUser; 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.pda.security.PdaWarehouseIdentity; 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.mockito.Mock; 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.web.server.ResponseStatusException; import java.util.List; import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -55,6 +60,23 @@ class ReceivingContainerControllerTest { .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 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 void pickerAuthorityWithoutWarehouseBindingIsForbidden() { AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java index 734406198..443d5fbee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; 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.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.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; class HttpUnloadPhotoFileGatewayTest { private HttpUnloadPhotoFileGateway gateway; @@ -51,4 +55,32 @@ class HttpUnloadPhotoFileGatewayTest { error -> assertThat(error.getStatusCode().value()).isEqualTo(400)); 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(); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 6fedbef7b..b7bf58732 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -315,6 +315,11 @@ class ReceivingContainerServiceBehaviorTest { ReceivingPhotoRole.DOOR_OPEN); org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId") .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, 2L); verify(unloadPhotos).validate("100", 8L, 3L); @@ -356,6 +361,35 @@ class ReceivingContainerServiceBehaviorTest { 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 void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { stubCurrent("FBA", "CONT-A"); diff --git a/scripts/README.md b/scripts/README.md index 360871a62..87f2ddec1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,7 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 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` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | diff --git a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs index 2ee659802..b41412b45 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.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, /const pickupReadyIndex = STANDARD_NODES\.indexOf\('PICKUP_READY'\)/) 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, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/) assert.match(pda, /seen\.has\(targetContainerNo\)/) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 4a26efff9..12ed6ecb0 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -20,8 +20,8 @@ const MARKER = 'T-PHASE4' function ok(data, requestId = 'req-ok') { return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' } } -function conflict(requestId) { - return { http: 200, body: { code: 'A0409', message: 'conflict', requestId }, requestId, traceId: 'trace-conflict' } +function conflict(requestId, message = 'conflict') { + return { http: 200, body: { code: 'A0409', message, requestId }, requestId, traceId: 'trace-conflict' } } function clone(value) { return structuredClone(value) } @@ -121,7 +121,7 @@ test('missing phase3 outbound query is an explicit deployment blocker and perfor function markerOwnedContext() { let nextId = 9007199254740994000n - const calls = []; const steps = []; const idempotency = new Map() + const calls = []; const steps = []; const assertions = []; const idempotency = new Map() const task = { id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID, containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', @@ -179,6 +179,11 @@ function markerOwnedContext() { : conflict(`req-hash-${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)}` nodes.push({ id: String(nextId++), providerCode: 'MANUAL', providerEventId, providerNodeCode: body.standardNode, @@ -203,13 +208,16 @@ function markerOwnedContext() { apiSuccess: (_module, _name, response) => { 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) => { steps.push({ name, passed, evidence, options, requestId: response?.requestId || null }) 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 () => { @@ -218,11 +226,15 @@ test('marker-owned chain covers idempotency conflicts, all actual nodes, excepti await run(fixture.context) 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.headers['Idempotency-Key'])) 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.latestActualNode, 'EMPTY_RETURNED') assert.equal(fixture.task.stopReason, '已还柜') diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 903cb0da3..3402042e5 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -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 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 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) } 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) { const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] @@ -189,7 +206,34 @@ export async function runUntilPickupReady(context) { : Date.parse(`${frozenLatestActual}Z`) const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 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 actualAt = localIso(new Date(baseActual + index * 60_000)) const body = manualNodePayload({ expectedVersion: version, standardNode, @@ -213,6 +257,32 @@ export async function runUntilPickupReady(context) { `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) version = Number(updatedTask.version) 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 @@ -287,6 +357,6 @@ export async function run(context) { const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse, 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) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 61ac6ec3a..0f4bf940b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -82,6 +82,27 @@ test('PDA module covers the container-number receiving contract and excludes out 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 () => { const calls = []; const gaps = []; const steps = [] const context = { diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index b160d35e0..1fc3508ae 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -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) { $script:checked++ $lines = Get-Content -LiteralPath $path @@ -91,6 +99,7 @@ Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机 Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围' Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则' Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界' +Assert-Contains $clientAgents '不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API' 'Client 头程无兼容边界' # 部署单元必须能从根索引进入。 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') 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 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw 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/iam/pages/InternalUsersView.vue', '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/modules/overseas-warehouse/routes.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/data/overseas-workbench.js', '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/api/auth.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/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/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/mapper/OceanBookingMapper.java', '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/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/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/service/PdaStocktakeService.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/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/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/scripts/analyze-lastleg-dependencies.mjs', 'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs', @@ -367,6 +425,16 @@ $criticalPaths = @( ) $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 = @( $rootAgentsPath, @@ -383,6 +451,7 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'common-web/AGENTS.md'), (Join-Path $backendRoot 'common-openapi/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 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') @@ -422,5 +491,5 @@ if ($failures.Count -gt 0) { 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 diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index c888270b4..a0991e123 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -12,6 +12,7 @@ | 目标 | 入口 | |---|---| | 仓库/地址/能力 | `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/`,并与普通海外仓语义明确区分 | | 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` | | 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` | @@ -26,6 +27,7 @@ ## 修改约束 - 内部仓储接口使用 `X-Internal-Service-Token`,不加入 Gateway 公网路由;资源停用/不存在遵循统一 body code。 +- 仓库 ID、类型、状态、地址和服务能力在本模块唯一维护;海外服务不得复制仓库主档。共享海外收货仓目录只接受内部服务令牌,列表和单项必须使用相同的 `tenant_id=0 + OVERSEAS + ACTIVE + RECEIVING` 判定。 - 导入先全量校验再原子写入;调整 DTO、类型或容量规则时同步 Order/Firstleg 客户端和契约测试。 ## 三期托盘、库位与 A0 规则