优化
This commit is contained in:
Vendored
+19
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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,相同键不同内容返回冲突 |
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+43
-2
@@ -35,6 +35,9 @@ import java.util.Set;
|
||||
@Service
|
||||
public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
private static final Set<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
|
||||
private static final Set<String> 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<InboundCandidateRow> 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<InboundCandidateRow> 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() + "”" : "入仓码缺失的片段";
|
||||
|
||||
@@ -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:
|
||||
|
||||
+10
-5
@@ -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")
|
||||
|
||||
+104
-8
@@ -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<InboundCandidateRow> 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)));
|
||||
|
||||
@@ -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 已有报告,不启动服务 |
|
||||
|
||||
@@ -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 合柜报关别名入口'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user