This commit is contained in:
2026-08-10 12:46:12 +08:00
parent fc554273b9
commit 986e97acb6
14 changed files with 198 additions and 13 deletions
+1 -1
View File
@@ -31,7 +31,7 @@
- 一期:`auth`、`iam`、`warehouse`、`order`、`firstleg`、`file` 完成登录、客户/RBAC、公共仓、订单闭环;先读 `../docs/一期/README.md` 及闭环设计。
- 二期:`firstleg` 主导入库/测量/审核/计费,`warehouse` 管托盘库位,`file` 管测量图片,`integration-service` 管 OCR,`order` 仅提供货物内部查询;终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`,不实现分柜、装柜、PDA 和完整财务。
- 三期:已确认范围可按 `../docs/三期/三期开工门禁.md` 进入契约和模块实现。`firstleg` 负责履约编排、柜次、排柜、订舱、拖车、PDA 与 `LOADED` 门禁;`order`、`warehouse`、`file`、`iam`、`gateway` 和 `integration-service` 仅在各自契约需要时协作。未冻结的字段、权限、迁移或设备事项只阻断对应模块。
- 三期:已确认范围可按 `../docs/三期/三期开工门禁.md` 进入契约和模块实现。`firstleg` 负责履约编排、柜次、排柜、订舱、拖车、PDA 与 `LOADED` 门禁;`order`、`warehouse`、`file`、`iam`、`gateway` 和 `integration-service` 仅在各自契约需要时协作。未冻结的字段、权限、迁移或跨服务调用只阻断对应模块。
## 契约、数据库与验证入口
+1 -1
View File
@@ -39,4 +39,4 @@
- 一期:履约任务与分配;先读 `../../docs/一期/README.md`。
- 二期:入库、测量、审核、计费闭环;先读 `../../docs/二期/README.md`、主流程和联调报告。
- 三期:负责订单履约编排、柜次、智能排柜、订舱、拖车、PDA 作业与 `LOADED` 门禁;先读 `../../docs/三期/三期开工门禁.md`。已确认范围按 Controller、DTO、OpenAPI、迁移和调用方共同实现;设备与真机事项只阻断相应验收,不得阻断已确认契约。
- 三期:负责订单履约编排、柜次、智能排柜、订舱、拖车、PDA 作业与 `LOADED` 门禁;先读 `../../docs/三期/三期开工门禁.md`。已确认范围按 Controller、DTO、OpenAPI、迁移和调用方共同实现;PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。
@@ -33,6 +33,7 @@ import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -694,12 +694,35 @@ BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence',
'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
'uk_inbound_split_history_operation')) <> 5 THEN
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence')) <> 2
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_batch'
AND index_name IN ('uk_inbound_split_operation', 'uk_inbound_split_idempotency',
'uk_inbound_split_source_once')) <> 3
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_item'
AND index_name IN ('uk_inbound_split_batch_line',
'uk_inbound_split_batch_source_child')) <> 2
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_history'
AND index_name = 'uk_inbound_split_history_operation') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_batch'
AND column_name IN ('source_inbound_id', 'loaded_child_inbound_id',
'remainder_child_inbound_id', 'loaded_package_count',
'remaining_package_count', 'loading_plan_id',
'container_booking_id')) <> 7 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源、子单、数量或柜次字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_item'
AND column_name IN ('source_measurement_id', 'source_measurement_item_id')
AND is_nullable = 'NO') <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账必须绑定非空测量来源';
END IF;
IF (SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
@@ -725,10 +748,20 @@ BEGIN
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name IN ('uk_inbound_tenant_order', 'uk_inbound_one_child')
AND index_name IN ('uk_inbound_tenant_order', 'uk_inbound_one_child',
'uk_inbound_one_secondary', 'uk_inbound_parent_sequence')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item'
AND index_name IN ('uk_inbound_split_child', 'uk_inbound_split_batch_child',
'uk_inbound_split_batch_source_item')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧拆分明细唯一索引仍阻止数量守恒分配';
END IF;
END$$
DELIMITER ;
CALL phase3_assert_inbound_parent_child_schema();
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.loading.mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingFulfillmentCompletionMapperContractTest {
@Test
void completesLoadedSliceAndAggregatesRootOnlyAfterAllSlicesAreTerminal() throws Exception {
String target = String.join(" ", LoadingInboundMapper.class
.getMethod("completionTarget", long.class, long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
String completeChild = String.join(" ", LoadingInboundMapper.class
.getMethod("completeLoadedChild", long.class, long.class, long.class, int.class)
.getAnnotation(Update.class).value()).replaceAll("\\s+", " ");
String aggregateRoot = String.join(" ", LoadingInboundMapper.class
.getMethod("aggregateRootLoaded", long.class, long.class, long.class)
.getAnnotation(Update.class).value()).replaceAll("\\s+", " ");
assertThat(target)
.contains("batch.source_inbound_id=#{inboundId}")
.contains("batch.loaded_child_inbound_id=#{inboundId}")
.contains("batch.status='APPLIED'");
assertThat(completeChild)
.contains("batch.loaded_child_inbound_id=child.id")
.contains("child.process_status='LOADED'")
.contains("source.version=#{expectedSourceVersion}");
assertThat(aggregateRoot)
.contains("remainder.split_purpose='REMAINDER'")
.contains("remainder.split_status='NOT_SPLIT'")
.contains("loaded.split_purpose='LOADED'")
.contains("loaded.process_status&lt;&gt;'LOADED'");
}
}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -79,6 +80,44 @@ class LoadingDeliveryServiceTest {
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void completesLoadedFulfillmentSliceBeforeAggregatingRoot() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "INBOUNDED", 3));
when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget(
20L, 21L, 20L, "INBOUNDED", 3, "LOADING", 1));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
when(repository.completeLoadedChild(10, 20, 7, 3)).thenReturn(1);
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "slice-1",
new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 3);
verify(repository).aggregateRootLoaded(10, 20, 7);
verify(histories).insert(argThat((InboundStatusHistoryEntity history) ->
Long.valueOf(21L).equals(history.getInboundId())));
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void rejectsCompletionWhenEvidenceIsIncomplete() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -62,7 +62,7 @@ class InboundParentChildSqlContractTest {
.contains("NEW.remaining_package_count")
.contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count")
.contains("source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)")
.contains("拆分批次来源必须是根主单或其余量子单")
.contains("拆分批次来源必须是箱数匹配且未消费的根主单或余量子单")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("子单承接箱数超过来源测量箱规箱数")
.contains("child_order.package_count >= NEW.package_count")
@@ -90,7 +90,8 @@ class InboundParentChildSqlContractTest {
.contains("'fl_inbound_measurement', 'source_measurement_id'")
.contains("'fl_inbound_measurement_item', 'split_item_id'")
.contains("'fl_inbound_measurement_item', 'source_measurement_item_id'")
.contains("source_measurement_id BIGINT UNSIGNED NULL")
.contains("source_measurement_id BIGINT UNSIGNED NOT NULL")
.contains("source_measurement_item_id BIGINT UNSIGNED NOT NULL")
.contains("fk_inbound_split_item_measurement")
.contains("fk_measure_split_batch")
.contains("fk_measure_source_batch")
@@ -100,6 +101,18 @@ class InboundParentChildSqlContractTest {
.contains("idx_measure_item_split_source");
}
@Test
void finalAssertionIsTableSpecificAndRejectsLegacyIndexes() {
assertThat(sql)
.contains("table_name = 'fl_inbound_split_batch'")
.contains("uk_inbound_split_source_once")
.contains("table_name = 'fl_inbound_split_item'")
.contains("uk_inbound_split_batch_source_child")
.contains("is_nullable = 'NO'")
.contains("'uk_inbound_one_secondary', 'uk_inbound_parent_sequence'")
.contains("'uk_inbound_split_batch_source_item'");
}
private static String loadSql() {
try {
return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
@@ -66,5 +66,11 @@ class PdaSplitSettlementSqlContractTest {
.contains("container_booking_id")
.contains("#{loadingPlanId}")
.contains("#{containerBookingId}");
String clone = InboundSplitSettlementMapper.class.getMethod("cloneChildWithPurpose",
long.class, long.class, long.class, long.class, String.class, int.class,
int.class, String.class, long.class, String.class)
.getAnnotation(Insert.class).value()[0];
assertThat(clone).contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END");
}
}
@@ -39,7 +39,12 @@ public final class OrderSplitDtos {
) {}
public record Child(Long orderId, String orderNo, Integer splitSequence,
String splitRole, Integer packageCount) {}
String splitRole, Integer packageCount, Long sourceOrderId,
Long loadingPlanId, Long containerBookingId) {
public Child(Long orderId, String orderNo, Integer splitSequence, String splitRole, Integer packageCount) {
this(orderId, orderNo, splitSequence, splitRole, packageCount, null, null, null);
}
}
public record Tree(Long parentOrderId, String parentOrderNo, Integer parentVersion,
List<Child> children, Integer allocatedPackageCount,
@@ -15,6 +15,7 @@ public class OrderFulfillmentSplitEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long parentOrderId;
private Long sourceOrderId;
private Long childOrderId;
private Long loadingPlanId;
private Long containerBookingId;
@@ -36,6 +36,8 @@ public class OrderFulfillmentSplitService {
return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay);
}
OrderEntity parent = splits.lockOrder(req.tenantId(), req.parentOrderId());
replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
if (!replay.isEmpty()) return response(req, parent, replay);
if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "父订单不存在或不属于该客户");
if (!req.parentOrderId().equals(req.sourceOrderId()) && !isDirectChild(req.sourceOrderId(), parent))
throw new BizException(ResultCode.CONFLICT, "来源订单必须是父订单或其直接子订单");
@@ -67,7 +69,7 @@ public class OrderFulfillmentSplitService {
List<OrderFulfillmentSplitEntity> rows = splits.findByParent(tenantId, parentId);
Map<Long,OrderEntity> children = new HashMap<>();
for (OrderFulfillmentSplitEntity row: rows) { OrderEntity child=orders.selectById(row.getChildOrderId()); if(child!=null&&tenantId==child.getTenantId()) children.put(child.getId(),child); }
List<OrderSplitDtos.Child> result = rows.stream().map(r -> { OrderEntity c=children.get(r.getChildOrderId()); return new OrderSplitDtos.Child(r.getChildOrderId(), c==null?null:c.getOrderNo(), c==null?null:c.getSplitSequence(), r.getSplitRole(), "LOADED".equals(r.getSplitRole())?r.getLoadedPackageCount():r.getRemainingPackageCount()); }).toList();
List<OrderSplitDtos.Child> result = rows.stream().map(r -> { OrderEntity c=children.get(r.getChildOrderId()); return new OrderSplitDtos.Child(r.getChildOrderId(), c==null?null:c.getOrderNo(), c==null?null:c.getSplitSequence(), r.getSplitRole(), "LOADED".equals(r.getSplitRole())?r.getLoadedPackageCount():r.getRemainingPackageCount(), r.getSourceOrderId(), r.getLoadingPlanId(), r.getContainerBookingId()); }).toList();
int allocated = rows.stream().filter(r -> "LOADED".equals(r.getSplitRole())).mapToInt(OrderFulfillmentSplitEntity::getLoadedPackageCount).sum();
return new OrderSplitDtos.Tree(parent.getId(), parent.getOrderNo(), parent.getVersion(), result, allocated, Math.max(0, rows.stream().mapToInt(OrderFulfillmentSplitEntity::getSourcePackageCount).findFirst().orElse(0)-allocated));
}
@@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
parent_order_id BIGINT UNSIGNED NOT NULL,
source_order_id BIGINT UNSIGNED NOT NULL,
child_order_id BIGINT UNSIGNED NOT NULL,
split_role VARCHAR(12) NOT NULL COMMENT 'LOADED或REMAINDER',
loading_plan_id BIGINT UNSIGNED NULL,
@@ -74,6 +75,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role),
UNIQUE KEY uk_order_split_child (tenant_id,child_order_id),
KEY idx_order_split_parent (tenant_id,parent_order_id,created_at,id),
KEY idx_order_split_source (tenant_id,source_order_id,split_role),
KEY idx_order_split_container (tenant_id,container_booking_id,created_at,id),
CONSTRAINT fk_order_split_parent FOREIGN KEY(parent_order_id) REFERENCES ord_order(id),
CONSTRAINT fk_order_split_child FOREIGN KEY(child_order_id) REFERENCES ord_order(id),
@@ -114,6 +116,7 @@ END$$
DELIMITER ;
CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT 'LOADED或REMAINDER'");
CALL phase3_order_add_column('ord_order_fulfillment_split','source_order_id','BIGINT UNSIGNED NULL COMMENT ''实际被消费的来源订单ID''');
CALL phase3_order_add_column('ord_order_fulfillment_split','request_hash',"CHAR(64) NULL COMMENT '请求内容SHA-256;存量记录允许为空'");
DROP PROCEDURE IF EXISTS phase3_order_drop_split_index;
@@ -228,6 +228,46 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限}
/internal/order/v1/fulfillment-splits:
post:
summary: 创建父订单装柜拆分(Firstleg 内部)
parameters:
- $ref: '#/components/parameters/InternalToken'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FulfillmentSplitCreateRequest' }
responses:
'200': { description: 成功, content: { application/json: { schema: { $ref: '#/components/schemas/FulfillmentSplitResponse' } } } }
'409': { description: 数量或版本/幂等冲突 }
/internal/order/v1/fulfillment-splits/{tenantId}/{parentOrderId}:
get:
summary: 查询父子订单柜次树(Firstleg 内部)
parameters:
- $ref: '#/components/parameters/InternalToken'
- { name: tenantId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: parentOrderId, in: path, required: true, schema: { type: integer, format: int64 } }
responses:
'200': { description: 成功 }
'403': { description: 内部凭证无效 }
/api/platform/v1/orders/{orderId}/fulfillment-tree:
get:
summary: 查询父子订单与柜次数量摘要
parameters:
- { name: orderId, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: tenantId, in: query, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: 成功 }, '403': { description: 无权限 } }
/api/merchant/v1/orders/{orderId}/fulfillment-tree:
get:
summary: 查询我的父子订单与柜次数量摘要
parameters:
- { name: orderId, in: path, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: 成功 }, '403': { description: 无权限 } }
components:
securitySchemes:
bearerAuth:
@@ -15,9 +15,15 @@ export async function run(context) {
state: 'pending-public-contract',
assertions: [
'父单与自动剩余子单关系可由 HTTP 查询',
'一单多柜关系由 container aggregate 返回',
'一柜多单关系由 container aggregate 返回',
'已装数量 + 自动子单剩余数量 = 拆分前数量',
'同一订单可分配多个柜次且同柜可包含多个订单',
'子单仅允许一层且继续拆分被拒绝',
'同一 Idempotency-Key 与相同参数重放返回同一拆分结果',
'同一 Idempotency-Key 与不同参数被冲突拒绝且无部分写入',
'旧 expectedVersion 被冲突拒绝且父子订单数量不变',
'平台与客户 fulfillment-tree 均隔离其他客户数据',
'子订单继续拆分被拒绝,不产生嵌套层级',
'已消费 REMAINDER 再次消费被拒绝且数量不变',
],
},
}