优化
This commit is contained in:
@@ -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
@@ -29,4 +29,4 @@
|
||||
|
||||
- 一期:登录、注册、账号状态和 Token;先读 `../../docs/一期/README.md`。
|
||||
- 二期:无独立登录流程,仅保障入库/计费沿用 JWT、应用域和客户隔离;先读 `../../docs/二期/README.md`。
|
||||
- 三期:NO-GO,不新增出仓账号或角色;确认表解除阻断后再改权限。
|
||||
- 三期:没有独立账号或角色模型;认证与会话仅在 IAM 三期权限契约需要时保持兼容。先读 `../../docs/三期/三期开工门禁.md`,不得借三期新增租户层级或绕过服务端授权。
|
||||
|
||||
+1
-1
@@ -27,4 +27,4 @@
|
||||
|
||||
- 一期:资质上传与读取;先读 `../../docs/一期/README.md`。
|
||||
- 二期:测量图片和 OCR 重试;先读 `../../docs/二期/README.md` 与第三方联调说明。
|
||||
- 三期:NO-GO,不新增装柜/报关附件模型;确认后再定义证据生命周期。
|
||||
- 三期:按稳定契约实现装柜五类证据和报关回执的存储、归属、鉴权与审计;先读 `../../docs/三期/三期开工门禁.md`。照片允许追加、错误记录仅作废不硬删除,客户不得读取照片或锁码。
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+38
@@ -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
|
||||
|
||||
+1
-2
@@ -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
@@ -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
@@ -30,4 +30,4 @@
|
||||
|
||||
- 一期:客户建档审核、平台用户与 RBAC、企业资料;先读 `../../docs/一期/README.md`。
|
||||
- 二期:仅提供客户资格/授权快照,不承载入库或计费状态;先读 `../../docs/二期/README.md`。
|
||||
- 三期:NO-GO,不提前增加出仓角色/PDA 权限;先读 `../../docs/三期/三期需求确认表.md`。
|
||||
- 三期:按最小权限为操作员、拣货员、理货员落地已确认的角色与权限;先读 `../../docs/三期/三期开工门禁.md`。具体权限码与授权快照须先随服务端契约冻结,不自行扩展租户层级或客户可见范围。
|
||||
|
||||
@@ -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
@@ -27,4 +27,4 @@
|
||||
|
||||
- 一期:订单下单与交易状态闭环;先读 `../../docs/一期/README.md` 与闭环设计。
|
||||
- 二期:仅提供货物内部查询,不承载入库状态;先读 `../../docs/二期/README.md`。
|
||||
- 三期:NO-GO;待确认后再设计分柜/出仓状态扩展。
|
||||
- 三期:在 `firstleg` 编排契约稳定后,负责父子订单、数量守恒、订单主状态、幂等和时间线所需的主数据扩展;先读 `../../docs/三期/三期开工门禁.md`。不得自行创建独立出仓单或绕过 `LOADED` 服务端门禁。
|
||||
|
||||
+40
@@ -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
@@ -37,4 +37,4 @@
|
||||
|
||||
- 一期:公共仓与主数据;先读 `../../docs/一期/README.md`。
|
||||
- 二期:托盘/库位预占占用和海外仓费用;先读 `../../docs/二期/README.md` 与主流程。
|
||||
- 三期:NO-GO;待确认后再扩展出仓资源。
|
||||
- 三期:仅为 PDA 拣货与资源结算提供实时库存、当前位置、可用量和原子结算能力;先读 `../../docs/三期/三期开工门禁.md`。不扩展完整 WMS,且设备真机事项不改变服务端资源契约。
|
||||
|
||||
Reference in New Issue
Block a user