diff --git a/Jenkinsfile b/Jenkinsfile index 5a1264f5c..f3ec9511c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -369,6 +369,25 @@ pipeline { } } + stage('数据库结构只读门禁') { + agent { label 'java-builder' } + steps { + script { + String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-schema-readonly" + withCredentials([file(credentialsId: credentialId, variable: 'SCHEMA_CHECK_ENV_FILE')]) { + sh ''' + set -eu + set -a + . "$SCHEMA_CHECK_ENV_FILE" + set +a + test "${SCHEMA_CHECK_READ_ONLY:-}" = "true" + node scripts/database-schema-contract.mjs --region BOTH + ''' + } + } + } + } + stage('统一 Maven 测试门禁') { when { expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 32fffe3f1..8d8cf8596 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -37,7 +37,7 @@ | 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;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`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | -| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | +| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、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`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java index 6bc428f35..eef7d4cfb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java @@ -22,6 +22,15 @@ public interface ColoadGroupMapper { (SELECT COUNT(*) FROM fl_loading_plan_item item WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0) activeAllocationCount, + (SELECT COUNT(DISTINCT item.loading_plan_id) FROM fl_loading_plan_item item + WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0) + activeAllocationPlanCount, + (SELECT MIN(item.loading_plan_id) FROM fl_loading_plan_item item + WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0) + activeAllocationPlanId, + (SELECT COALESCE(SUM(item.package_count),0) FROM fl_loading_plan_item item + WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0) + activeAllocatedPackageCount, (SELECT member.group_id FROM fl_coload_group_member member WHERE member.tenant_id=o.tenant_id AND member.inbound_id=o.id AND member.active_flag=1 LIMIT 1) activeGroupId diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java index 4ea52edc5..6ee1eca41 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java @@ -25,6 +25,9 @@ public final class ColoadPersistenceRows { private String channelName; private Integer fullPackageCount; private Integer activeAllocationCount; + private Integer activeAllocationPlanCount; + private Long activeAllocationPlanId; + private Integer activeAllocatedPackageCount; private Long activeGroupId; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java index 369717a2c..dfa38d9cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java @@ -35,6 +35,9 @@ import java.util.Set; @Service public class ColoadGroupService implements ColoadGroupAssignmentPort { private static final Set CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED"); + private static final Set PRE_LOADING_GROUP_STATUSES = Set.of( + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"); @Resource private ColoadGroupMapper mapper; @@ -50,6 +53,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { List candidates = authoritativeCandidates(orderIds, null); validateSameRoute(candidates); + Long lockedPlanId = validateAllocationState(candidates); long groupId = IdWorker.getId(); String groupNo = "CLG-" + groupId; InboundCandidateRow first = candidates.get(0); @@ -60,6 +64,14 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { candidate.getInboundId(), candidate.getFullPackageCount()); } history(groupId, "CREATE", null, "ACTIVE", null, key, null, operatorId, operatorName); + if (lockedPlanId != null) { + if (mapper.lockToPlan(groupId, lockedPlanId, operatorId, trim(operatorName)) != 1) { + throw conflict("装柜中订单的同柜组锁定失败,请刷新后重试"); + } + history(groupId, "LOCK", "ACTIVE", "LOCKED", lockedPlanId, + "CREATE-LOCK-" + sha256(key).substring(0, 48), null, + operatorId, operatorName); + } complete(claim.commandId(), groupId); return requiredSnapshot(groupId); } @@ -257,8 +269,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { if (row.getTenantId() == null || row.getTenantId() <= 0 || row.getInboundId() == null || row.getInboundId() <= 0 || !StringUtils.hasText(row.getInboundCode())) return "权威业务快照缺失"; - if (!"WAIT_SPLIT".equals(row.getProcessStatus())) return "状态非待分柜"; - if (!Objects.equals(row.getActiveAllocationCount(), 0)) return "已分配装柜计划"; + if (!PRE_LOADING_GROUP_STATUSES.contains(row.getProcessStatus()) + && !"LOADING".equals(row.getProcessStatus())) { + return "状态不允许创建同柜组"; + } if (row.getActiveGroupId() != null && !Objects.equals(row.getActiveGroupId(), allowedGroupId)) { return "已在其他同柜组"; } @@ -271,6 +285,33 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { return null; } + private Long validateAllocationState(List rows) { + boolean hasAllocation = rows.stream().anyMatch(row -> !Objects.equals(row.getActiveAllocationCount(), 0)); + boolean hasUnallocated = rows.stream().anyMatch(row -> Objects.equals(row.getActiveAllocationCount(), 0)); + if (!hasAllocation) { + if (rows.stream().anyMatch(row -> "LOADING".equals(row.getProcessStatus()))) { + throw conflict("装柜中订单缺少有效装柜计划占用"); + } + return null; + } + if (hasUnallocated) { + throw conflict("所选订单只有部分成员进入装柜计划,不能创建同柜组"); + } + Long planId = null; + for (InboundCandidateRow row : rows) { + if (!Objects.equals(row.getActiveAllocationPlanCount(), 1) + || row.getActiveAllocationPlanId() == null + || !Objects.equals(row.getActiveAllocatedPackageCount(), row.getFullPackageCount())) { + throw conflict(candidateLabel(row) + "未完整分配到唯一装柜计划"); + } + if (planId == null) planId = row.getActiveAllocationPlanId(); + else if (!Objects.equals(planId, row.getActiveAllocationPlanId())) { + throw conflict("所选装柜中订单不在同一个装柜计划,不能创建同柜组"); + } + } + return planId; + } + private static String candidateLabel(InboundCandidateRow row) { return StringUtils.hasText(row.getInboundCode()) ? "入仓码“" + row.getInboundCode().trim() + "”" : "入仓码缺失的片段"; diff --git a/firstleg/src/main/resources/openapi/coload-groups-v1.yaml b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml index 577859844..abe56c392 100644 --- a/firstleg/src/main/resources/openapi/coload-groups-v1.yaml +++ b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml @@ -8,11 +8,12 @@ paths: post: summary: 创建同柜不可拆组 description: >- - 需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单全部 WAIT_SPLIT、 - 未分配且未属于其他活动组的可用入库片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。 - 同一订单部分片段不可用时只纳入全部当前可用片段;全部不可用时按入仓码明确返回状态非待分柜、 - 已分配装柜计划、已在其他同柜组、箱数无效或仓库/渠道权威快照缺失。跨成员仓库或渠道不一致时 - 返回入仓码及仓库名/渠道名;平台错误不得展示 orderId、inboundId 或 groupId。 + 需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单处于待审核、 + 待测量/测量中、待分柜或装柜中的全部有效入仓片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。 + 全部成员未进入装柜计划时创建 ACTIVE 组;全部成员已经按满箱数完整进入同一个装柜计划时直接创建并 + 锁定为 LOCKED 组。仅部分成员进柜、分散在不同装柜计划、箱数不完整、已属于其他活动组或权威仓库/ + 渠道快照缺失时拒绝。跨成员仓库或渠道不一致时返回入仓码及仓库名/渠道名;平台错误不得展示 + orderId、inboundId 或 groupId。 parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java index 5b74f9424..67c3004be 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java @@ -39,12 +39,17 @@ class ColoadGroupOpenApiContractTest { .contains("platform:firstleg:smart-loading:assign") .contains("platform:firstleg:smart-loading:list") .contains("任一冻结成员缺少有效 inboundCode 时失败关闭") - .contains("全部 WAIT_SPLIT") + .contains("待审核") + .contains("待测量/测量中") + .contains("待分柜或装柜中") + .contains("直接创建并") + .contains("锁定为 LOCKED 组") .contains("国内仓和渠道一致") - .contains("状态非待分柜") - .contains("已分配装柜计划") - .contains("已在其他同柜组") - .contains("平台错误不得展示 orderId、inboundId 或 groupId") + .contains("仅部分成员进柜") + .contains("分散在不同装柜计划") + .contains("已属于其他活动组") + .contains("平台错误不得展示") + .contains("orderId、inboundId 或 groupId") .doesNotContain("destinationType") .doesNotContain("destinationWarehouseId") .doesNotContain("destinationCode") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java index 07e46b5f3..587578fcc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java @@ -123,11 +123,9 @@ class ColoadGroupServiceTest { @Test void createsCrossCustomerGroupAndFreezesAllAvailableFragmentsForEveryOrder() { - InboundCandidateRow alreadyAllocatedFragment = candidate(101L, 11L, 1004L, 40); - alreadyAllocatedFragment.setActiveAllocationCount(1); when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( candidate(101L, 11L, 1001L, 30), candidate(101L, 11L, 1003L, 20), - alreadyAllocatedFragment, candidate(202L, 12L, 1002L, 50))); + candidate(202L, 12L, 1002L, 50))); when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"), eq(7L), eq("operator"))).thenReturn(1); when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt())) @@ -154,8 +152,82 @@ class ColoadGroupServiceTest { verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1001L), eq(30)); verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1003L), eq(20)); verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50)); - verify(mapper, never()).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), - eq(1004L), anyInt()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = { + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" + }) + void createsUnlockedGroupBeforeLoading(String status) { + InboundCandidateRow first = candidate(101L, 11L, 1001L, 30); first.setProcessStatus(status); + InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); second.setProcessStatus(status); + stubSuccessfulCreate("status-" + status, List.of(first, second), "ACTIVE", null); + + var result = service.create(List.of(11L, 12L), "status-" + status, 7L, "operator"); + + assertThat(result.status()).isEqualTo("ACTIVE"); + verify(mapper, never()).lockToPlan(anyLong(), anyLong(), anyLong(), any()); + } + + @Test + void createsLockedGroupWhenEveryLoadingMemberIsFullyAllocatedToTheSamePlan() { + InboundCandidateRow first = loadingCandidate(101L, 11L, 1001L, 30, 700L); + InboundCandidateRow second = loadingCandidate(202L, 12L, 1002L, 50, 700L); + stubSuccessfulCreate("loading-key", List.of(first, second), "LOCKED", 700L); + when(mapper.lockToPlan(anyLong(), eq(700L), eq(7L), eq("operator"))).thenReturn(1); + + var result = service.create(List.of(11L, 12L), "loading-key", 7L, "operator"); + + assertThat(result.status()).isEqualTo("LOCKED"); + assertThat(result.lockedPlanId()).isEqualTo(700L); + verify(mapper).lockToPlan(anyLong(), eq(700L), eq(7L), eq("operator")); + verify(mapper).insertHistory(anyLong(), anyLong(), eq("LOCK"), eq("ACTIVE"), eq("LOCKED"), + eq(700L), anyString(), isNull(), eq(7L), eq("operator")); + } + + @Test + void createsLockedGroupForPreMeasurementOrdersAlreadyFullyReservedInTheSamePlan() { + InboundCandidateRow first = loadingCandidate(101L, 11L, 1001L, 30, 700L); + first.setProcessStatus("PREPARING"); + InboundCandidateRow second = loadingCandidate(202L, 12L, 1002L, 50, 700L); + second.setProcessStatus("MEASURING"); + stubSuccessfulCreate("reserved-early", List.of(first, second), "LOCKED", 700L); + when(mapper.lockToPlan(anyLong(), eq(700L), eq(7L), eq("operator"))).thenReturn(1); + + var result = service.create(List.of(11L, 12L), "reserved-early", 7L, "operator"); + + assertThat(result.status()).isEqualTo("LOCKED"); + assertThat(result.lockedPlanId()).isEqualTo(700L); + } + + @Test + void rejectsLoadingMembersSplitAcrossPlansOrNotFullyAllocated() { + when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString())).thenReturn(1); + InboundCandidateRow first = loadingCandidate(101L, 11L, 1001L, 30, 700L); + InboundCandidateRow otherPlan = loadingCandidate(202L, 12L, 1002L, 50, 701L); + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, otherPlan)); + assertThatThrownBy(() -> service.create(List.of(11L, 12L), "different-plans", 7L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("不在同一个装柜计划"); + + InboundCandidateRow partial = loadingCandidate(202L, 12L, 1002L, 50, 700L); + partial.setActiveAllocatedPackageCount(49); + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, partial)); + assertThatThrownBy(() -> service.create(List.of(11L, 12L), "partial-loading", 7L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("未完整分配到唯一装柜计划"); + } + + @Test + void rejectsSelectionWhenOnlyPartOfTheMembersHasEnteredAPlan() { + when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString())).thenReturn(1); + InboundCandidateRow reserved = loadingCandidate(101L, 11L, 1001L, 30, 700L); + reserved.setProcessStatus("WAIT_MEASURE"); + InboundCandidateRow unallocated = candidate(202L, 12L, 1002L, 50); + unallocated.setProcessStatus("MEASURING"); + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(reserved, unallocated)); + + assertThatThrownBy(() -> service.create(List.of(11L, 12L), "partly-reserved", 7L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("只有部分成员进入装柜计划"); } @Test @@ -179,7 +251,7 @@ class ColoadGroupServiceTest { assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator")) .isInstanceOf(BizException.class) .hasMessageContaining("订单“FO-11”") - .hasMessageContaining("入仓码“IN-1001”已分配装柜计划") + .hasMessageContaining("入仓码“IN-1001”已在其他同柜组") .hasMessageNotContaining("订单 11") .hasMessageNotContaining("groupId") .hasMessageNotContaining("inboundId"); @@ -219,8 +291,8 @@ class ColoadGroupServiceTest { when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString())) .thenReturn(1); InboundCandidateRow status = candidate(101L, 11L, 1001L, 30); - status.setProcessStatus("LOADING"); - assertUnavailable(status, "state-key", "状态非待分柜"); + status.setProcessStatus("LOADED"); + assertUnavailable(status, "state-key", "状态不允许创建同柜组"); InboundCandidateRow grouped = candidate(101L, 11L, 1001L, 30); grouped.setActiveGroupId(900L); @@ -393,9 +465,33 @@ class ColoadGroupServiceTest { value.setProcessStatus("WAIT_SPLIT"); value.setWarehouseId(9L); value.setWarehouseName("义乌仓"); value.setChannelCode("CH"); value.setChannelName("美森海卡"); value.setFullPackageCount(packages); value.setActiveAllocationCount(0); + value.setActiveAllocationPlanCount(0); value.setActiveAllocatedPackageCount(0); return value; } + private static InboundCandidateRow loadingCandidate(long tenantId, long orderId, long inboundId, + int packages, long planId) { + InboundCandidateRow value = candidate(tenantId, orderId, inboundId, packages); + value.setProcessStatus("LOADING"); value.setActiveAllocationCount(1); + value.setActiveAllocationPlanCount(1); value.setActiveAllocationPlanId(planId); + value.setActiveAllocatedPackageCount(packages); + return value; + } + + private void stubSuccessfulCreate(String key, List candidates, + String resultStatus, Long planId) { + when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq(key), anyString())).thenReturn(1); + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(candidates); + when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"), eq(7L), eq("operator"))).thenReturn(1); + when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt())).thenReturn(1); + when(mapper.insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(), any(), + anyString(), any(), anyLong(), any())).thenReturn(1); + when(mapper.completeCommand(anyLong(), anyLong())).thenReturn(1); + when(mapper.group(anyLong())).thenReturn(group(500L, resultStatus, planId, "LOCKED".equals(resultStatus) ? 1 : 0)); + when(mapper.members(anyLong())).thenReturn(candidates.stream().map(row -> member( + row.getTenantId(), row.getOrderId(), row.getInboundId(), row.getFullPackageCount())).toList()); + } + private void assertUnavailable(InboundCandidateRow row, String key, String reason) { when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( row, candidate(202L, 12L, 1002L, 50))); diff --git a/scripts/README.md b/scripts/README.md index e3e641057..e0ec274ad 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -126,6 +126,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 | | 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway | | 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 | +| Jenkins 只读数据库结构 | `database-schema-contract.mjs` | 只查询 `information_schema` 核对区域初始化 SQL 的表和字段;不执行 SQL、不造业务数据、不执行迁移 | | 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 | | 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 | | 本地质量 | `java-quality-summary.mjs` | 聚合 Maven 拓扑、OpenAPI、JaCoCo 和 SpotBugs 已有报告,不启动服务 | diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index f9d071fde..0962b711d 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -267,10 +267,10 @@ $coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/re Assert-Contains $businessIndex '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 $firstlegAgents '服务端允许待审核、待测量/测量中、待分柜及装柜中订单' 'Firstleg 同柜建组边界' Assert-Contains $warehouseAgents 'scripts/warehouse-public-master-data-contract.test.mjs' 'Warehouse 局部脚本入口' Assert-Contains $coloadOpenApi '允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致' '同柜资格 OpenAPI' -Assert-Contains $coloadOpenApi '服务端解析每个订单全部 WAIT_SPLIT' '同柜订单展开 OpenAPI' +Assert-Contains $coloadOpenApi '待分柜或装柜中的全部有效入仓片段' '同柜订单展开 OpenAPI' Assert-Contains $rootAgents '用户可见页面、提示和错误不得展示或以内部数据库 ID 作为业务编号回退' '根业务编号展示边界' Assert-Contains $adminAgents '/loading-plans/{planId}/lifecycle' 'Admin 装柜清单生命周期入口' Assert-Contains $adminAgents '合柜报关 / 同柜不可拆组' 'Admin 合柜报关别名入口' diff --git a/scripts/database-schema-contract.mjs b/scripts/database-schema-contract.mjs new file mode 100644 index 000000000..f5728f720 --- /dev/null +++ b/scripts/database-schema-contract.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..') +const SQL_ROOT = path.join(REPO_ROOT, 'deploy', 'sql') +const MYSQL_CANDIDATES = [process.env.MYSQL_BIN, 'mysql'].filter(Boolean) + +const REGIONS = { + CN: { + sql: path.join(SQL_ROOT, '头程数据库统一增量-MySQL8.sql'), + schemas: [ + 'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse', + 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', + 'baiyue_finance', + ], + }, + OVERSEAS: { + sql: path.join(SQL_ROOT, '海外区域统一增量-MySQL8.sql'), + schemas: ['baiyue_lastleg', 'baiyue_lastlag_warehouse', 'baiyue_event_bridge_overseas'], + }, +} + +function usage() { + console.log('用法:node scripts/database-schema-contract.mjs --region CN|OVERSEAS|BOTH') + console.log('必需环境变量:SCHEMA_CHECK_HOST、SCHEMA_CHECK_PORT、SCHEMA_CHECK_USERNAME、SCHEMA_CHECK_PASSWORD') + console.log('可选环境变量:MYSQL_BIN;默认只读查询 information_schema,不执行 SQL') +} + +function parseRegion(argv) { + const index = argv.indexOf('--region') + const region = index >= 0 ? argv[index + 1] : 'BOTH' + if (region === 'BOTH') return ['CN', 'OVERSEAS'] + if (!REGIONS[region]) throw new Error(`不支持的区域:${region}`) + return [region] +} + +function config() { + const keys = ['SCHEMA_CHECK_HOST', 'SCHEMA_CHECK_PORT', 'SCHEMA_CHECK_USERNAME', 'SCHEMA_CHECK_PASSWORD'] + const missing = keys.filter((key) => !process.env[key]) + if (missing.length) throw new Error(`缺少只读数据库环境变量:${missing.join(', ')}`) + return Object.fromEntries(keys.map((key) => [key, process.env[key]])) +} + +function parseExpected(sql) { + const expected = new Map() + let currentSchema = '' + const lines = sql.split(/\r?\n/) + for (let index = 0; index < lines.length; index += 1) { + const use = lines[index].match(/^USE\s+`?([a-z][a-z0-9_]*)`?\s*;/i) + if (use) { + currentSchema = use[1] + continue + } + const start = lines[index].match(/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(?:`?([a-z][a-z0-9_]*)`?\.)?`?([a-z][a-z0-9_]*)`?\s*\(/i) + if (!start) continue + const body = [] + index += 1 + while (index < lines.length && !/^\) ENGINE=/i.test(lines[index])) { + body.push(lines[index]) + index += 1 + } + const schema = start[1] || currentSchema + const table = start[2] + const columns = new Set() + for (const line of body) { + const field = line.match(/^\s+`([a-z][a-z0-9_]*)`\s+/i) + if (field) columns.add(field[1]) + } + if (!columns.size) continue + const key = schema ? `${schema}.${table}` : table + expected.set(key, columns) + } + return expected +} + +function mysql(cfg, database, sql) { + let lastError = '' + for (const executable of MYSQL_CANDIDATES) { + const result = spawnSync(executable, [ + '--protocol=TCP', '-h', cfg.SCHEMA_CHECK_HOST, '-P', cfg.SCHEMA_CHECK_PORT, + '-u', cfg.SCHEMA_CHECK_USERNAME, '--database', database, + '--batch', '--skip-column-names', '--default-character-set=utf8mb4', + ], { input: sql, encoding: 'utf8', env: { ...process.env, MYSQL_PWD: cfg.SCHEMA_CHECK_PASSWORD } }) + if (result.error?.code === 'ENOENT') { + lastError = result.error.message + continue + } + if (result.status !== 0) throw new Error((result.stderr || result.stdout || 'mysql 查询失败').trim()) + return result.stdout + } + throw new Error(`找不到 mysql 客户端:${lastError}`) +} + +function actualSchema(cfg, schema) { + const sql = 'SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = DATABASE() ORDER BY table_name, ordinal_position;' + const output = mysql(cfg, schema, sql) + const actual = new Map() + for (const line of output.trim().split(/\r?\n/).filter(Boolean)) { + const [table, column] = line.split('\t') + if (!table || !column) continue + if (!actual.has(table)) actual.set(table, new Set()) + actual.get(table).add(column) + } + return actual +} + +function compare(expected, actual) { + const missingTables = [] + const missingColumns = [] + for (const [table, columns] of expected) { + const current = actual.get(table) + if (!current) { + missingTables.push(table) + continue + } + for (const column of columns) if (!current.has(column)) missingColumns.push(`${table}.${column}`) + } + return { missingTables, missingColumns } +} + +async function main() { + const argv = process.argv.slice(2) + if (argv.includes('--help') || argv.includes('-h')) return usage() + const cfg = config() + const regions = parseRegion(argv) + const failures = [] + for (const region of regions) { + const definition = REGIONS[region] + const sql = await readFile(definition.sql, 'utf8') + const expectedAll = parseExpected(sql) + for (const schema of definition.schemas) { + const expected = new Map([...expectedAll].filter(([key]) => key.startsWith(`${schema}.`)).map(([key, columns]) => [key.slice(schema.length + 1), columns])) + const actual = actualSchema(cfg, schema) + const result = compare(expected, actual) + console.log(`${region} ${schema}: expected_tables=${expected.size} actual_tables=${actual.size} missing_tables=${result.missingTables.length} missing_columns=${result.missingColumns.length}`) + if (result.missingTables.length) console.log(` missing_tables=${result.missingTables.join(',')}`) + if (result.missingColumns.length) console.log(` missing_columns=${result.missingColumns.join(',')}`) + failures.push(...result.missingTables.map((item) => `${region}/${schema}/${item}`), ...result.missingColumns.map((item) => `${region}/${schema}/${item}`)) + } + } + if (failures.length) { + console.error(`DATABASE_SCHEMA_CONTRACT_FAIL missing=${failures.length}`) + process.exitCode = 1 + return + } + console.log(`DATABASE_SCHEMA_CONTRACT_PASS regions=${regions.join(',')}`) +} + +main().catch((error) => { + console.error(`DATABASE_SCHEMA_CONTRACT_ERROR: ${error.message}`) + process.exitCode = 1 +})