This commit is contained in:
2026-09-20 14:43:26 +08:00
parent 398f4e6983
commit a7d458cddd
11 changed files with 356 additions and 23 deletions
+1 -1
View File
@@ -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,相同键不同内容返回冲突 |
@@ -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
@@ -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;
}
@@ -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:
@@ -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")
@@ -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)));