From ca9e7b60e89a5c77d7d42870bbe6af32b2a50c8a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 30 Aug 2026 17:02:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + firstleg/AGENTS.md | 1 + .../PlatformLoadingAssociationController.java | 34 ++++++ .../firstleg/dto/LoadingAssociationDtos.java | 33 ++++++ .../mapper/LoadingAssociationMapper.java | 72 +++++++++++++ .../LoadingAssociationQueryService.java | 59 ++++++++++ .../main/resources/openapi/firstleg-v1.yaml | 48 +++++++++ ...dingAssociationControllerContractTest.java | 46 ++++++++ .../LoadingAssociationMapperContractTest.java | 38 +++++++ .../LoadingAssociationQueryServiceTest.java | 97 +++++++++++++++++ .../InboundCodeRelationshipController.java | 44 ++++++++ .../dto/InboundCodeRelationshipResponse.java | 21 ++++ .../mapper/InboundCodeRelationshipMapper.java | 41 +++++++ .../model/InboundCodeRelationshipRows.java | 29 +++++ .../InboundCodeRelationshipService.java | 85 +++++++++++++++ .../openapi/overseas-business-v1.yaml | 62 +++++++++++ ...ndCodeRelationshipOpenApiContractTest.java | 35 ++++++ ...InboundCodeRelationshipControllerTest.java | 68 ++++++++++++ ...undCodeRelationshipMapperContractTest.java | 19 ++++ .../InboundCodeRelationshipServiceTest.java | 102 ++++++++++++++++++ .../MerchantFirstLegOrderController.java | 15 +++ .../PlatformFirstLegOrderController.java | 15 +++ .../dto/InboundCodeAssociationSummary.java | 19 ++++ .../order/repository/OrderRepository.java | 15 +++ .../InboundCodeAssociationService.java | 65 +++++++++++ .../src/main/resources/openapi/order-v1.yaml | 59 +++++++++- .../InboundCodeAssociationContractTest.java | 48 +++++++++ .../OrderControllerPermissionTest.java | 4 + .../PlatformOrderPageControllerTest.java | 2 + .../OrderRepositoryIsolationUpdateTest.java | 30 ++++++ .../InboundCodeAssociationServiceTest.java | 83 ++++++++++++++ scripts/README.md | 58 ++++++++++ .../IT测试客户八千票待分柜数据生成方案.md | 6 +- scripts/admin-test-env-modules/README.md | 2 + .../批量待分柜数据生成指南.md | 6 +- scripts/agents-index-contract.ps1 | 13 +++ scripts/it-test-wait-split-bulk.mjs | 10 ++ .../script-documentation-contract.test.mjs | 81 ++++++++++++++ warehouse/scripts/README.md | 9 ++ ...house-public-master-data-contract.test.mjs | 2 +- 40 files changed, 1471 insertions(+), 6 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingAssociationDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingAssociationMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingAssociationQueryService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingAssociationMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingAssociationQueryServiceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InboundCodeRelationshipResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InboundCodeRelationshipRows.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InboundCodeRelationshipOpenApiContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapperContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipServiceTest.java create mode 100644 order/src/main/java/com/baiyue/order/dto/InboundCodeAssociationSummary.java create mode 100644 order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java create mode 100644 order/src/test/java/com/baiyue/order/controller/InboundCodeAssociationContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/InboundCodeAssociationServiceTest.java create mode 100644 scripts/script-documentation-contract.test.mjs create mode 100644 warehouse/scripts/README.md rename warehouse/{src/test => }/scripts/warehouse-public-master-data-contract.test.mjs (98%) diff --git a/AGENTS.md b/AGENTS.md index 9718027b2..c139e9e48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | +| 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | | 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 660334ebe..3fe5c446d 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -29,6 +29,7 @@ | 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | +| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 货柜与智能排柜 | `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` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationController.java new file mode 100644 index 000000000..8152da2f2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationController.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingAssociationDtos.Summary; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.LoadingAssociationQueryService; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/first-leg/loading-associations") +public class PlatformLoadingAssociationController { + @Resource + private LoadingAssociationQueryService service; + + @GetMapping("/summary") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse summary(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) @Positive Long inboundId, + @RequestParam(required = false) @Size(max = 40) String inboundCode) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.summary(inboundId, inboundCode)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingAssociationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingAssociationDtos.java new file mode 100644 index 000000000..6867634a0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingAssociationDtos.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.dto; + +import java.util.List; + +/** Admin 统一关联抽屉使用的装柜/PDA只读业务摘要。 */ +public final class LoadingAssociationDtos { + private LoadingAssociationDtos() { + } + + public record ItemReference(String itemId) { + } + + public record Summary( + String inboundId, + String inboundCode, + String orderNo, + String loadingPlanId, + String loadingPlanNo, + String containerBookingId, + String cabinetNo, + String containerNo, + String loadingStatus, + String taskId, + String pdaTaskStatus, + String coLoadGroupId, + String coLoadGroupNo, + List items + ) { + public Summary { + items = items == null ? List.of() : List.copyOf(items); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingAssociationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingAssociationMapper.java new file mode 100644 index 000000000..5496cac24 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingAssociationMapper.java @@ -0,0 +1,72 @@ +package com.baiyue.firstleg.mapper; + +import lombok.Getter; +import lombok.Setter; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** Firstleg 单库内按入仓片段读取装柜、PDA与同柜组稳定关联。 */ +@Mapper +public interface LoadingAssociationMapper { + String SELECT_ASSOCIATIONS = """ + SELECT inbound.id inboundId,inbound.inbound_code inboundCode,inbound.order_no orderNo, + item.id itemId,plan.id loadingPlanId,plan.plan_no loadingPlanNo, + booking.id containerBookingId,booking.cabinet_no cabinetNo, + booking.container_no containerNo,plan.plan_status loadingStatus, + task.id taskId, + CASE WHEN task.id IS NULL THEN NULL + WHEN task.task_status='COMPLETED' THEN 'COMPLETED' + WHEN task.task_status='COMPLETING' THEN 'PROCESSING' + WHEN task.task_status='COMPLETION_FAILED' THEN 'COMPLETION_FAILED' + WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED' + WHEN NOT EXISTS (SELECT 1 FROM fl_loading_pick_item unresolved + WHERE unresolved.pick_task_id=task.id + AND unresolved.item_status IN ('PENDING','PICKING')) + THEN 'AWAITING_REVIEW' + ELSE 'IN_PROGRESS' END pdaTaskStatus, + group_row.id coLoadGroupId,group_row.group_no coLoadGroupNo + FROM fl_inbound_order inbound + LEFT JOIN fl_loading_plan_item item + ON item.account_id=inbound.tenant_id AND item.inbound_id=inbound.id AND item.deleted=0 + LEFT JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id + LEFT JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + LEFT JOIN fl_loading_pick_task task ON task.loading_plan_id=plan.id + LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id + LEFT JOIN fl_coload_group_member group_member ON group_member.id=( + SELECT MAX(member_latest.id) FROM fl_coload_group_member member_latest + JOIN fl_coload_group group_latest ON group_latest.id=member_latest.group_id + AND group_latest.group_status<>'CANCELLED' + WHERE member_latest.tenant_id=inbound.tenant_id + AND member_latest.inbound_id=inbound.id) + LEFT JOIN fl_coload_group group_row ON group_row.id=group_member.group_id + WHERE inbound.deleted=0 + """; + + @Select(SELECT_ASSOCIATIONS + " AND inbound.id=#{inboundId} ORDER BY item.id") + List byInboundId(@Param("inboundId") long inboundId); + + @Select(SELECT_ASSOCIATIONS + " AND inbound.inbound_code=#{inboundCode} ORDER BY inbound.id,item.id") + List byInboundCode(@Param("inboundCode") String inboundCode); + + @Getter + @Setter + class Row { + private Long inboundId; + private String inboundCode; + private String orderNo; + private Long itemId; + private Long loadingPlanId; + private String loadingPlanNo; + private Long containerBookingId; + private String cabinetNo; + private String containerNo; + private String loadingStatus; + private Long taskId; + private String pdaTaskStatus; + private Long coLoadGroupId; + private String coLoadGroupNo; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingAssociationQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingAssociationQueryService.java new file mode 100644 index 000000000..339ae4685 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingAssociationQueryService.java @@ -0,0 +1,59 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingAssociationDtos.ItemReference; +import com.baiyue.firstleg.dto.LoadingAssociationDtos.Summary; +import com.baiyue.firstleg.mapper.LoadingAssociationMapper; +import com.baiyue.firstleg.mapper.LoadingAssociationMapper.Row; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Objects; + +/** 装柜关联只读查询;inboundCode解析和业务摘要编排均不改变现有写接口主键。 */ +@Service +public class LoadingAssociationQueryService { + @Resource + private LoadingAssociationMapper mapper; + + public Summary summary(Long inboundId, String inboundCode) { + String code = StringUtils.hasText(inboundCode) ? inboundCode.trim() : null; + if ((inboundId == null) == (code == null)) { + throw new BizException(ResultCode.BAD_REQUEST, "inboundId与inboundCode必须且只能提供一个"); + } + if (inboundId != null && inboundId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "inboundId必须大于0"); + } + List rows = inboundId == null ? mapper.byInboundCode(code) : mapper.byInboundId(inboundId); + if (rows == null || rows.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "入仓片段不存在"); + } + Row first = rows.get(0); + requireSingleValue(rows.stream().map(Row::getInboundId).toList(), "inboundCode匹配多个入仓片段"); + requireSingleValue(rows.stream().map(Row::getLoadingPlanId).filter(Objects::nonNull).toList(), + "同一入仓片段关联多个有效装柜计划"); + requireSingleValue(rows.stream().map(Row::getTaskId).filter(Objects::nonNull).toList(), + "同一入仓片段关联多个PDA任务"); + requireSingleValue(rows.stream().map(Row::getCoLoadGroupId).filter(Objects::nonNull).toList(), + "同一入仓片段关联多个有效同柜组"); + List items = rows.stream().map(Row::getItemId).filter(Objects::nonNull).distinct() + .sorted().map(String::valueOf).map(ItemReference::new).toList(); + return new Summary(id(first.getInboundId()), first.getInboundCode(), first.getOrderNo(), + id(first.getLoadingPlanId()), first.getLoadingPlanNo(), id(first.getContainerBookingId()), + first.getCabinetNo(), first.getContainerNo(), first.getLoadingStatus(), id(first.getTaskId()), + first.getPdaTaskStatus(), id(first.getCoLoadGroupId()), first.getCoLoadGroupNo(), items); + } + + private static void requireSingleValue(List values, String message) { + if (values.stream().distinct().limit(2).count() > 1) { + throw new BizException(ResultCode.CONFLICT, message); + } + } + + private static String id(Long value) { + return value == null ? null : value.toString(); + } +} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index ac2851984..137ba397b 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -414,6 +414,29 @@ paths: - {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: {'200': {description: 成功,data 为入仓码级候选列表}} + /api/platform/v1/first-leg/loading-associations/summary: + get: + summary: 查询入仓片段的装柜与国内PDA关联摘要 + description: >- + 需要 platform:firstleg:smart-loading:list 权限。inboundId 与 inboundCode 必须且只能提供一个; + inboundCode 是订单当前有效入仓片段业务号。响应优先提供 orderNo、loadingPlanNo、cabinetNo/containerNo、 + loadingStatus、pdaTaskStatus 与 coLoadGroupNo 供页面展示;loadingPlanId、containerBookingId、taskId、 + coLoadGroupId 和 items[].itemId 仅为技术关联,不得替代业务号作为UI主标识。 + parameters: + - {name: inboundId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundCode, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 40}} + responses: + '200': + description: 成功或业务失败;未进入装柜时仍返回 inboundId/inboundCode,其余关联字段为空 + content: + application/json: + schema: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/LoadingAssociationSummary'} /api/platform/v1/first-leg/smart-loading/plans/{planId}: get: summary: 查询智能装柜草稿详情 @@ -1682,6 +1705,31 @@ components: expectedPlanVersion: {type: integer, minimum: 0} expectedItemVersion: {type: integer, minimum: 0} operationId: {type: string, minLength: 1, maxLength: 128} + LoadingAssociationSummary: + type: object + required: [inboundId, inboundCode, orderNo, items] + properties: + inboundId: {type: string, pattern: '^[1-9][0-9]*$', description: 入仓片段内部技术ID} + inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示} + orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示} + loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID} + loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示} + containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID} + cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示} + containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示} + loadingStatus: {type: string, enum: [EDITING, LOADING, COMPLETED], nullable: true} + taskId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 国内PDA任务技术关联ID} + pdaTaskStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED], nullable: true, description: 国内PDA任务业务状态} + coLoadGroupId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 同柜不可拆组技术关联ID} + coLoadGroupNo: {type: string, nullable: true, description: 同柜不可拆组业务号,UI主展示} + items: + type: array + description: 装柜明细技术关联;不作为UI主展示 + items: + type: object + required: [itemId] + properties: + itemId: {type: string, pattern: '^[1-9][0-9]*$'} SmartLoadingPlan: type: object required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationControllerContractTest.java new file mode 100644 index 000000000..56668189c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingAssociationControllerContractTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingAssociationDtos; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformLoadingAssociationControllerContractTest { + @Test + void technicalAssociationIdsAreExplicitStringsInResponseDto() { + assertThat(LoadingAssociationDtos.Summary.class.getRecordComponents()) + .filteredOn(component -> component.getName().endsWith("Id")) + .allSatisfy(component -> assertThat(component.getType()).isEqualTo(String.class)); + assertThat(LoadingAssociationDtos.ItemReference.class.getRecordComponents()[0].getType()) + .isEqualTo(String.class); + } + + @Test + void summaryUsesSmartLoadingReadPermission() throws Exception { + Method method = PlatformLoadingAssociationController.class.getDeclaredMethod( + "summary", AuthenticatedUser.class, Long.class, String.class); + + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:smart-loading:list')"); + } + + @Test + void openApiKeepsBusinessNumbersPrimaryAndTechnicalIdsAsAssociations() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/firstleg-v1.yaml")); + + assertThat(yaml) + .contains("/api/platform/v1/first-leg/loading-associations/summary:") + .contains("inboundId 与 inboundCode 必须且只能提供一个") + .contains("LoadingAssociationSummary:") + .contains("orderNo:", "loadingPlanNo:", "cabinetNo:", "containerNo:", "coLoadGroupNo:") + .contains("pdaTaskStatus:") + .contains("loadingPlanId:", "containerBookingId:", "taskId:", "itemId:") + .contains("仅为技术关联,不得替代业务号作为UI主标识"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingAssociationMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingAssociationMapperContractTest.java new file mode 100644 index 000000000..189820170 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/mapper/LoadingAssociationMapperContractTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingAssociationMapperContractTest { + @Test + void mapperIsExplicitAndReadsOnlyFirstlegOwnedTables() throws Exception { + assertThat(LoadingAssociationMapper.class).hasAnnotation(Mapper.class); + Method method = LoadingAssociationMapper.class.getDeclaredMethod("byInboundId", long.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("fl_inbound_order", "fl_loading_plan_item", "fl_loading_plan", + "fl_container_booking", "fl_loading_pick_task", "fl_coload_group_member") + .contains("plan.plan_no loadingPlanNo") + .contains("inbound.order_no orderNo") + .contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo") + .contains("group_row.group_no coLoadGroupNo") + .contains("'UNASSIGNED'", "'IN_PROGRESS'", "'AWAITING_REVIEW'", "'PROCESSING'", + "'COMPLETION_FAILED'", "'COMPLETED'") + .doesNotContain("baiyue_order", "baiyue_warehouse", "baiyue_iam"); + } + + @Test + void inboundCodeQueryUsesExactBusinessIdentifier() throws Exception { + Method method = LoadingAssociationMapper.class.getDeclaredMethod("byInboundCode", String.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + + assertThat(sql).contains("inbound.inbound_code=#{inboundCode}") + .doesNotContain("LIKE CONCAT"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingAssociationQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingAssociationQueryServiceTest.java new file mode 100644 index 000000000..c31cc3df2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingAssociationQueryServiceTest.java @@ -0,0 +1,97 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.mapper.LoadingAssociationMapper; +import com.baiyue.firstleg.mapper.LoadingAssociationMapper.Row; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +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; + +@SpringJUnitConfig(LoadingAssociationQueryService.class) +class LoadingAssociationQueryServiceTest { + @MockBean + private LoadingAssociationMapper mapper; + @Resource + private LoadingAssociationQueryService service; + + @Test + void returnsBusinessIdentifiersAndPreservesEveryTechnicalItemReference() { + Row first = row(800L); + Row second = row(801L); + when(mapper.byInboundCode("ZBYHK2608300001-2")).thenReturn(List.of(first, second)); + + var result = service.summary(null, " ZBYHK2608300001-2 "); + + assertThat(result.inboundCode()).isEqualTo("ZBYHK2608300001-2"); + assertThat(result.orderNo()).isEqualTo("FO-260830-0001"); + assertThat(result.inboundId()).isEqualTo("100"); + assertThat(result.loadingPlanId()).isEqualTo("200"); + assertThat(result.loadingPlanNo()).isEqualTo("LP-260830-0001"); + assertThat(result.cabinetNo()).isEqualTo("CAB-260830-0001"); + assertThat(result.containerNo()).isEqualTo("MSCU1234567"); + assertThat(result.loadingStatus()).isEqualTo("LOADING"); + assertThat(result.pdaTaskStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(result.coLoadGroupNo()).isEqualTo("CLG-260830-0001"); + assertThat(result.items()).extracting(item -> item.itemId()).containsExactly("800", "801"); + verify(mapper).byInboundCode("ZBYHK2608300001-2"); + } + + @Test + void returnsFragmentIdentityWhenItHasNoLoadingAssociation() { + Row row = new Row(); + row.setInboundId(100L); + row.setInboundCode("ZBYHK2608300002"); + when(mapper.byInboundId(100L)).thenReturn(List.of(row)); + + var result = service.summary(100L, null); + + assertThat(result.inboundCode()).isEqualTo("ZBYHK2608300002"); + assertThat(result.loadingPlanId()).isNull(); + assertThat(result.pdaTaskStatus()).isNull(); + assertThat(result.items()).isEmpty(); + } + + @Test + void selectorMustBeExclusiveAndExactCodeCannotResolveMultipleFragments() { + assertThatThrownBy(() -> service.summary(null, null)) + .isInstanceOf(BizException.class).hasMessageContaining("必须且只能"); + assertThatThrownBy(() -> service.summary(100L, "ZBYHK2608300001")) + .isInstanceOf(BizException.class).hasMessageContaining("必须且只能"); + Row first = row(800L); + Row other = row(801L); + other.setInboundId(101L); + when(mapper.byInboundCode("DUPLICATE")).thenReturn(List.of(first, other)); + + assertThatThrownBy(() -> service.summary(null, "DUPLICATE")) + .isInstanceOfSatisfying(BizException.class, error -> + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + } + + private static Row row(long itemId) { + Row row = new Row(); + row.setInboundId(100L); + row.setInboundCode("ZBYHK2608300001-2"); + row.setOrderNo("FO-260830-0001"); + row.setItemId(itemId); + row.setLoadingPlanId(200L); + row.setLoadingPlanNo("LP-260830-0001"); + row.setContainerBookingId(300L); + row.setCabinetNo("CAB-260830-0001"); + row.setContainerNo("MSCU1234567"); + row.setLoadingStatus("LOADING"); + row.setTaskId(400L); + row.setPdaTaskStatus("AWAITING_REVIEW"); + row.setCoLoadGroupId(500L); + row.setCoLoadGroupNo("CLG-260830-0001"); + return row; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipController.java new file mode 100644 index 000000000..c983a74a6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipController.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse; +import com.baiyue.lastleg.inventory.query.service.InboundCodeRelationshipService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@Tag(name = "Inventory queries") +public class InboundCodeRelationshipController { + static final String PATH = "/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}"; + + @Resource private InboundCodeRelationshipService service; + + @GetMapping(PATH) + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "Resolve overseas relationships by inbound code", + description = "Platform-only Lastleg relationship read model. Required authority: " + + OverseasPermissions.INVENTORY_LIST) + public ApiResponse find( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String inboundCode) { + requirePlatform(user); + return ApiResponse.success(service.find(inboundCode)); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InboundCodeRelationshipResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InboundCodeRelationshipResponse.java new file mode 100644 index 000000000..65b4e028c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InboundCodeRelationshipResponse.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.util.List; + +public record InboundCodeRelationshipResponse( + String inboundCode, + boolean found, + Customer customer, + OverseasWarehouse overseasWarehouse, + String overseasReceivingStatus, + List taillegOrders, + List inboundPlans, + List palletCodes, + List inventory +) { + public record Customer(String customerId, String customerName) { } + public record OverseasWarehouse(String warehouseId, String warehouseCode, String warehouseName) { } + public record TaillegOrder(String orderNo, String orderStatus) { } + public record InboundPlan(String planNo, String planStatus, String receivingStatus) { } + public record InventorySku(String skuCode, String productNameZh, String productNameEn, long packageCount) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapper.java new file mode 100644 index 000000000..382697527 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapper.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface InboundCodeRelationshipMapper { + @Select("SELECT o.customer_id AS customerId,o.customer_name AS customerName,o.order_no AS orderNo," + + "o.current_status AS orderStatus,p.plan_no AS planNo,p.status AS planStatus," + + "COALESCE(p.status,o.current_status) AS receivingStatus," + + "COALESCE(b.overseas_warehouse_id,o.overseas_warehouse_id,CAST(p.overseas_warehouse_id AS CHAR)) AS overseasWarehouseId," + + "b.overseas_warehouse_code AS overseasWarehouseCode," + + "COALESCE(b.overseas_warehouse_name,o.overseas_warehouse_name) AS overseasWarehouseName " + + "FROM ll_order o " + + "LEFT JOIN ll_inbound_plan_line l ON l.customer_id=o.customer_id AND l.order_id=o.id AND l.inbound_code=o.inbound_code " + + "LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.id=l.plan_id " + + "LEFT JOIN ll_inventory_balance b ON b.customer_id=o.customer_id AND b.inbound_order_id=o.id AND b.inbound_code=o.inbound_code " + + "WHERE o.inbound_code=#{inboundCode} ORDER BY o.customer_id,o.id,p.id,b.id") + List bases(@Param("inboundCode") String inboundCode); + + @Select("SELECT DISTINCT r.pallet_no FROM ll_inbound_stock_receipt r " + + "JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " + + "WHERE o.inbound_code=#{inboundCode} AND o.customer_id=#{customerId} " + + "AND r.pallet_no IS NOT NULL AND TRIM(r.pallet_no)<>'' ORDER BY r.pallet_no") + List palletCodes(@Param("customerId") String customerId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT r.sku_code AS skuCode,MAX(r.product_name_zh) AS productNameZh," + + "MAX(r.product_name_en) AS productNameEn,SUM(r.received_packages) AS packageCount " + + "FROM ll_inbound_stock_receipt r " + + "JOIN ll_order o ON o.customer_id=r.customer_id AND o.id=r.order_id " + + "WHERE o.inbound_code=#{inboundCode} AND o.customer_id=#{customerId} " + + "GROUP BY r.sku_code ORDER BY r.sku_code") + List inventory( + @Param("customerId") String customerId, + @Param("inboundCode") String inboundCode); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InboundCodeRelationshipRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InboundCodeRelationshipRows.java new file mode 100644 index 000000000..79c9f2424 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InboundCodeRelationshipRows.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.inventory.query.model; + +import lombok.Data; + +public final class InboundCodeRelationshipRows { + private InboundCodeRelationshipRows() { } + + @Data + public static class Base { + private String customerId; + private String customerName; + private String orderNo; + private String orderStatus; + private String planNo; + private String planStatus; + private String receivingStatus; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + } + + @Data + public static class InventorySku { + private String skuCode; + private String productNameZh; + private String productNameEn; + private Long packageCount; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipService.java new file mode 100644 index 000000000..3e72514d1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipService.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse; +import com.baiyue.lastleg.inventory.query.mapper.InboundCodeRelationshipMapper; +import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Objects; + +@Service +public class InboundCodeRelationshipService { + @Resource private InboundCodeRelationshipMapper mapper; + + public InboundCodeRelationshipResponse find(String inboundCode) { + String normalized = required(inboundCode); + List bases = mapper.bases(normalized); + if (bases.isEmpty()) return empty(normalized); + List customerIds = bases.stream().map(InboundCodeRelationshipRows.Base::getCustomerId) + .filter(Objects::nonNull).distinct().toList(); + if (customerIds.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code resolves multiple customer scopes"); + } + String customerId = customerIds.get(0); + String customerName = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getCustomerName).toList(), + "customer name"); + String warehouseId = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getOverseasWarehouseId).toList(), + "overseas warehouse ID"); + String warehouseCode = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getOverseasWarehouseCode).toList(), + "overseas warehouse code"); + String warehouseName = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getOverseasWarehouseName).toList(), + "overseas warehouse name"); + List orders = bases.stream() + .filter(value -> trim(value.getOrderNo()) != null) + .map(value -> new InboundCodeRelationshipResponse.TaillegOrder( + value.getOrderNo().trim(), trim(value.getOrderStatus()))) + .distinct().sorted(java.util.Comparator.comparing(InboundCodeRelationshipResponse.TaillegOrder::orderNo)) + .toList(); + List plans = bases.stream() + .filter(value -> trim(value.getPlanNo()) != null) + .map(value -> new InboundCodeRelationshipResponse.InboundPlan( + value.getPlanNo().trim(), trim(value.getPlanStatus()), trim(value.getReceivingStatus()))) + .distinct().sorted(java.util.Comparator.comparing(InboundCodeRelationshipResponse.InboundPlan::planNo)).toList(); + List inventory = mapper.inventory(customerId, normalized).stream() + .map(value -> new InboundCodeRelationshipResponse.InventorySku(value.getSkuCode(), + value.getProductNameZh(), value.getProductNameEn(), number(value.getPackageCount()))).toList(); + String status = unique(bases.stream().map(InboundCodeRelationshipRows.Base::getReceivingStatus).toList(), + "overseas receiving status"); + InboundCodeRelationshipResponse.OverseasWarehouse warehouse = warehouseId == null + && warehouseCode == null && warehouseName == null ? null + : new InboundCodeRelationshipResponse.OverseasWarehouse(warehouseId, warehouseCode, warehouseName); + return new InboundCodeRelationshipResponse(normalized, true, + new InboundCodeRelationshipResponse.Customer(customerId, customerName), warehouse, status, + orders, plans, mapper.palletCodes(customerId, normalized), inventory); + } + + private static InboundCodeRelationshipResponse empty(String inboundCode) { + return new InboundCodeRelationshipResponse(inboundCode, false, null, null, null, + List.of(), List.of(), List.of(), List.of()); + } + + private static String unique(List values, String field) { + List normalized = values.stream().map(InboundCodeRelationshipService::trim) + .filter(Objects::nonNull).distinct().toList(); + if (normalized.size() > 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code has inconsistent " + field + " snapshots"); + } + return normalized.isEmpty() ? null : normalized.get(0); + } + + private static String required(String value) { + String normalized = trim(value); + if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "inboundCode is required"); + if (normalized.length() > 128) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "inboundCode is too long"); + return normalized; + } + + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static long number(Long value) { return value == null ? 0L : value; } +} 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 3eba290c0..695f6d2c3 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3,6 +3,17 @@ info: title: Lastleg Overseas Business API version: 1.0.0 paths: + /api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}: + get: + summary: 按入仓码查询海外关系摘要 + description: 仅平台且要求 lastleg:inventory:list;不接收customerId,不跨国内数据库查询,未命中返回found=false和空关系。 + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: inboundCode, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + responses: + '200': { description: 海外订单、计划、收货、托盘和库存业务关系, content: { application/json: { schema: { $ref: '#/components/schemas/InboundCodeRelationshipApiResponse' } } } } + '403': { description: 非平台身份或缺少库存查看权限 } + '409': { description: 入仓码跨客户或快照关系不一致 } /api/pda/v1/last-leg/receiving-containers/candidates: get: summary: 查询当前客户待签收柜号 @@ -643,6 +654,57 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + InboundCodeRelationship: + type: object + required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory] + properties: + inboundCode: { type: string } + found: { type: boolean } + customer: { $ref: '#/components/schemas/InboundCodeRelationshipCustomer', nullable: true } + overseasWarehouse: { $ref: '#/components/schemas/InboundCodeRelationshipWarehouse', nullable: true } + overseasReceivingStatus: { type: string, nullable: true } + taillegOrders: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipOrder' } } + inboundPlans: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipPlan' } } + palletCodes: { type: array, uniqueItems: true, items: { type: string } } + inventory: { type: array, items: { $ref: '#/components/schemas/InboundCodeRelationshipInventorySku' } } + InboundCodeRelationshipCustomer: + type: object + required: [customerId] + properties: + customerId: { type: string, description: 字符串业务隔离标识,禁止按JavaScript number处理 } + customerName: { type: string, nullable: true } + InboundCodeRelationshipWarehouse: + type: object + properties: + warehouseId: { type: string, nullable: true, description: 字符串共享仓库ID,禁止按JavaScript number处理 } + warehouseCode: { type: string, nullable: true } + warehouseName: { type: string, nullable: true } + InboundCodeRelationshipOrder: + type: object + required: [orderNo] + properties: + orderNo: { type: string } + orderStatus: { type: string, nullable: true } + InboundCodeRelationshipPlan: + type: object + required: [planNo] + properties: + planNo: { type: string } + planStatus: { type: string, nullable: true } + receivingStatus: { type: string, nullable: true } + InboundCodeRelationshipInventorySku: + type: object + required: [skuCode, packageCount] + properties: + skuCode: { type: string } + productNameZh: { type: string, nullable: true } + productNameEn: { type: string, nullable: true } + packageCount: { type: integer, format: int64, minimum: 0 } + InboundCodeRelationshipApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/InboundCodeRelationship' } } FbaTransferDestination: type: object required: [warehouseId, warehouseCode, warehouseName] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InboundCodeRelationshipOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InboundCodeRelationshipOpenApiContractTest.java new file mode 100644 index 000000000..2d1d21f67 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InboundCodeRelationshipOpenApiContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inventory.query; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class InboundCodeRelationshipOpenApiContractTest { + + @Test + void publishesPlatformRelationshipLookupWithoutClientCustomerScope() throws IOException { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml")); + String path = "/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}:"; + int start = yaml.indexOf(path); + int end = yaml.indexOf("\n /", start + path.length()); + + assertThat(start).isGreaterThanOrEqualTo(0); + String operation = yaml.substring(start, end > start ? end : yaml.length()); + assertThat(operation) + .contains("name: inboundCode") + .contains("$ref: '#/components/schemas/InboundCodeRelationshipApiResponse'") + .doesNotContain("name: customerId"); + assertThat(yaml) + .contains("InboundCodeRelationship:") + .contains("overseasReceivingStatus:") + .contains("taillegOrders:") + .contains("inboundPlans:") + .contains("orderStatus:") + .contains("planStatus:") + .contains("palletCodes:") + .contains("inventory:"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipControllerTest.java new file mode 100644 index 000000000..2e7a2c47c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipControllerTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InboundCodeRelationshipResponse; +import com.baiyue.lastleg.inventory.query.service.InboundCodeRelationshipService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.junit.jupiter.api.BeforeEach; +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.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.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundCodeRelationshipControllerTest { + @Mock InboundCodeRelationshipService service; + private InboundCodeRelationshipController controller; + + @BeforeEach + void setUp() { + controller = new InboundCodeRelationshipController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void platformReadsByInboundCodeWithoutSupplyingCustomerId() { + InboundCodeRelationshipResponse response = new InboundCodeRelationshipResponse( + "IN-1", false, null, null, null, List.of(), List.of(), List.of(), List.of()); + when(service.find("IN-1")).thenReturn(response); + + var result = controller.find(platform(), "IN-1"); + + assertThat(result.data()).isSameAs(response); + verify(service).find("IN-1"); + } + + @Test + void customerAccountCannotUsePlatformRelationshipReadModel() { + AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 8L, "customer", + "MERCHANT", "WEB", Set.of(OverseasPermissions.INVENTORY_LIST)); + + assertThatThrownBy(() -> controller.find(customer, "IN-1")) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("403"); + verifyNoInteractions(service); + } + + @Test + void missingIdentityIsUnauthorized() { + assertThatThrownBy(() -> controller.find(null, "IN-1")) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("401"); + verifyNoInteractions(service); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", + Set.of(OverseasPermissions.INVENTORY_LIST)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapperContractTest.java new file mode 100644 index 000000000..50f32fbe0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapperContractTest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeRelationshipMapperContractTest { + @Test + void mapperIsExplicitlyScannableAndQueriesOnlyLastlegTables() throws Exception { + assertThat(InboundCodeRelationshipMapper.class).hasAnnotation(Mapper.class); + String sql = String.join(" ", InboundCodeRelationshipMapper.class + .getMethod("bases", String.class).getAnnotation(Select.class).value()); + + assertThat(sql).contains("ll_order", "ll_inbound_plan", "ll_inventory_balance"); + assertThat(sql).doesNotContain("customerId}", "warehouse.", "iam."); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipServiceTest.java new file mode 100644 index 000000000..75d71274a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipServiceTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.lastleg.inventory.query.mapper.InboundCodeRelationshipMapper; +import com.baiyue.lastleg.inventory.query.model.InboundCodeRelationshipRows; +import org.junit.jupiter.api.BeforeEach; +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.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundCodeRelationshipServiceTest { + @Mock InboundCodeRelationshipMapper mapper; + private InboundCodeRelationshipService service; + + @BeforeEach + void setUp() { + service = new InboundCodeRelationshipService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test + void missingInboundCodeReturnsExplicitEmptyRelationship() { + when(mapper.bases("NYEXX2608300001")).thenReturn(List.of()); + + var result = service.find(" NYEXX2608300001 "); + + assertThat(result.found()).isFalse(); + assertThat(result.customer()).isNull(); + assertThat(result.taillegOrders()).isEmpty(); + assertThat(result.inboundPlans()).isEmpty(); + assertThat(result.palletCodes()).isEmpty(); + assertThat(result.inventory()).isEmpty(); + } + + @Test + void relationshipUsesBusinessNumbersAndKeepsLongIdentifiersAsStrings() { + InboundCodeRelationshipRows.Base row = base("2092618717861048321", "Customer A"); + when(mapper.bases("NYEXX2608300001")).thenReturn(List.of(row, row)); + when(mapper.palletCodes("2092618717861048321", "NYEXX2608300001")) + .thenReturn(List.of("PAL-LAX-001")); + InboundCodeRelationshipRows.InventorySku sku = new InboundCodeRelationshipRows.InventorySku(); + sku.setSkuCode("SKU-001"); sku.setProductNameZh("产品一"); sku.setPackageCount(40L); + when(mapper.inventory("2092618717861048321", "NYEXX2608300001")).thenReturn(List.of(sku)); + + var result = service.find("NYEXX2608300001"); + + assertThat(result.found()).isTrue(); + assertThat(result.customer().customerId()).isEqualTo("2092618717861048321"); + assertThat(result.overseasWarehouse().warehouseId()).isEqualTo("193000000000200003"); + assertThat(result.taillegOrders()).extracting("orderNo").containsExactly("LMO-260830-0001"); + assertThat(result.taillegOrders()).extracting("orderStatus").containsExactly("INBOUND_PLANNED"); + assertThat(result.inboundPlans()).extracting("planNo").containsExactly("LIP-260830-0001"); + assertThat(result.inboundPlans()).extracting("planStatus").containsExactly("COMPLETED"); + assertThat(result.palletCodes()).containsExactly("PAL-LAX-001"); + assertThat(result.inventory()).extracting("skuCode", "packageCount") + .containsExactly(org.assertj.core.groups.Tuple.tuple("SKU-001", 40L)); + } + + @Test + void sameInboundCodeAcrossCustomersIsRejectedBeforeScopedDetailQueries() { + when(mapper.bases("DUPLICATE")).thenReturn(List.of( + base("100", "Customer A"), base("200", "Customer B"))); + + assertThatThrownBy(() -> service.find("DUPLICATE")) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + + verify(mapper, never()).palletCodes(anyString(), anyString()); + verify(mapper, never()).inventory(anyString(), anyString()); + } + + @Test + void blankAndOversizedInboundCodesAreRejected() { + assertThatThrownBy(() -> service.find(" ")).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("400"); + assertThatThrownBy(() -> service.find("X".repeat(129))).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("400"); + verifyNoInteractions(mapper); + } + + private static InboundCodeRelationshipRows.Base base(String customerId, String customerName) { + InboundCodeRelationshipRows.Base value = new InboundCodeRelationshipRows.Base(); + value.setCustomerId(customerId); value.setCustomerName(customerName); + value.setOrderNo("LMO-260830-0001"); value.setOrderStatus("INBOUND_PLANNED"); + value.setPlanNo("LIP-260830-0001"); value.setPlanStatus("COMPLETED"); + value.setReceivingStatus("COMPLETED"); value.setOverseasWarehouseId("193000000000200003"); + value.setOverseasWarehouseCode("US-LAX-01"); value.setOverseasWarehouseName("洛杉矶一号仓"); + return value; + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index 23734d6f5..e09240dcb 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -10,11 +10,13 @@ import com.baiyue.order.dto.MerchantBatchDraftDtos; import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.dto.MerchantInboundOrderRow; import com.baiyue.order.dto.InboundCodePolicyResponse; +import com.baiyue.order.dto.InboundCodeAssociationSummary; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.MerchantInboundOrderListService; import com.baiyue.order.service.MerchantBatchDraftService; +import com.baiyue.order.service.InboundCodeAssociationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -50,6 +52,8 @@ public class MerchantFirstLegOrderController { private MerchantInboundOrderListService inboundOrderListService; @Resource private MerchantBatchDraftService batchDraftService; + @Resource + private InboundCodeAssociationService inboundCodeAssociations; @GetMapping("/inbound-code-policy") @PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')") @@ -57,6 +61,17 @@ public class MerchantFirstLegOrderController { return ApiResponse.success(service.inboundCodePolicy()); } + @GetMapping("/inbound-code-associations/{inboundCode}") + @PreAuthorize("hasAuthority('merchant:order:list')") + @Operation(summary = "按入仓码查询本客户订单基础关联", + description = "客户范围只取登录态;其他客户的入仓码按不存在处理。") + public ApiResponse inboundCodeAssociation( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String inboundCode) { + return ApiResponse.success(inboundCodeAssociations.findForCustomer( + OrderScopeGuard.merchant(user), inboundCode)); + } + @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿") @Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。") @ApiResponses({ diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 531e108c0..56b740cfc 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -9,12 +9,14 @@ import com.baiyue.order.dto.BatchOrderReviewResponse; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.InboundCodePolicyResponse; +import com.baiyue.order.dto.InboundCodeAssociationSummary; import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; +import com.baiyue.order.service.InboundCodeAssociationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -47,6 +49,8 @@ import java.util.List; public class PlatformFirstLegOrderController { @Resource private OrderApplicationService service; + @Resource + private InboundCodeAssociationService inboundCodeAssociations; @GetMapping("/inbound-code-policy") @PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')") @@ -54,6 +58,17 @@ public class PlatformFirstLegOrderController { return ApiResponse.success(service.inboundCodePolicy()); } + @GetMapping("/inbound-code-associations/{inboundCode}") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "按入仓码查询订单基础关联", + description = "入仓码和订单号是主要展示键;Long ID 仅供路由关联。") + public ApiResponse inboundCodeAssociation( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String inboundCode) { + OrderScopeGuard.platform(user); + return ApiResponse.success(inboundCodeAssociations.findForPlatform(inboundCode)); + } + @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿") @Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。") @ApiResponses({ diff --git a/order/src/main/java/com/baiyue/order/dto/InboundCodeAssociationSummary.java b/order/src/main/java/com/baiyue/order/dto/InboundCodeAssociationSummary.java new file mode 100644 index 000000000..38ec8ee37 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/InboundCodeAssociationSummary.java @@ -0,0 +1,19 @@ +package com.baiyue.order.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** 以全局唯一入仓码定位的订单基础关联摘要;技术 ID 统一使用字符串。 */ +public record InboundCodeAssociationSummary( + @Schema(description = "主要展示键:全局唯一入仓码") String inboundCode, + @Schema(description = "主要展示键:订单业务号") String orderNo, + @Schema(description = "订单交易状态") String orderStatus, + @Schema(description = "订单内部 ID,仅供路由关联") String orderId, + @Schema(description = "客户 ID,字符串避免前端精度损失") String customerId, + String customerName, + String destinationType, + String destinationCountryCode, + @Schema(description = "目的仓内部 ID,仅供路由关联") String destinationWarehouseId, + @Schema(description = "目的仓业务编码;FBA 场景为 Amazon 仓码") String destinationWarehouseCode, + String destinationWarehouseName +) { +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 9734d6c5d..8263e70ba 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -121,6 +121,21 @@ public class OrderRepository { .eq(OrderEntity::getId, orderId)); } + /** 平台按数据库全局唯一键查询,不接受客户筛选参数。 */ + public OrderEntity findByInboundCode(String inboundCode) { + return orderMapper.selectOne(new LambdaQueryWrapper() + .eq(OrderEntity::getInboundCode, inboundCode) + .eq(OrderEntity::getDeleted, false)); + } + + /** 客户查询必须在 SQL 条件中同时绑定登录客户与入仓码。 */ + public OrderEntity findOwnedByInboundCode(long tenantId, String inboundCode) { + return orderMapper.selectOne(new LambdaQueryWrapper() + .eq(OrderEntity::getTenantId, tenantId) + .eq(OrderEntity::getInboundCode, inboundCode) + .eq(OrderEntity::getDeleted, false)); + } + public boolean inboundCodeExists(String inboundCode, Long excludedOrderId) { if (!StringUtils.hasText(inboundCode)) return false; return orderMapper.selectCount(new LambdaQueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java b/order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java new file mode 100644 index 000000000..2ac262888 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java @@ -0,0 +1,65 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.InboundCodeAssociationSummary; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.repository.OrderRepository; +import org.springframework.stereotype.Service; + +import java.util.regex.Pattern; + +/** 从 Order 权威快照按入仓码读取只读基础关联。 */ +@Service +public class InboundCodeAssociationService { + private static final Pattern INBOUND_CODE = Pattern.compile("[A-Za-z0-9._:-]{1,40}"); + + private final OrderRepository repository; + + public InboundCodeAssociationService(OrderRepository repository) { + this.repository = repository; + } + + public InboundCodeAssociationSummary findForPlatform(String inboundCode) { + String normalized = normalize(inboundCode); + return summary(require(repository.findByInboundCode(normalized)), normalized); + } + + public InboundCodeAssociationSummary findForCustomer(long tenantId, String inboundCode) { + if (tenantId <= 0) throw new BizException(ResultCode.FORBIDDEN, "Customer scope is required"); + String normalized = normalize(inboundCode); + return summary(require(repository.findOwnedByInboundCode(tenantId, normalized)), normalized); + } + + private String normalize(String value) { + String normalized = value == null ? "" : value.trim(); + if (!INBOUND_CODE.matcher(normalized).matches()) { + throw new BizException(ResultCode.BAD_REQUEST, "入仓码格式无效"); + } + return normalized; + } + + private OrderEntity require(OrderEntity order) { + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入仓码关联订单不存在"); + return order; + } + + private InboundCodeAssociationSummary summary(OrderEntity order, String inboundCode) { + return new InboundCodeAssociationSummary( + inboundCode, + order.getOrderNo(), + order.getStatus() == null ? null : order.getStatus().name(), + string(order.getId()), + string(order.getCustomerId()), + order.getCustomerName(), + order.getDestinationType(), + order.getDestinationCountryCode(), + string(order.getDestinationWarehouseId()), + order.getAmazonWarehouseCode(), + order.getDestinationWarehouseName()); + } + + private String string(Long value) { + return value == null ? null : value.toString(); + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 1d9340a63..86376894f 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 柏悦头程订单 API description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。 - version: 1.6.0 + version: 1.7.0 security: - bearerAuth: [] paths: @@ -21,6 +21,21 @@ paths: code: {type: string, example: '200'} data: {$ref: '#/components/schemas/InboundCodePolicy'} '403': {description: 需要平台订单列表、创建、编辑或提交权限之一} + /api/platform/v1/orders/first-leg/inbound-code-associations/{inboundCode}: + get: + summary: 平台按全局入仓码查询订单基础关联 + description: >- + 需要 platform:order:list。入仓码和订单业务号是主要展示键;Long ID 均以字符串返回, + 只供页面路由和后续接口关联。查询直接使用 Order 全局唯一入仓码事实,不跨库读取 Firstleg。 + parameters: + - $ref: '#/components/parameters/InboundCodePath' + responses: + '200': + description: 查询成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/InboundCodeAssociationEnvelope'} + '403': {description: 需要平台身份和 platform:order:list 权限} /api/platform/v1/orders: get: summary: 平台统一订单列表 @@ -187,6 +202,21 @@ paths: schema: {$ref: '#/components/schemas/FirstLegOrderDraft'} responses: '200': {description: 成功或业务失败,按响应体 code 判断} + /api/merchant/v1/orders/first-leg/inbound-code-associations/{inboundCode}: + get: + summary: 客户按入仓码查询本客户订单基础关联 + description: >- + 需要 merchant:order:list。客户范围仅取登录态,不接受客户 ID 参数;其他客户的入仓码统一按不存在处理。 + 入仓码和订单业务号是主要展示键,Long ID 只供路由关联。 + parameters: + - $ref: '#/components/parameters/InboundCodePath' + responses: + '200': + description: 查询成功、不存在或其他业务失败,按响应体 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/InboundCodeAssociationEnvelope'} + '403': {description: 需要客户身份和 merchant:order:list 权限} /api/merchant/v1/orders/first-leg/batch: post: summary: 批量创建头程订单草稿 @@ -671,6 +701,27 @@ components: properties: mode: {type: string, enum: [AUTO, LEGACY_MANUAL]} manualEntryRequired: {type: boolean} + InboundCodeAssociationEnvelope: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/InboundCodeAssociationSummary'} + InboundCodeAssociationSummary: + type: object + required: [inboundCode, orderNo, orderStatus, orderId, customerId, customerName, destinationType] + properties: + inboundCode: {type: string, maxLength: 40, pattern: '^[A-Za-z0-9._:-]+$', description: 主要展示键:全局唯一入仓码} + orderNo: {type: string, description: 主要展示键:订单业务号} + orderStatus: {type: string, description: Order 权威交易状态} + orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 内部 ID,仅供路由关联} + customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 客户 ID,字符串避免精度损失} + customerName: {type: string} + destinationType: {type: string} + destinationCountryCode: {type: string, nullable: true} + destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 内部 ID,仅供路由关联} + destinationWarehouseCode: {type: string, nullable: true, description: 目的仓业务编码;FBA 场景为 Amazon 仓码} + destinationWarehouseName: {type: string, nullable: true} FirstLegBatchDraftItem: type: object required: [clientRowId, draft] @@ -778,6 +829,12 @@ components: packageCount: {type: integer} loadingPlanId: {type: string} parameters: + InboundCodePath: + name: inboundCode + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 40, pattern: '^[A-Za-z0-9._:-]+$'} + description: 全局唯一业务入仓码 InternalToken: name: X-Internal-Service-Token in: header diff --git a/order/src/test/java/com/baiyue/order/controller/InboundCodeAssociationContractTest.java b/order/src/test/java/com/baiyue/order/controller/InboundCodeAssociationContractTest.java new file mode 100644 index 000000000..4cec417ae --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/InboundCodeAssociationContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.InboundCodeAssociationSummary; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeAssociationContractTest { + @Test + void platformAndCustomerEndpointsUseListPermissionAndSameSummary() throws Exception { + assertEndpoint(PlatformFirstLegOrderController.class, "platform:order:list"); + assertEndpoint(MerchantFirstLegOrderController.class, "merchant:order:list"); + } + + @Test + void openApiMakesBusinessKeysPrimaryAndRouteIdsStrings() throws Exception { + String openapi = new ClassPathResource("openapi/order-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(openapi) + .contains("/api/platform/v1/orders/first-leg/inbound-code-associations/{inboundCode}:") + .contains("/api/merchant/v1/orders/first-leg/inbound-code-associations/{inboundCode}:") + .contains("InboundCodeAssociationSummary:") + .contains("入仓码和订单业务号是主要展示键") + .contains("orderId: {type: string") + .contains("customerId: {type: string") + .contains("destinationWarehouseId: {type: string") + .contains("其他客户的入仓码统一按不存在处理") + .doesNotContain("tenantId, in: query, required: true, description: 入仓码关联查询"); + } + + private void assertEndpoint(Class controller, String authority) throws Exception { + var method = controller.getDeclaredMethod( + "inboundCodeAssociation", AuthenticatedUser.class, String.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/inbound-code-associations/{inboundCode}"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + authority + "')"); + assertThat(method.getGenericReturnType().getTypeName()) + .contains(InboundCodeAssociationSummary.class.getName()); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 7686f0daa..5d116b78b 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -21,6 +21,8 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, MerchantInboundOrderQuery.class); assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')", AuthenticatedUser.class, long.class); + assertAuthority(MerchantFirstLegOrderController.class, "inboundCodeAssociation", + "hasAuthority('merchant:order:list')", AuthenticatedUser.class, String.class); } @Test @@ -31,6 +33,8 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, long.class); assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')", AuthenticatedUser.class, List.class); + assertAuthority(PlatformFirstLegOrderController.class, "inboundCodeAssociation", + "hasAuthority('platform:order:list')", AuthenticatedUser.class, String.class); } @Test diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index 61e043825..320a1c5bc 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -7,6 +7,7 @@ import com.baiyue.order.dto.BatchOrderReviewResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; +import com.baiyue.order.service.InboundCodeAssociationService; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; @@ -26,6 +27,7 @@ import static org.mockito.Mockito.verifyNoInteractions; @SpringJUnitConfig(PlatformFirstLegOrderController.class) class PlatformOrderPageControllerTest { @MockBean private OrderApplicationService service; + @MockBean private InboundCodeAssociationService inboundCodeAssociations; @Resource private PlatformFirstLegOrderController controller; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); diff --git a/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java b/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java index 025b8bf5b..a18329cde 100644 --- a/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java +++ b/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java @@ -126,6 +126,36 @@ class OrderRepositoryIsolationUpdateTest { .contains("FIRST_LEG_ORDER", "PENDING"); } + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void customerInboundCodeLookupBindsCustomerAndGlobalBusinessKey() { + OrderMapper mapper = mock(OrderMapper.class); + OrderRepository repository = repository("orderMapper", mapper); + + repository.findOwnedByInboundCode(11L, "ZBYHK2608300001"); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).selectOne(wrapper.capture()); + assertThat(wrapper.getValue().getSqlSegment()).contains("tenant_id", "inbound_code", "deleted"); + assertThat(params(wrapper.getValue()).values()).contains(11L, "ZBYHK2608300001", false); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void platformInboundCodeLookupUsesGlobalUniqueBusinessKeyWithoutCustomerPredicate() { + OrderMapper mapper = mock(OrderMapper.class); + OrderRepository repository = repository("orderMapper", mapper); + + repository.findByInboundCode("ZBYHK2608300001"); + + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).selectOne(wrapper.capture()); + assertThat(wrapper.getValue().getSqlSegment()) + .contains("inbound_code", "deleted") + .doesNotContain("tenant_id"); + assertThat(params(wrapper.getValue()).values()).contains("ZBYHK2608300001", false); + } + private static void initialize(Class entityType, String resource) { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), resource), entityType); diff --git a/order/src/test/java/com/baiyue/order/service/InboundCodeAssociationServiceTest.java b/order/src/test/java/com/baiyue/order/service/InboundCodeAssociationServiceTest.java new file mode 100644 index 000000000..f7b8f082a --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/InboundCodeAssociationServiceTest.java @@ -0,0 +1,83 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class InboundCodeAssociationServiceTest { + private final OrderRepository repository = mock(OrderRepository.class); + private final InboundCodeAssociationService service = new InboundCodeAssociationService(repository); + + @Test + void platformReadsGlobalBusinessKeyAndReturnsStringRouteIds() { + when(repository.findByInboundCode("ZBYHK2608300001")).thenReturn(order()); + + var summary = service.findForPlatform(" ZBYHK2608300001 "); + + assertThat(summary.inboundCode()).isEqualTo("ZBYHK2608300001"); + assertThat(summary.orderNo()).isEqualTo("FL202608300001"); + assertThat(summary.orderStatus()).isEqualTo("APPROVED"); + assertThat(summary.orderId()).isEqualTo("9007199254740993"); + assertThat(summary.customerId()).isEqualTo("9007199254740995"); + assertThat(summary.customerName()).isEqualTo("客户甲"); + assertThat(summary.destinationType()).isEqualTo("FBA"); + assertThat(summary.destinationCountryCode()).isEqualTo("US"); + assertThat(summary.destinationWarehouseId()).isEqualTo("9007199254740997"); + assertThat(summary.destinationWarehouseCode()).isEqualTo("ONT8"); + assertThat(summary.destinationWarehouseName()).isEqualTo("ONT8 仓"); + } + + @Test + void customerQueryAlwaysUsesAuthenticatedCustomerScope() { + when(repository.findOwnedByInboundCode(10001L, "ZBYHK2608300001")).thenReturn(order()); + + service.findForCustomer(10001L, "ZBYHK2608300001"); + + verify(repository).findOwnedByInboundCode(10001L, "ZBYHK2608300001"); + verify(repository, never()).findByInboundCode("ZBYHK2608300001"); + } + + @Test + void anotherCustomersCodeIsIndistinguishableFromMissingCode() { + when(repository.findOwnedByInboundCode(10001L, "ZBYHK2608300001")).thenReturn(null); + + assertThatThrownBy(() -> service.findForCustomer(10001L, "ZBYHK2608300001")) + .isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND)) + .hasMessage("入仓码关联订单不存在"); + } + + @Test + void invalidInboundCodeIsRejectedBeforeRepositoryAccess() { + assertThatThrownBy(() -> service.findForPlatform("bad/code")) + .isInstanceOfSatisfying(BizException.class, + error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.BAD_REQUEST)); + verifyNoInteractions(repository); + } + + private OrderEntity order() { + OrderEntity order = new OrderEntity(); + order.setId(9007199254740993L); + order.setOrderNo("FL202608300001"); + order.setStatus(OrderStatus.APPROVED); + order.setCustomerId(9007199254740995L); + order.setCustomerName("客户甲"); + order.setDestinationType("FBA"); + order.setDestinationCountryCode("US"); + order.setDestinationWarehouseId(9007199254740997L); + order.setAmazonWarehouseCode("ONT8"); + order.setDestinationWarehouseName("ONT8 仓"); + return order; + } +} diff --git a/scripts/README.md b/scripts/README.md index ebc99de65..a0def08cd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,64 @@ 全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。 +## 脚本目录归属 + +脚本按作用域放置,不要求全部集中到根 `scripts/`: + +| 目录 | 归属 | 是否直接运行 | +|---|---|---| +| `scripts/` | 仓库级验收、回归、诊断、环境辅助和静态合同 | 本文“仓库级入口清单”列出的文件可以直接运行 | +| `scripts/admin-test-env-modules/` | `admin-test-env-acceptance.mjs` 动态加载的业务模块、fixture 和离线合同 | 业务 `.mjs` 原则上不直接运行;模块名、依赖和边界见该目录 `README.md` | +| `scripts/openapi-validation/` | 全仓 OpenAPI 发现、解析和引用校验 | 从该目录安装依赖后运行 `validate-openapi.mjs`;`validate-openapi.test.mjs` 是离线合同 | +| `firstleg/scripts/` | Firstleg 服务内部 Java 包依赖分析 | 只维护 Firstleg 内部模块化基线,与服务一起演进 | +| `lastleg-service/scripts/` | Lastleg 服务内部 Java 包依赖分析 | 只维护 Lastleg 内部模块化基线,与服务一起演进 | +| `warehouse/scripts/` | Warehouse 公共主数据等服务局部离线合同 | 从后端根用 Node test runner 执行,不连接数据库 | +| `deploy/scripts/` | Jenkins/部署主流程调用的镜像部署与测试库初始化脚本 | 只在受控部署或测试基础设施流程运行 | +| `deploy/rocketmq/` | RocketMQ 容器启动时渲染 broker 配置 | 由容器入口调用,不作为人工验收入口 | + +因此 `firstleg/scripts/`、`lastleg-service/scripts/` 和 `deploy/**` 不是散落或放错目录;它们靠近所属部署单元或运行组件。仓库检索到的可执行脚本如果既不在上述目录,也没有在本表登记,应视为脚本归属缺失。 + +## 仓库级入口清单 + +下表覆盖 `scripts/` 下所有非 `*.test.mjs` 的可执行入口。`*.test.mjs` 统一为离线 Node 静态合同,不访问测试环境;`admin-test-env-modules/*.mjs` 的逐模块说明由该目录 `README.md` 和总入口注册表维护。 + +| 分类 | 入口 | 用途与副作用 | +|---|---|---| +| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | +| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 | +| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 | +| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | +| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | +| 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 | +| 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 | +| 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 二期测试环境 API 验收,会写业务数据 | +| 场景组件 | `mojia-measurement-scenario.mjs` | 被 Admin 验收入口复用的墨家测量场景;不是独立 CLI,调用正式测量接口并上传图片 | +| 定向诊断 | `pda-final-complete-diagnostic.mjs` | 对指定 PDA task 回查最终完成门禁;传 `--seed-evidence` 时会补五类证据并产生写操作 | +| 本地真实 API | `order-warehouse-real-api-regression.ps1` | 本地 Order/Warehouse 往来单位真实 API 回归,并直接读写本机 MySQL;不得指向共享或测试数据库 | +| 一期本地验收 | `phase1-acceptance.ps1` | 一期本地服务/API 综合验收,会创建本地业务数据 | +| 一期本地验收 | `phase1-business-maturity-regression.ps1` | 一期业务成熟度回归,调用本地服务并生成报告 | +| 一期本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 | +| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 | +| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试与类型检查,不访问 Gateway | +| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 | +| 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 | +| 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 | +| 本地质量 | `java-quality-summary.mjs` | 聚合 Maven 拓扑、OpenAPI、JaCoCo 和 SpotBugs 已有报告,不启动服务 | +| 本地质量 | `agents-index-contract.ps1` | 离线检查 AGENTS 快速索引与关键入口一致性 | +| 契约数据 | `synchronous-dependency-baseline.mjs` | 同步服务依赖合同的数据源,只由相邻合同测试导入,不是 CLI | +| OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 | +| 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 | +| 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 | +| 密钥配置 | `generate-overseas-jwk.mjs` | 生成 3072 位海外 JWT RSA JWK 并写入指定 `deploy/env/.env.*`;已有 JWK 时拒绝覆盖,密钥文件不得提交 | +| 迁移辅助 | `migrate-local-qualification-file.ps1` | 本地资质文件迁移辅助,会修改本地文件/数据;运行前核对脚本参数和备份策略 | +| Jenkins 管理 | `provision-development-service-jobs.groovy` | Jenkins Script Console 批量准备开发服务 Job;默认 `applyChanges=false` 仅预览,改为 true 会创建 Job | + +部署目录的入口另有:`deploy/scripts/deploy-built-service.sh`(单服务健康检查与失败回滚)、`deploy/scripts/deploy-development-service.sh`(经 SSH 部署开发服务)、`deploy/scripts/deploy-services.sh`(按服务列表编排部署)、`deploy/scripts/initialize-test-databases.sh`(初始化测试基础设施数据库)和 `deploy/rocketmq/render-broker-config.sh`(容器内渲染并启动 RocketMQ broker)。这些脚本都会改变运行环境,不能作为普通本地检查执行。 + +服务局部分析入口为 `firstleg/scripts/analyze-firstleg-dependencies.mjs` 与 `lastleg-service/scripts/analyze-lastleg-dependencies.mjs`,相邻的 `*.test.mjs` 负责对基线和违规依赖做离线回归;`warehouse/scripts/warehouse-public-master-data-contract.test.mjs` 只读校验 Warehouse 公共主数据 CSV 与 SQL。SQL 辅助文件 `scripts/db/incremental.sql` 不是当前统一部署入口,正式数据库变更以 `deploy/sql/README.md` 指向的统一增量为准。 + +新增可直接运行的脚本时,必须同时在本节登记用途、目标环境、是否写数据和密钥边界,并运行 `node --test scripts/script-documentation-contract.test.mjs`。仅新增 `*.test.mjs` 或 `admin-test-env-modules/` 内部模块时,分别更新对应合同范围或模块 README,不把内部文件误写成独立入口。 + | 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 | |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | diff --git a/scripts/admin-test-env-modules/IT测试客户八千票待分柜数据生成方案.md b/scripts/admin-test-env-modules/IT测试客户八千票待分柜数据生成方案.md index aa8a03352..0be31490c 100644 --- a/scripts/admin-test-env-modules/IT测试客户八千票待分柜数据生成方案.md +++ b/scripts/admin-test-env-modules/IT测试客户八千票待分柜数据生成方案.md @@ -42,9 +42,10 @@ - SKU 数随机为 `1–4`,但不得超过本票总箱数。 - 总箱数随机拆分到各 SKU,每个 SKU 至少 1 箱。 - 毛重、净重、数量、申报价值和体积根据各 SKU 箱数计算。 -- 称重图片、托盘和占用库位数量必须与上述托数完全一致。 +- 称重图片数量与上述托数一致;每托只在既有 `TP-001`、`YW-TP-002` 中随机复用一个托盘编码,不创建新的托盘主档,因此同一票和不同票都允许重复使用这两个编码。 +- 库位由正式仓储预占/结算接口分配,不创建库位主档;普通库位不足时直接使用仓库公共位 `A0`。托盘复用不改变 `palletCount = ceil(totalPackageCount / 30)` 的业务托数。 - 箱唛打印冻结真实总箱数;多 SKU 测量使用各 SKU 实际箱数和对应箱序号。 -- 通过正式文件、称重 OCR、托盘库位、测量提交和审核接口推进状态,不直接写数据库。 +- 通过正式文件、称重 OCR、托盘库位预占、测量提交和审核接口推进状态,不直接写数据库。 ## 尽力执行语义 @@ -92,5 +93,6 @@ node scripts/it-test-wait-split-bulk.mjs ` - 仅用于已明确授权的测试环境,禁止用于生产环境。 - 账号、密码、Token 和环境地址只从本机环境配置加载,不进入源码或报告。 - 不自动删除已成功或部分成功的数据。 +- 不新增托盘或库位主档;只能复用 `TP-001`、`YW-TP-002`,库位不足落公共位 `A0`。 - 不重置数据库、不执行 SQL、不绕过正式状态机。 - 清理测试数据或文件必须另行获得明确授权。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 6ada21338..5df154b64 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。 +固定“IT测试”客户与“义乌仓”、其余线路随机并尽力生成最多 8,000 票时,独立编排入口是 `../it-test-wait-split-bulk.mjs`。它仍复用本目录的 `orders.mjs` 与 `inbound-measurement.mjs`,但逐票保存日志且单票失败后继续。测量前置只随机复用既有 `TP-001`、`YW-TP-002`,不得调用托盘新增接口;库位由正式仓储接口分配,普通位不足时使用公共位 `A0`,不得调用库位新增接口。 + 订单造数中的 `GET /api/merchant/v1/partners/candidates?type=SHIPPER` 属于 Warehouse 正式客户候选能力。失败时订单脚本仍可使用 sender 快照继续收集后续链路证据,但 HTTP 503/`S0503` 必须保留为 deployment blocker,不能因订单、测量、分柜或装柜后续通过而降级为 coverage gap。 人工分柜采用两个可选模块,不进入默认全量入口:`manual-order-splitting-fixture.mjs` 基于本轮至少四票 `WAIT_SPLIT` 数据生成部分分配、整票拼柜和未分配对照清单;人工在 Admin 完成计划分配后,`manual-order-splitting-verify.mjs` 读取准备报告和 `loadingPlanId`,只读核对柜次范围、计划箱数及候选余量。完整命令见同一指南的“人工分柜验收”章节。 diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md index 1f9b50166..093e204a9 100644 --- a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -55,6 +55,7 @@ - 固定渠道存在当前 `ACTIVE` 价格簿和进仓码前缀。 - FBA/OVERSEAS 至少存在一个与固定渠道价格明细匹配的目的仓。 - File、Order、Firstleg、Warehouse、IAM、Auth 等链路可用。 +- 测量托盘只复用仓库已有 `TP-001`、`YW-TP-002`;脚本不创建托盘主档。库位由正式仓储接口分配,普通位不足时使用公共位 `A0`,脚本不创建库位主档。 账号、密码、环境域名和 Token 不得写入本文、源码、提交记录或报告。建议由本地未提交环境文件或当前终端环境变量提供。 @@ -405,7 +406,7 @@ node scripts/admin-test-env-acceptance.mjs ` ## 失败、重跑与批量拆分 -- 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。 +- `admin-test-env-acceptance.mjs --wait-split-count` 的普通批次遇到第一条失败即停止;`it-test-wait-split-bulk.mjs` 会记录该票失败并继续尝试后续票。两种入口都不会自动删除或回滚已经成功创建的订单。 - 先查看日志和 JSON 报告中最后一个成功 fixture、失败接口的 `code/requestId/traceId`,修复前置后再运行剩余数量。 - 重跑会生成新业务单号和入仓码,不覆盖旧订单;不要假设跨多次运行的入仓码一定连续。 - 每次运行的随机序列从 1 重新开始;分批执行时组合可能重复,但仍保持固定三项和真实契约。 @@ -429,6 +430,7 @@ node scripts/admin-test-env-acceptance.mjs ` ```powershell node --check scripts/admin-test-env-acceptance.mjs +node --check scripts/it-test-wait-split-bulk.mjs node --check scripts/admin-test-env-modules/orders.mjs node --check scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs node --check scripts/admin-test-env-modules/manual-order-splitting-verify.mjs @@ -440,7 +442,7 @@ node --check scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs node --check scripts/admin-test-env-modules/manual-pda-batch-verify.mjs node --check scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs node --check scripts/admin-test-env-modules/manual-loading-complete-verify.mjs -node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs +node --test scripts/script-documentation-contract.test.mjs scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs git diff --check ``` diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 72f4f74cd..dc1ee96b4 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -107,6 +107,14 @@ Assert-Contains $backendAgents '所有生产 MyBatis Mapper 接口必须显式 Assert-Contains $backendAgents 'Mapper 新增、移动、Bean 缺失与扫描遗漏' '后端 Mapper 快速路由' Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同' Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同' +$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md') +$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml') +Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' +Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义' +Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口' +Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界' +Assert-Contains $coloadOpenApi '允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致' '同柜资格 OpenAPI' +Assert-Contains $coloadOpenApi '服务端解析每个订单全部 WAIT_SPLIT' '同柜订单展开 OpenAPI' $mapperModuleAgents = @( @{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' }, @{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' }, @@ -408,6 +416,8 @@ $criticalPaths = @( 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts', 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.test.ts', 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/unified-orders/api/coLoadGroups.ts', + 'apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue', 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue', 'apps/admin-web/src/core/routing/navigationRecovery.ts', @@ -483,6 +493,9 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java', 'baiyue-firstleg/firstleg/src/main/resources/openapi/customs-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/coload-groups-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java', 'baiyue-firstleg/file/src/main/resources/openapi/customs-file-and-export-v1.yaml', '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', diff --git a/scripts/it-test-wait-split-bulk.mjs b/scripts/it-test-wait-split-bulk.mjs index 877f72fdf..b8b9f3328 100644 --- a/scripts/it-test-wait-split-bulk.mjs +++ b/scripts/it-test-wait-split-bulk.mjs @@ -1,3 +1,13 @@ +#!/usr/bin/env node + +/** + * 为已授权的非生产环境尽力生成 IT测试/义乌仓 WAIT_SPLIT 数据。 + * + * 每票随机 1-300 箱(可通过参数缩小),失败会记录后继续;不会自动清理已写入数据。 + * 测量前置只复用 TP-001/YW-TP-002,不创建托盘;库位由正式仓储接口分配, + * 普通位不足时由服务端落公共位 A0,脚本不创建库位。 + * 账号、密码和 Gateway 只从 admin-test-env-acceptance.mjs 支持的环境变量读取。 + */ import { randomInt } from 'node:crypto' import { appendFile, mkdir, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' diff --git a/scripts/script-documentation-contract.test.mjs b/scripts/script-documentation-contract.test.mjs new file mode 100644 index 000000000..05f7c6939 --- /dev/null +++ b/scripts/script-documentation-contract.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +const scriptsReadme = path.join(repositoryRoot, 'scripts', 'README.md') +const excludedDirectoryNames = new Set([ + '.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target', 'uni_modules', +]) +const allowedDirectoryPrefixes = [ + 'scripts/', + 'deploy/scripts/', + 'deploy/rocketmq/', + 'firstleg/scripts/', + 'lastleg-service/scripts/', + 'warehouse/scripts/', +] + +async function executableFiles(directory, extensions, { excludeTests = true } = {}) { + const entries = await readdir(path.join(repositoryRoot, directory), { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile()) + .filter((entry) => extensions.some((extension) => entry.name.endsWith(extension))) + .filter((entry) => !excludeTests || !entry.name.endsWith('.test.mjs')) + .map((entry) => `${directory}/${entry.name}`.replaceAll('\\', '/')) +} + +async function discoverScriptFiles(directory = repositoryRoot) { + const discovered = [] + const entries = await readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && ( + excludedDirectoryNames.has(entry.name) + || entry.name.startsWith('node_modules.') + || entry.name.startsWith('.codex-') + || entry.name.startsWith('.runtime-') + )) continue + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) { + discovered.push(...await discoverScriptFiles(absolute)) + continue + } + if (!['.mjs', '.ps1', '.sh', '.groovy'].some((extension) => entry.name.endsWith(extension))) continue + discovered.push(path.relative(repositoryRoot, absolute).replaceAll('\\', '/')) + } + return discovered +} + +test('executable scripts only live in documented directories', async () => { + const files = await discoverScriptFiles() + const misplaced = files.filter((file) => !allowedDirectoryPrefixes.some((prefix) => file.startsWith(prefix))) + assert.deepEqual(misplaced, [], `以下脚本位于未登记目录:\n${misplaced.join('\n')}`) +}) + +test('all directly runnable scripts are documented in scripts/README.md', async () => { + const readme = await readFile(scriptsReadme, 'utf8') + const files = [ + ...await executableFiles('scripts', ['.mjs', '.ps1', '.groovy']), + ...await executableFiles('scripts/openapi-validation', ['.mjs']), + ...await executableFiles('deploy/scripts', ['.sh']), + ...await executableFiles('deploy/rocketmq', ['.sh']), + ...await executableFiles('firstleg/scripts', ['.mjs']), + ...await executableFiles('lastleg-service/scripts', ['.mjs']), + ] + + const undocumented = files.filter((file) => { + const relativeToScripts = file.startsWith('scripts/') ? file.slice('scripts/'.length) : file + return !readme.includes(file) && !readme.includes(relativeToScripts) + }) + + assert.deepEqual(undocumented, [], `以下可执行脚本未登记用途与边界:\n${undocumented.join('\n')}`) +}) + +test('README explains internal modules and test scripts are not standalone entries', async () => { + const readme = await readFile(scriptsReadme, 'utf8') + assert.match(readme, /\*\.test\.mjs.*离线 Node 静态合同/) + assert.match(readme, /admin-test-env-modules\/\*\.mjs.*逐模块说明/) + assert.match(readme, /warehouse\/scripts\/warehouse-public-master-data-contract\.test\.mjs/) +}) diff --git a/warehouse/scripts/README.md b/warehouse/scripts/README.md new file mode 100644 index 000000000..797deee84 --- /dev/null +++ b/warehouse/scripts/README.md @@ -0,0 +1,9 @@ +# Warehouse 服务局部脚本 + +本目录只放 Warehouse 服务自身的离线分析或数据合同,不放仓库级 HTTP 验收、部署脚本或共享工具。 + +| 脚本 | 用途 | 副作用 | 运行方式 | +|---|---|---|---| +| `warehouse-public-master-data-contract.test.mjs` | 校验公共 FBA 仓 CSV、四个固定平台仓和 `warehouse-public-master-data.sql` 的数量、唯一性、字段完整性及 SQL 安全边界 | 只读 Warehouse 源码中的 CSV/SQL;不连接数据库、不执行 SQL、不写文件 | 从后端根执行 `node --test warehouse/scripts/warehouse-public-master-data-contract.test.mjs` | + +新增 Warehouse 局部 Node 脚本时应放在本目录,并同步本表。跨服务或仓库级脚本仍放根 `scripts/`,Java/JUnit 测试仍放 `src/test/java/`。 diff --git a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/scripts/warehouse-public-master-data-contract.test.mjs similarity index 98% rename from warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs rename to warehouse/scripts/warehouse-public-master-data-contract.test.mjs index 363e10047..6dbb91c9e 100644 --- a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/scripts/warehouse-public-master-data-contract.test.mjs @@ -4,7 +4,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const currentDir = dirname(fileURLToPath(import.meta.url)) -const warehouseRoot = resolve(currentDir, '..', '..', '..') +const warehouseRoot = resolve(currentDir, '..') const sqlPath = resolve(warehouseRoot, 'src/main/resources/db/warehouse-public-master-data.sql') const acceptedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.accepted.csv') const rejectedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.rejected.csv')