Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	order/AGENTS.md
#	scripts/agents-index-contract.ps1
This commit is contained in:
2026-09-02 12:08:08 +08:00
18 changed files with 689 additions and 3 deletions
@@ -11,6 +11,8 @@ import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.InboundCartonLabelService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.service.OrderDeclarationSyncService;
import com.baiyue.firstleg.dto.OrderDeclarationSync;
import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.UnifiedInboundRequest;
@@ -39,6 +41,8 @@ public class InternalInboundOrderController {
private InboundCartonLabelService cartonLabels;
@Resource
private UnifiedInboundQueryService unifiedQueryService;
@Resource
private OrderDeclarationSyncService declarationSync;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@@ -106,6 +110,23 @@ public class InternalInboundOrderController {
request.tenantId(), request.orderIds().stream().distinct().toList()));
}
@PostMapping("/order-channel-changes/validate")
public ApiResponse<OrderDeclarationSync.ChannelChangeValidationResponse> validateOrderChannelChange(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody OrderDeclarationSync.ChannelChangeValidationRequest request) {
requireToken(token);
return ApiResponse.success(declarationSync.validateChannelChange(request));
}
@PostMapping("/order-declaration-updated")
public ApiResponse<Void> updateOrderDeclaration(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody OrderDeclarationSync.UpdatedEvent event) {
requireToken(token);
declarationSync.consume(event);
return ApiResponse.success(null);
}
@PostMapping("/unified-orders/query")
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** Order -> Firstleg 客户申报版本事件及审核后渠道履约门禁契约。 */
public final class OrderDeclarationSync {
private OrderDeclarationSync() {}
public record ChannelChangeValidationRequest(long tenantId, long orderId, long orderVersion,
String channelCode) {}
public record ChannelChangeValidationResponse(long orderId, long orderVersion,
String channelCode, String mainStatus) {}
public record Cargo(Integer itemNo, String cargoName, String cargoNameEn, String skuNo, String model,
Integer packageCount, Integer totalQuantity, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm,
BigDecimal netWeightKg, String hsCode, String customsHsCode, Integer unitsPerPackage,
BigDecimal unitDeclaredValue, BigDecimal declaredValue, String currency,
String material, String usage, String brand, String cargoRemark) {}
public record FieldChange(Integer itemNo, String field, Object beforeValue, Object afterValue) {}
public record UpdatedEvent(int schemaVersion, String eventId, long tenantId, long orderId,
String orderNo, String inboundCode, int version, long operatorId,
LocalDateTime occurredAt, String customerRemark,
LocalDateTime expectedArrivalAt, String channelCode, String channelName,
boolean channelChanged, List<Cargo> cargoes, List<FieldChange> changes) {}
}
@@ -0,0 +1,116 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper
public interface OrderDeclarationSyncMapper {
@Select("""
SELECT inbound.id inboundId,inbound.order_id orderId,inbound.order_no orderNo,
inbound.inbound_code inboundCode,inbound.process_status processStatus,
inbound.channel_code channelCode,
(SELECT COUNT(*) FROM fl_coload_group_member member
JOIN fl_coload_group group_row ON group_row.id=member.group_id
WHERE member.tenant_id=inbound.tenant_id AND member.inbound_id=inbound.id
AND member.active_flag=1 AND group_row.group_status IN ('ACTIVE','LOCKED')
AND UPPER(group_row.channel_code)<>UPPER(#{newChannelCode})) incompatibleGroupCount,
(SELECT COUNT(*) FROM fl_inbound_split_batch batch
WHERE batch.tenant_id=inbound.tenant_id AND batch.source_inbound_id=inbound.id
AND batch.status='APPLIED') splitCount,
(SELECT COUNT(*) FROM fl_loading_plan_item item
WHERE item.account_id=inbound.tenant_id AND item.inbound_id=inbound.id AND item.deleted=0) preloadCount,
(SELECT COUNT(*) FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id
WHERE plan_item.account_id=inbound.tenant_id AND plan_item.inbound_id=inbound.id) pdaCount
FROM fl_inbound_order inbound
WHERE inbound.tenant_id=#{tenantId} AND inbound.order_id=#{orderId} AND inbound.deleted=0
ORDER BY inbound.created_at DESC,inbound.id DESC LIMIT 1
""")
GateRow gate(@Param("tenantId") long tenantId, @Param("orderId") long orderId,
@Param("newChannelCode") String newChannelCode);
@Select("""
SELECT inbound.id inboundId,inbound.order_id orderId,inbound.order_no orderNo,
inbound.inbound_code inboundCode,inbound.process_status processStatus,inbound.channel_code channelCode
FROM fl_inbound_order inbound
WHERE inbound.tenant_id=#{tenantId} AND inbound.order_id=#{orderId} AND inbound.deleted=0
ORDER BY inbound.created_at DESC,inbound.id DESC LIMIT 1 FOR UPDATE
""")
InboundRow lockInbound(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Select("SELECT tenant_id tenantId,order_id orderId,event_id eventId,order_version orderVersion,request_hash requestHash "
+ "FROM fl_order_declaration_snapshot WHERE tenant_id=#{tenantId} AND order_id=#{orderId} FOR UPDATE")
SnapshotRow lockSnapshot(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Select("SELECT tenant_id tenantId,order_id orderId,event_id eventId,order_version orderVersion,request_hash requestHash "
+ "FROM fl_order_declaration_event WHERE tenant_id=#{tenantId} AND event_id=#{eventId}")
SnapshotRow byEvent(@Param("tenantId") long tenantId, @Param("eventId") String eventId);
@Insert("INSERT INTO fl_order_declaration_event "
+ "(tenant_id,event_id,order_id,order_version,request_hash,consumed_at) VALUES "
+ "(#{tenantId},#{eventId},#{orderId},#{orderVersion},#{requestHash},CURRENT_TIMESTAMP(3))")
int insertConsumedEvent(@Param("tenantId") long tenantId, @Param("eventId") String eventId,
@Param("orderId") long orderId, @Param("orderVersion") int orderVersion,
@Param("requestHash") String requestHash);
@Insert("""
INSERT INTO fl_order_declaration_snapshot
(tenant_id,order_id,event_id,order_version,request_hash,customer_remark,expected_arrival_at,
channel_code,channel_name,cargoes_json,changes_json,occurred_at,created_at,updated_at)
VALUES
(#{tenantId},#{orderId},#{eventId},#{orderVersion},#{requestHash},#{customerRemark},#{expectedArrivalAt},
#{channelCode},#{channelName},#{cargoesJson},#{changesJson},#{occurredAt},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertSnapshot(@Param("tenantId") long tenantId, @Param("orderId") long orderId,
@Param("eventId") String eventId, @Param("orderVersion") int orderVersion,
@Param("requestHash") String requestHash, @Param("customerRemark") String customerRemark,
@Param("expectedArrivalAt") LocalDateTime expectedArrivalAt,
@Param("channelCode") String channelCode, @Param("channelName") String channelName,
@Param("cargoesJson") String cargoesJson, @Param("changesJson") String changesJson,
@Param("occurredAt") LocalDateTime occurredAt);
@Update("""
UPDATE fl_order_declaration_snapshot
SET event_id=#{eventId},order_version=#{nextVersion},request_hash=#{requestHash},
customer_remark=#{customerRemark},expected_arrival_at=#{expectedArrivalAt},
channel_code=#{channelCode},channel_name=#{channelName},cargoes_json=#{cargoesJson},
changes_json=#{changesJson},occurred_at=#{occurredAt},updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND order_version=#{currentVersion}
""")
int advanceSnapshot(@Param("tenantId") long tenantId, @Param("orderId") long orderId,
@Param("currentVersion") int currentVersion, @Param("nextVersion") int nextVersion,
@Param("eventId") String eventId, @Param("requestHash") String requestHash,
@Param("customerRemark") String customerRemark,
@Param("expectedArrivalAt") LocalDateTime expectedArrivalAt,
@Param("channelCode") String channelCode, @Param("channelName") String channelName,
@Param("cargoesJson") String cargoesJson, @Param("changesJson") String changesJson,
@Param("occurredAt") LocalDateTime occurredAt);
@Update("""
UPDATE fl_inbound_order
SET public_remark=#{customerRemark},expected_arrival_at=#{expectedArrivalAt},
channel_code=#{channelCode},channel_name=#{channelName},updated_by=#{operatorId},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0
AND order_id=#{orderId} AND order_no=#{orderNo} AND inbound_code=#{inboundCode}
""")
int updateAllowedSnapshot(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("orderId") long orderId, @Param("orderNo") String orderNo,
@Param("inboundCode") String inboundCode,
@Param("customerRemark") String customerRemark,
@Param("expectedArrivalAt") LocalDateTime expectedArrivalAt,
@Param("channelCode") String channelCode, @Param("channelName") String channelName,
@Param("operatorId") long operatorId);
record GateRow(Long inboundId, Long orderId, String orderNo, String inboundCode, String processStatus,
String channelCode, Integer incompatibleGroupCount, Integer splitCount,
Integer preloadCount, Integer pdaCount) {}
record InboundRow(Long inboundId, Long orderId, String orderNo, String inboundCode,
String processStatus, String channelCode) {}
record SnapshotRow(Long tenantId, Long orderId, String eventId, Integer orderVersion, String requestHash) {}
}
@@ -159,6 +159,7 @@ public class OrderChannelChangePricingService {
try {
String value = request.tenantId() + ":" + request.orderId() + ":" + request.orderVersion() + ":"
+ request.originWarehouseId() + ":" + request.channelCode().trim().toUpperCase() + ":"
+ request.channelName().trim() + ":"
+ request.destinationType() + ":" + request.destinationWarehouseId() + ":"
+ request.destinationCode() + ":" + request.pricingTaxMode();
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -0,0 +1,152 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderDeclarationSync.ChannelChangeValidationRequest;
import com.baiyue.firstleg.dto.OrderDeclarationSync.ChannelChangeValidationResponse;
import com.baiyue.firstleg.dto.OrderDeclarationSync.UpdatedEvent;
import com.baiyue.firstleg.mapper.OrderDeclarationSyncMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.dao.DuplicateKeyException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
@Service
public class OrderDeclarationSyncService {
@Resource private OrderDeclarationSyncMapper mapper;
@Resource private ObjectMapper json;
@Transactional(readOnly = true)
public ChannelChangeValidationResponse validateChannelChange(ChannelChangeValidationRequest request) {
requireValidationRequest(request);
String channelCode = normalizeChannel(request.channelCode());
var row = mapper.gate(request.tenantId(), request.orderId(), channelCode);
if (row == null) throw new BizException(ResultCode.NOT_FOUND, "订单对应入库单不存在");
if (isLoadingOrLater(row.processStatus())) {
throw conflict("订单已进入装柜或后续履约阶段,渠道不可变更");
}
if (positive(row.pdaCount())) throw conflict("订单已生成PDA装柜任务,渠道不可变更");
if (positive(row.preloadCount())) throw conflict("订单已进入预装计划,渠道不可变更");
if (positive(row.splitCount())) throw conflict("订单已产生分柜事实,渠道不可变更");
if (positive(row.incompatibleGroupCount())) {
throw conflict("订单所属同柜组与新渠道不兼容,渠道不可变更");
}
return new ChannelChangeValidationResponse(request.orderId(), request.orderVersion(), channelCode,
mainStatus(row.processStatus()));
}
@Transactional
public void consume(UpdatedEvent event) {
requireEvent(event);
String hash = hash(event);
var inbound = mapper.lockInbound(event.tenantId(), event.orderId());
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单对应入库单不存在");
if (!Objects.equals(inbound.orderNo(), event.orderNo())
|| !Objects.equals(inbound.inboundCode(), event.inboundCode())) {
throw conflict("订单号或入仓码与 Firstleg 入库快照不一致");
}
var eventReplay = mapper.byEvent(event.tenantId(), event.eventId());
if (eventReplay != null) {
if (eventReplay.orderId() != event.orderId() || !hash.equals(eventReplay.requestHash())) {
throw conflict("eventId已用于不同申报事件");
}
return;
}
var current = mapper.lockSnapshot(event.tenantId(), event.orderId());
if (current != null && event.version() < current.orderVersion()) {
recordEvent(event, hash);
return;
}
if (current != null && event.version() == current.orderVersion()) {
throw conflict("同一订单版本已存在不同申报事件");
}
String cargoesJson = write(event.cargoes());
String changesJson = write(event.changes());
recordEvent(event, hash);
int saved = current == null
? mapper.insertSnapshot(event.tenantId(), event.orderId(), event.eventId(), event.version(), hash,
event.customerRemark(), event.expectedArrivalAt(), normalizeChannel(event.channelCode()),
event.channelName(), cargoesJson, changesJson, event.occurredAt())
: mapper.advanceSnapshot(event.tenantId(), event.orderId(), current.orderVersion(), event.version(),
event.eventId(), hash, event.customerRemark(), event.expectedArrivalAt(),
normalizeChannel(event.channelCode()), event.channelName(), cargoesJson, changesJson, event.occurredAt());
if (saved != 1) throw conflict("申报事件版本已发生并发变化");
if (mapper.updateAllowedSnapshot(event.tenantId(), inbound.inboundId(), event.orderId(), event.orderNo(),
event.inboundCode(), event.customerRemark(), event.expectedArrivalAt(),
normalizeChannel(event.channelCode()), event.channelName(), event.operatorId()) != 1) {
throw conflict("Firstleg 入库申报快照已发生变化");
}
}
private void recordEvent(UpdatedEvent event, String hash) {
try {
if (mapper.insertConsumedEvent(event.tenantId(), event.eventId(), event.orderId(),
event.version(), hash) != 1) throw conflict("订单申报事件幂等记录未保存");
} catch (DuplicateKeyException duplicate) {
throw conflict("eventId已用于其他申报事件");
}
}
private static void requireValidationRequest(ChannelChangeValidationRequest request) {
if (request == null || request.tenantId() <= 0 || request.orderId() <= 0 || request.orderVersion() < 0
|| !StringUtils.hasText(request.channelCode()) || request.channelCode().length() > 64) {
throw new BizException(ResultCode.BAD_REQUEST, "渠道变更履约校验参数不完整");
}
}
private static void requireEvent(UpdatedEvent event) {
if (event == null || event.schemaVersion() != 1 || !StringUtils.hasText(event.eventId())
|| event.eventId().length() > 128 || event.tenantId() <= 0 || event.orderId() <= 0
|| event.version() < 0 || event.operatorId() <= 0 || event.occurredAt() == null
|| !StringUtils.hasText(event.orderNo()) || !StringUtils.hasText(event.inboundCode())
|| event.cargoes() == null || event.cargoes().isEmpty() || event.cargoes().size() > 500
|| event.changes() == null || !StringUtils.hasText(event.channelCode()) || event.channelCode().length() > 64
|| !StringUtils.hasText(event.channelName()) || event.channelName().length() > 128
|| event.inboundCode().length() > 40
|| event.customerRemark() != null && event.customerRemark().length() > 1000) {
throw new BizException(ResultCode.BAD_REQUEST, "订单申报更新事件不完整");
}
}
private String hash(UpdatedEvent event) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json.writeValueAsBytes(event)));
} catch (NoSuchAlgorithmException | JsonProcessingException exception) {
throw new IllegalStateException("无法计算订单申报事件摘要", exception);
}
}
private String write(Object value) {
try {
return json.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法持久化订单申报快照", exception);
}
}
private static String normalizeChannel(String value) {
return value.trim().toUpperCase(Locale.ROOT);
}
private static boolean positive(Integer value) { return value != null && value > 0; }
private static boolean isLoadingOrLater(String value) {
return "LOADING".equals(value) || "LOADED".equals(value) || "DELIVERED".equals(value);
}
private static String mainStatus(String value) {
if ("WAIT_SPLIT".equals(value) || "LOADING".equals(value)
|| "LOADED".equals(value) || "DELIVERED".equals(value)) return value;
return "WAIT_MEASURE";
}
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}