This commit is contained in:
2026-08-10 12:40:47 +08:00
parent 7684f3cfec
commit fc554273b9
22 changed files with 322 additions and 56 deletions
+2 -2
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 和完整财务。
- 三期:当前 NO-GO;待 `../docs/三期/三期需求确认表.md` 的 P3 阻断项解除并锁定契约后,才扩展国内出仓、分柜、装柜、报关和 PDA 协作。
- 三期:已确认范围可按 `../docs/三期/三期开工门禁.md` 进入契约和模块实现。`firstleg` 负责履约编排、柜次、排柜、订舱、拖车、PDA 与 `LOADED` 门禁;`order`、`warehouse`、`file`、`iam`、`gateway` 和 `integration-service` 仅在各自契约需要时协作。未冻结的字段、权限、迁移或设备事项只阻断对应模块。
## 契约、数据库与验证入口
@@ -44,4 +44,4 @@
- 一期:`../docs/一期/README.md`、`../docs/一期/一期开发进度表.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
+1 -1
View File
@@ -29,4 +29,4 @@
- 一期:登录、注册、账号状态和 Token;先读 `../../docs/一期/README.md`。
- 二期:无独立登录流程,仅保障入库/计费沿用 JWT、应用域和客户隔离;先读 `../../docs/二期/README.md`。
- 三期:NO-GO,不新增出仓账号或角色;确认表解除阻断后再改权限。
- 三期:没有独立账号或角色模型;认证与会话仅在 IAM 三期权限契约需要时保持兼容。先读 `../../docs/三期/三期开工门禁.md`,不得借三期新增租户层级或绕过服务端授权。
+1 -1
View File
@@ -27,4 +27,4 @@
- 一期:资质上传与读取;先读 `../../docs/一期/README.md`。
- 二期:测量图片和 OCR 重试;先读 `../../docs/二期/README.md` 与第三方联调说明。
- 三期:NO-GO,不新增装柜/报关附件模型;确认后再定义证据生命周期。
- 三期:按稳定契约实现装柜五类证据和报关回执的存储、归属、鉴权与审计;先读 `../../docs/三期/三期开工门禁.md`。照片允许追加、错误记录仅作废不硬删除,客户不得读取照片或锁码。
+1 -1
View File
@@ -39,4 +39,4 @@
- 一期:履约任务与分配;先读 `../../docs/一期/README.md`。
- 二期:入库、测量、审核、计费闭环;先读 `../../docs/二期/README.md`、主流程和联调报告。
- 三期:NO-GO;待 P3 阻断解除后重新定义出仓状态机。
- 三期:负责订单履约编排、柜次、智能排柜、订舱、拖车、PDA 作业与 `LOADED` 门禁;先读 `../../docs/三期/三期开工门禁.md`。已确认范围按 Controller、DTO、OpenAPI、迁移和调用方共同实现;设备与真机事项只阻断相应验收,不得阻断已确认契约。
@@ -11,10 +11,67 @@ public interface LoadingInboundMapper {
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("""
SELECT batch.source_inbound_id AS sourceInboundId,
batch.loaded_child_inbound_id AS loadedChildInboundId,
batch.parent_inbound_id AS rootInboundId,
source.process_status AS sourceStatus, source.version AS sourceVersion,
child.process_status AS loadedChildStatus, child.version AS loadedChildVersion
FROM fl_inbound_split_batch batch
JOIN fl_inbound_order source ON source.id=batch.source_inbound_id
AND source.tenant_id=batch.tenant_id AND source.deleted=0
JOIN fl_inbound_order child ON child.id=batch.loaded_child_inbound_id
AND child.tenant_id=batch.tenant_id AND child.deleted=0
WHERE batch.tenant_id=#{tenantId} AND batch.status='APPLIED'
AND (batch.source_inbound_id=#{inboundId} OR batch.loaded_child_inbound_id=#{inboundId})
ORDER BY batch.id DESC LIMIT 1
""")
CompletionTarget completionTarget(@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='LOADED', updated_by=#{operatorId}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADING' AND version=#{expectedVersion}")
int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order child
JOIN fl_inbound_split_batch batch ON batch.loaded_child_inbound_id=child.id
AND batch.tenant_id=child.tenant_id AND batch.status='APPLIED'
JOIN fl_inbound_order source ON source.id=batch.source_inbound_id
AND source.tenant_id=batch.tenant_id AND source.deleted=0
SET child.process_status='LOADED', child.updated_by=#{operatorId}, child.version=child.version+1
WHERE child.tenant_id=#{tenantId} AND batch.source_inbound_id=#{sourceInboundId}
AND child.deleted=0 AND child.process_status='LOADING'
AND source.version=#{expectedSourceVersion}
""")
int completeLoadedChild(@Param("tenantId") long tenantId,
@Param("sourceInboundId") long sourceInboundId,
@Param("operatorId") long operatorId,
@Param("expectedSourceVersion") int expectedSourceVersion);
@Update("""
UPDATE fl_inbound_order root
SET root.process_status='LOADED', root.updated_by=#{operatorId}, root.version=root.version+1
WHERE root.tenant_id=#{tenantId} AND root.id=#{rootInboundId} AND root.deleted=0
AND root.inbound_role='MASTER' AND root.process_status IN ('INBOUNDED','LOADING')
AND NOT EXISTS (
SELECT 1 FROM fl_inbound_order remainder
WHERE remainder.tenant_id=root.tenant_id AND remainder.parent_inbound_id=root.id
AND remainder.inbound_role='CHILD' AND remainder.split_purpose='REMAINDER'
AND remainder.split_status='NOT_SPLIT' AND remainder.deleted=0)
AND NOT EXISTS (
SELECT 1 FROM fl_inbound_order loaded
WHERE loaded.tenant_id=root.tenant_id AND loaded.parent_inbound_id=root.id
AND loaded.inbound_role='CHILD' AND loaded.split_purpose='LOADED'
AND loaded.process_status<>'LOADED' AND loaded.deleted=0)
""")
int aggregateRootLoaded(@Param("tenantId") long tenantId,
@Param("rootInboundId") long rootInboundId,
@Param("operatorId") long operatorId);
record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {}
record CompletionTarget(Long sourceInboundId, Long loadedChildInboundId, Long rootInboundId,
String sourceStatus, Integer sourceVersion,
String loadedChildStatus, Integer loadedChildVersion) {}
}
@@ -29,6 +29,19 @@ public class LoadingRepository {
return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion);
}
public LoadingInboundMapper.CompletionTarget completionTarget(long tenantId, long inboundId) {
return inbounds.completionTarget(tenantId, inboundId);
}
public int completeLoadedChild(long tenantId, long sourceInboundId, long operatorId,
int expectedSourceVersion) {
return inbounds.completeLoadedChild(tenantId, sourceInboundId, operatorId, expectedSourceVersion);
}
public int aggregateRootLoaded(long tenantId, long rootInboundId, long operatorId) {
return inbounds.aggregateRootLoaded(tenantId, rootInboundId, operatorId);
}
public List<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
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.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -98,6 +99,10 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) {
return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key, request, target);
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
loadingModelBridge.requireCompleted(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
@@ -114,6 +119,30 @@ public class LoadingDeliveryService {
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
}
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
CompletionTarget target) {
if ("LOADED".equals(target.loadedChildStatus())) {
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
}
if (!"LOADING".equals(target.loadedChildStatus())) {
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
}
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
completionGate.requireBookingTransportAndCustoms(tenantId, target.sourceInboundId());
requireCompletedPicking(tenantId, target.sourceInboundId());
requireCompletionEvidence(tenantId, target.sourceInboundId());
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent();
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.loadedChildInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
}
public EvidenceListResponse evidence(long tenantId, long inboundId) {
requireLoadingStage(tenantId, inboundId);
return evidenceResponse(tenantId, inboundId);
@@ -274,6 +303,8 @@ public class LoadingDeliveryService {
private Snapshot requireLoadingStage(long tenantId, long inboundId) {
Snapshot value = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null && Set.of("LOADING", "LOADED").contains(target.loadedChildStatus())) return value;
if (!("LOADING".equals(value.processStatus()) || "LOADED".equals(value.processStatus())
|| "DELIVERED".equals(value.processStatus()))) {
throw new BizException(ResultCode.CONFLICT, "订单尚未进入装柜阶段");
@@ -285,6 +316,13 @@ public class LoadingDeliveryService {
* corrections are handled by the offline/manual process defined for phase three. */
private Snapshot requireMutableLoadingStage(long tenantId, long inboundId) {
Snapshot value = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) {
if (!"LOADING".equals(target.loadedChildStatus())) {
throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据");
}
return value;
}
if (!"LOADING".equals(value.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据");
}
@@ -44,7 +44,8 @@ public interface InboundSplitSettlementMapper {
actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,express_company_name,
express_tracking_no,public_remark,created_by,created_by_name,created_at,updated_by,updated_at,deleted,version,
inbound_role,parent_inbound_id,root_inbound_id,split_sequence,split_status,split_purpose)
SELECT #{childId},tenant_id,task_id,order_id,order_no,#{childInboundCode},'INBOUNDED',order_status_snapshot,
SELECT #{childId},tenant_id,task_id,order_id,order_no,#{childInboundCode},
CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END,order_status_snapshot,
'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name,
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,
channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection,
@@ -153,7 +153,7 @@ public class UnifiedInboundQueryService {
: "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "LOADING" -> "COMPLETE_LOADING";
case "LOADED" -> "COMPLETE_DELIVERY";
case "LOADED" -> null;
case "DELIVERED" -> null;
default -> null;
};
@@ -160,13 +160,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID',
source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID',
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id, split_batch_id, source_measurement_item_id, child_inbound_id),
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
@@ -232,6 +232,9 @@ CALL phase3_inbound_add_column(
CALL phase3_inbound_add_column(
'fl_inbound_split_item', 'line_no',
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
ALTER TABLE fl_inbound_split_item
MODIFY source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID',
MODIFY source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID';
CALL phase3_inbound_add_column(
'fl_inbound_split_batch', 'loaded_child_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '本批次实装子单ID' AFTER package_count");
@@ -339,6 +342,9 @@ CALL phase3_inbound_add_index(
CALL phase3_inbound_add_index(
'fl_inbound_split_batch', 'idx_inbound_split_loading_plan',
'KEY `idx_inbound_split_loading_plan` (`tenant_id`, `loading_plan_id`, `container_booking_id`, `status`, `id`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_batch', 'uk_inbound_split_source_once',
'UNIQUE KEY `uk_inbound_split_source_once` (`tenant_id`, `source_inbound_id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DELIMITER $$
@@ -395,6 +401,9 @@ BEGIN
IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单';
END IF;
IF NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单用途必须为MASTER';
END IF;
IF NEW.inbound_role = 'CHILD' THEN
IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL
OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
@@ -419,7 +428,8 @@ CREATE TRIGGER trg_inbound_order_parent_before_update
BEFORE UPDATE ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NOT (OLD.inbound_role <=> NEW.inbound_role)
IF NOT (OLD.tenant_id <=> NEW.tenant_id)
OR NOT (OLD.inbound_role <=> NEW.inbound_role)
OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id)
OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id)
OR NOT (OLD.split_sequence <=> NEW.split_sequence)
@@ -436,8 +446,9 @@ BEGIN
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
END IF;
IF NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单用途非法';
IF (NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER')
OR (NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级与用途不一致';
END IF;
IF NEW.inbound_role = 'MASTER'
AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT')
@@ -450,6 +461,9 @@ CREATE TRIGGER trg_inbound_split_item_before_insert
BEFORE INSERT ON fl_inbound_split_item
FOR EACH ROW
BEGIN
IF NEW.source_measurement_id IS NULL OR NEW.source_measurement_item_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细必须绑定真实测量来源';
END IF;
IF NOT EXISTS (
SELECT 1
FROM fl_inbound_split_batch batch
@@ -467,7 +481,7 @@ BEGIN
AND child_order.parent_inbound_id = NEW.parent_inbound_id
AND child_order.package_count >= NEW.package_count
AND child_order.deleted = 0
AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
AND EXISTS (
SELECT 1
FROM fl_inbound_measurement_item source_item
JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id
@@ -475,7 +489,7 @@ BEGIN
AND source_item.tenant_id = NEW.tenant_id
AND source_item.measurement_id = NEW.source_measurement_id
AND source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)
))
)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
END IF;
@@ -497,23 +511,32 @@ BEGIN
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致';
END IF;
IF NEW.source_inbound_id IS NOT NULL AND NOT EXISTS (
IF NEW.source_inbound_id IS NULL OR NOT EXISTS (
SELECT 1 FROM fl_inbound_order source_order
WHERE source_order.id = NEW.source_inbound_id
AND source_order.tenant_id = NEW.tenant_id
AND source_order.deleted = 0
AND (source_order.id = NEW.parent_inbound_id OR
AND source_order.package_count = NEW.package_count
AND ((source_order.id = NEW.parent_inbound_id
AND source_order.inbound_role = 'MASTER'
AND source_order.split_status = 'NOT_SPLIT') OR
(source_order.inbound_role = 'CHILD'
AND source_order.parent_inbound_id = NEW.parent_inbound_id
AND source_order.split_purpose = 'REMAINDER'))
AND source_order.split_purpose = 'REMAINDER'
AND source_order.split_status = 'NOT_SPLIT'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是根主单或其余量子单';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是箱数匹配且未消费的根主单或余量子单';
END IF;
IF NEW.loaded_package_count > 0 AND (
NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_plan plan
WHERE plan.id = NEW.loading_plan_id
AND plan.container_booking_id = NEW.container_booking_id
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
WHERE plan_item.loading_plan_id = plan.id
AND plan_item.account_id = NEW.tenant_id
AND plan_item.inbound_id = NEW.source_inbound_id
AND plan_item.deleted = 0)
)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次';
@@ -544,13 +567,23 @@ BEGIN
NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_plan plan
WHERE plan.id = NEW.loading_plan_id
AND plan.container_booking_id = NEW.container_booking_id)))
AND plan.container_booking_id = NEW.container_booking_id
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
WHERE plan_item.loading_plan_id = plan.id
AND plan_item.account_id = NEW.tenant_id
AND plan_item.inbound_id = NEW.source_inbound_id
AND plan_item.deleted = 0)))
OR NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.version > NEW.expected_parent_version
) OR NOT EXISTS (
SELECT 1 FROM fl_inbound_order source_order
WHERE source_order.id = NEW.source_inbound_id
AND source_order.tenant_id = NEW.tenant_id
AND source_order.split_status = 'FULLY_SPLIT'
) OR
(SELECT COALESCE(SUM(item.package_count), 0)
FROM fl_inbound_split_item item
@@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_detail (
source_package_count > 1 AND locked_package_count > 0
AND locked_package_count < source_package_count
),
CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')),
CONSTRAINT chk_legacy_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')),
CONSTRAINT chk_inbound_split_completion CHECK (
(status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL)
OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL)
@@ -143,39 +143,14 @@ CREATE TRIGGER trg_inbound_split_detail_before_insert
BEFORE INSERT ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
DECLARE v_parent_tenant BIGINT UNSIGNED;
DECLARE v_parent_parent BIGINT UNSIGNED;
SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
IF v_parent_tenant <> NEW.tenant_id OR v_parent_parent IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只能发生在客户所属主入库单';
END IF;
IF NEW.status <> 'LOCKED' OR NEW.child_inbound_id IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定创建时不能提前绑定二级子入库单';
END IF;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧部分锁定台账已退役,请使用主子分片批次';
END$$
CREATE TRIGGER trg_inbound_split_detail_before_update
BEFORE UPDATE ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
DECLARE v_child_tenant BIGINT UNSIGNED;
DECLARE v_child_parent BIGINT UNSIGNED;
IF NEW.tenant_id <> OLD.tenant_id OR NEW.parent_inbound_id <> OLD.parent_inbound_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id
OR NEW.source_package_count <> OLD.source_package_count
OR NEW.locked_package_count <> OLD.locked_package_count
OR NEW.operation_id <> OLD.operation_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定的归属和箱数创建后不可修改';
END IF;
IF OLD.status <> 'LOCKED' OR NEW.status <> 'COMPLETED' OR NEW.child_inbound_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只允许完成并绑定二级子入库单';
END IF;
SELECT tenant_id, parent_inbound_id INTO v_child_tenant, v_child_parent
FROM fl_inbound_order WHERE id = NEW.child_inbound_id AND deleted = 0;
IF v_child_tenant <> NEW.tenant_id OR v_child_parent <> NEW.parent_inbound_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数必须绑定主单唯一二级子入库单';
END IF;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧部分锁定台账只读,不允许更新';
END$$
CREATE TRIGGER trg_inbound_split_detail_before_delete
@@ -52,8 +52,7 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null))
.isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null))
.isEqualTo("COMPLETE_DELIVERY");
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
}
+2 -2
View File
@@ -16,5 +16,5 @@
## 修改约束与分期
- 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。
- 一期承载登录后 Admin/Client 入口;二期接入到仓/测量/计费公开路由但保持内部测量接口隔离;三期当前 NO-GO,不开放分柜、订舱、PDA、装柜、报关或港务路由。
- 三期先读 `../../docs/三期/三期需求确认表.md`,解除阻断并冻结契约后再改网关。
- 一期承载登录后 Admin/Client 入口;二期接入到仓/测量/计费公开路由但保持内部测量接口隔离;三期仅在 Controller、权限和公开访问域契约稳定后新增必要路由,内部 PDA、仓储和服务令牌接口继续不得经 Gateway 暴露。
- 三期先读 `../../docs/三期/三期开工门禁.md`;网关变更只服务已确认模块,不以路由提前暴露未完成能力。
+1 -1
View File
@@ -30,4 +30,4 @@
- 一期:客户建档审核、平台用户与 RBAC、企业资料;先读 `../../docs/一期/README.md`。
- 二期:仅提供客户资格/授权快照,不承载入库或计费状态;先读 `../../docs/二期/README.md`。
- 三期:NO-GO,不提前增加出仓角色/PDA 权限;先读 `../../docs/三期/三期需求确认表.md`。
- 三期:按最小权限为操作员、拣货员、理货员落地已确认的角色与权限;先读 `../../docs/三期/三期开工门禁.md`。具体权限码与授权快照须先随服务端契约冻结,不自行扩展租户层级或客户可见范围。
+1 -1
View File
@@ -18,4 +18,4 @@
- 密钥、原始响应和客户敏感数据不得写入日志;真实调用默认关闭,适配器负责超时、重试、限流、错误归一化。
- OCR 只能访问受控输入/输出目录,接口仅接受内部服务令牌;契约变更同步检查 File 的 `WeightOcrClient` 和测试。
- 一期仅保留通用适配基础;二期服务 File 重量 OCR,凭证缺失时走人工修正降级;三期当前 NO-GO,不提前接入报关、船期或港务接口。阶段文档:`../../docs/二期/README.md`、`../../docs/三期/三期需求确认表.md`。
- 一期仅保留通用适配基础;二期服务 File 重量 OCR,凭证缺失时走人工修正降级;三期仅在船期来源、频率、限流、超时、最近有效结果和过期标识的契约稳定后接入外部查询,不建设报关自动对接。阶段文档:`../../docs/二期/README.md`、`../../docs/三期/三期开工门禁.md`、`../../docs/三期/三期需求确认表.md`。
+1 -1
View File
@@ -27,4 +27,4 @@
- 一期:订单下单与交易状态闭环;先读 `../../docs/一期/README.md` 与闭环设计。
- 二期:仅提供货物内部查询,不承载入库状态;先读 `../../docs/二期/README.md`。
- 三期:NO-GO;待确认后再设计分柜/出仓状态扩展。
- 三期:在 `firstleg` 编排契约稳定后,负责父子订单、数量守恒、订单主状态、幂等和时间线所需的主数据扩展;先读 `../../docs/三期/三期开工门禁.md`。不得自行创建独立出仓单或绕过 `LOADED` 服务端门禁。
@@ -0,0 +1,40 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderSplitDtos;
import com.baiyue.order.service.OrderFulfillmentSplitService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Firstleg 装柜结算调用的订单域内部契约。 */
@RestController
@RequestMapping("/internal/order/v1/fulfillment-splits")
public class InternalOrderFulfillmentSplitController {
@Resource private OrderFulfillmentSplitService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@PostConstruct void initializeToken(){expectedToken=internalToken.getBytes(StandardCharsets.UTF_8);}
@PostMapping
public ApiResponse<OrderSplitDtos.Response> create(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Operator-Id",defaultValue="0") long operatorId,
@Valid @RequestBody OrderSplitDtos.CreateRequest request) {
requireToken(token); return ApiResponse.success(service.split(request,operatorId));
}
@GetMapping("/{tenantId}/{parentOrderId}")
public ApiResponse<OrderSplitDtos.Tree> tree(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@PathVariable long tenantId,@PathVariable long parentOrderId){
requireToken(token); return ApiResponse.success(service.tree(tenantId,parentOrderId));
}
private void requireToken(String token){if(token==null||!MessageDigest.isEqual(expectedToken,token.getBytes(StandardCharsets.UTF_8)))throw new BizException(ResultCode.FORBIDDEN,"内部服务凭证无效");}
}
@@ -7,6 +7,8 @@ import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.UnifiedOrderQueryService;
import com.baiyue.order.service.OrderFulfillmentSplitService;
import com.baiyue.order.dto.OrderSplitDtos;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/merchant/v1/orders")
public class MerchantUnifiedOrderController {
@Resource private UnifiedOrderQueryService service;
@Resource private OrderFulfillmentSplitService splitService;
@GetMapping
@PreAuthorize("hasAuthority('merchant:order:list')")
@@ -42,4 +45,10 @@ public class MerchantUnifiedOrderController {
@PathVariable long orderId) {
return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false));
}
@GetMapping("/{orderId}/fulfillment-tree")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "我的父子订单与柜次数量摘要", description = "不返回装柜照片、锁码或内部审计字段。")
public ApiResponse<OrderSplitDtos.Tree> fulfillmentTree(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId){return ApiResponse.success(splitService.tree(OrderScopeGuard.merchant(user),orderId));}
}
@@ -10,6 +10,8 @@ import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.UnifiedOrderQueryService;
import com.baiyue.order.service.OrderFulfillmentSplitService;
import com.baiyue.order.dto.OrderSplitDtos;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -31,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformUnifiedOrderController {
@Resource private UnifiedOrderQueryService service;
@Resource private OrderApplicationService orderService;
@Resource private OrderFulfillmentSplitService splitService;
@GetMapping
@PreAuthorize("hasAuthority('platform:order:list')")
@@ -54,6 +57,14 @@ public class PlatformUnifiedOrderController {
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true));
}
@GetMapping("/{orderId}/fulfillment-tree")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "父子订单与柜次数量摘要")
public ApiResponse<OrderSplitDtos.Tree> fulfillmentTree(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,@PathVariable long orderId){
return ApiResponse.success(splitService.tree(OrderScopeGuard.platform(user,tenantId),orderId));
}
@PostMapping("/{orderId}/internal-notes")
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "Add internal note / 添加内部备注")
@PreAuthorize("hasAuthority('platform:order:note')")
@@ -8,12 +8,16 @@ import org.apache.ibatis.annotations.Select;
import com.baiyue.order.entity.OrderEntity;
import java.util.List;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OrderFulfillmentSplitMapper extends BaseMapper<OrderFulfillmentSplitEntity> {
@Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0 FOR UPDATE")
OrderEntity lockOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0")
OrderEntity findOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Select("SELECT * FROM ord_order_fulfillment_split WHERE tenant_id=#{tenantId} AND parent_order_id=#{parentOrderId} AND idempotency_key=#{key} ORDER BY id")
List<OrderFulfillmentSplitEntity> findByIdempotency(@Param("tenantId") long tenantId,
@Param("parentOrderId") long parentOrderId,
@@ -22,4 +26,7 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper<OrderFulfillment
@Select("SELECT * FROM ord_order_fulfillment_split WHERE tenant_id=#{tenantId} AND parent_order_id=#{parentOrderId} ORDER BY created_at,id")
List<OrderFulfillmentSplitEntity> findByParent(@Param("tenantId") long tenantId,
@Param("parentOrderId") long parentOrderId);
@Select("SELECT COALESCE(MAX(split_sequence),0) FROM ord_order WHERE tenant_id=#{tenantId} AND (id=#{parentOrderId} OR parent_order_id=#{parentOrderId}) AND deleted=0")
Integer maxSplitSequence(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId);
}
@@ -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.dto.OrderSplitDtos;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.mapper.OrderFulfillmentSplitMapper;
import com.baiyue.order.mapper.OrderMapper;
import com.baiyue.order.entity.OrderFulfillmentSplitEntity;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
/** 订单域父子单/柜次数量契约。仅生成一层 CHILD,不复制原订单身份。 */
@Service
public class OrderFulfillmentSplitService {
@Resource private OrderFulfillmentSplitMapper splits;
@Resource private OrderMapper orders;
@Resource private ObjectMapper json;
@Transactional
public OrderSplitDtos.Response split(OrderSplitDtos.CreateRequest req, long operatorId) {
validate(req);
String hash = hash(req);
List<OrderFulfillmentSplitEntity> replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
if (!replay.isEmpty()) {
if (replay.stream().anyMatch(x -> x.getRequestHash()!=null && !hash.equals(x.getRequestHash())))
throw new BizException(ResultCode.CONFLICT, "幂等键对应的请求内容不一致");
return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay);
}
OrderEntity parent = splits.lockOrder(req.tenantId(), req.parentOrderId());
if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "父订单不存在或不属于该客户");
if (!req.parentOrderId().equals(req.sourceOrderId()) && !isDirectChild(req.sourceOrderId(), parent))
throw new BizException(ResultCode.CONFLICT, "来源订单必须是父订单或其直接子订单");
if (!"MASTER".equals(parent.getOrderRole())) throw new BizException(ResultCode.CONFLICT, "子订单不能继续拆分");
if (parent.getVersion()!=null && !req.expectedParentVersion().equals(parent.getVersion()))
throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试");
int next = Optional.ofNullable(splits.maxSplitSequence(req.tenantId(), req.parentOrderId())).orElse(0) + 1;
List<OrderFulfillmentSplitEntity> rows = new ArrayList<>();
OrderSplitDtos.Child loaded = createChild(req, parent, next++, "LOADED", req.loadedPackageCount(), hash, operatorId, rows);
OrderSplitDtos.Child remainder = req.remainingPackageCount() > 0
? createChild(req, parent, next, "REMAINDER", req.remainingPackageCount(), hash, operatorId, rows) : null;
for (OrderFulfillmentSplitEntity row : rows) splits.insert(row);
parent.setVersion((parent.getVersion()==null?0:parent.getVersion()) + 1);
LambdaUpdateWrapper<OrderEntity> guard = new LambdaUpdateWrapper<OrderEntity>()
.eq(OrderEntity::getId, parent.getId())
.eq(OrderEntity::getTenantId, parent.getTenantId())
.eq(OrderEntity::getVersion, req.expectedParentVersion());
int updated = orders.update(parent, guard);
if (updated != 1) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试");
return new OrderSplitDtos.Response(parent.getId(), parent.getOrderNo(), parent.getVersion(), loaded, remainder,
req.sourcePackageCount(), req.loadedPackageCount(), req.remainingPackageCount(), req.loadingPlanId(), req.containerBookingId(), req.idempotencyKey(), LocalDateTime.now());
}
public OrderSplitDtos.Tree tree(long tenantId, long parentId) {
OrderEntity parent = splits.findOrder(tenantId, parentId);
if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
if (!"MASTER".equals(parent.getOrderRole())) parentId = Optional.ofNullable(parent.getParentOrderId()).orElse(parentId);
parent = splits.findOrder(tenantId, parentId);
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();
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));
}
private boolean isDirectChild(long source, OrderEntity parent) { OrderEntity c=orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<OrderEntity>().eq(OrderEntity::getId,source).eq(OrderEntity::getTenantId,parent.getTenantId())); return c!=null && "CHILD".equals(c.getOrderRole()) && parent.getId().equals(c.getParentOrderId()); }
private OrderSplitDtos.Child createChild(OrderSplitDtos.CreateRequest r, OrderEntity p, int seq, String role, int count, String hash, long op, List<OrderFulfillmentSplitEntity> rows) {
OrderEntity c = new OrderEntity(); c.setId(IdWorker.getId()); c.setTenantId(p.getTenantId()); c.setOrderNo(p.getOrderNo()+"-"+seq); c.setOrderRole("CHILD"); c.setParentOrderId(p.getId()); c.setRootOrderId(p.getId()); c.setSplitSequence(seq); c.setStatus(p.getStatus()); c.setCustomerId(p.getCustomerId()); c.setCustomerName(p.getCustomerName()); c.setTransportMode(p.getTransportMode()); c.setTradeTerm(p.getTradeTerm()); c.setDestinationCountryCode(p.getDestinationCountryCode()); c.setDestinationWarehouseId(p.getDestinationWarehouseId()); c.setDestinationWarehouseName(p.getDestinationWarehouseName()); c.setReceivingWarehouseId(p.getReceivingWarehouseId()); c.setReceivingWarehouseName(p.getReceivingWarehouseName()); c.setChannelCode(p.getChannelCode()); c.setChannelName(p.getChannelName()); c.setDestinationType(p.getDestinationType()); c.setCreatedBy(op); c.setUpdatedBy(op); orders.insert(c);
OrderFulfillmentSplitEntity row=new OrderFulfillmentSplitEntity(); row.setId(IdWorker.getId()); row.setTenantId(r.tenantId()); row.setParentOrderId(p.getId()); row.setChildOrderId(c.getId()); row.setLoadingPlanId(r.loadingPlanId()); row.setContainerBookingId(r.containerBookingId()); row.setSourcePackageCount(r.sourcePackageCount()); row.setLoadedPackageCount(r.loadedPackageCount()); row.setRemainingPackageCount(r.remainingPackageCount()); row.setOperationId(Optional.ofNullable(r.operationId()).orElse("split-"+r.idempotencyKey())); row.setIdempotencyKey(r.idempotencyKey()); row.setRequestHash(hash); row.setSplitRole(role); row.setExpectedParentVersion(r.expectedParentVersion()); row.setCreatedBy(op); row.setCreatedByName(r.operatorName()); row.setCreatedAt(LocalDateTime.now()); rows.add(row); return new OrderSplitDtos.Child(c.getId(), c.getOrderNo(), seq, role, count);
}
private void validate(OrderSplitDtos.CreateRequest r) { if(r==null||r.tenantId()==null||r.parentOrderId()==null||r.sourceOrderId()==null||r.loadingPlanId()==null||r.containerBookingId()==null||r.sourcePackageCount()==null||r.loadedPackageCount()==null||r.remainingPackageCount()==null||r.expectedParentVersion()==null||r.idempotencyKey()==null||r.idempotencyKey().isBlank()) throw new BizException(ResultCode.BAD_REQUEST,"拆分请求字段不完整"); if(r.loadedPackageCount()<1||r.remainingPackageCount()<0||r.loadedPackageCount()+r.remainingPackageCount()!=r.sourcePackageCount()) throw new BizException(ResultCode.VALIDATION_FAILED,"装柜数量与剩余数量必须守恒"); }
private String hash(Object o) { try { byte[] b=json.writeValueAsString(o).getBytes(StandardCharsets.UTF_8); byte[] d=MessageDigest.getInstance("SHA-256").digest(b); StringBuilder s=new StringBuilder(); for(byte x:d)s.append(String.format("%02x",x)); return s.toString(); } catch(Exception e){throw new BizException("无法生成请求指纹");} }
private OrderSplitDtos.Response response(OrderSplitDtos.CreateRequest r, OrderEntity p, List<OrderFulfillmentSplitEntity> rows) { OrderSplitDtos.Child l=null,m=null; for(var x:rows){OrderEntity c=orders.selectById(x.getChildOrderId()); var z=new OrderSplitDtos.Child(x.getChildOrderId(),c==null?null:c.getOrderNo(),c==null?null:c.getSplitSequence(),x.getSplitRole(),"LOADED".equals(x.getSplitRole())?x.getLoadedPackageCount():x.getRemainingPackageCount()); if("LOADED".equals(x.getSplitRole()))l=z;else m=z;} return new OrderSplitDtos.Response(p.getId(),p.getOrderNo(),p.getVersion(),l,m,r.sourcePackageCount(),r.loadedPackageCount(),r.remainingPackageCount(),r.loadingPlanId(),r.containerBookingId(),r.idempotencyKey(),rows.get(0).getCreatedAt()); }
}
+1 -1
View File
@@ -37,4 +37,4 @@
- 一期:公共仓与主数据;先读 `../../docs/一期/README.md`。
- 二期:托盘/库位预占占用和海外仓费用;先读 `../../docs/二期/README.md` 与主流程。
- 三期:NO-GO;待确认后再扩展出仓资源。
- 三期:仅为 PDA 拣货与资源结算提供实时库存、当前位置、可用量和原子结算能力;先读 `../../docs/三期/三期开工门禁.md`。不扩展完整 WMS,且设备真机事项不改变服务端资源契约。