This commit is contained in:
2026-09-02 10:41:32 +08:00
parent 31996491be
commit 8e7835fe86
10 changed files with 529 additions and 0 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,107 @@
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
""")
GateRow 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_snapshot WHERE tenant_id=#{tenantId} AND event_id=#{eventId}")
SnapshotRow byEvent(@Param("tenantId") long tenantId, @Param("eventId") String eventId);
@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 SnapshotRow(Long tenantId, Long orderId, String eventId, Integer orderVersion, String requestHash) {}
}
@@ -0,0 +1,135 @@
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 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()) return;
if (current != null && event.version() == current.orderVersion()) {
throw conflict("同一订单版本已存在不同申报事件");
}
String cargoesJson = write(event.cargoes());
String changesJson = write(event.changes());
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 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()
|| !StringUtils.hasText(event.channelCode()) || !StringUtils.hasText(event.channelName())) {
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); }
}
@@ -819,6 +819,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
KEY idx_inbound_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录';
CREATE TABLE IF NOT EXISTS fl_order_declaration_snapshot (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID',
event_id VARCHAR(128) NOT NULL COMMENT 'Order Outbox事件ID',
order_version INT NOT NULL COMMENT 'Order聚合版本,严格单调消费',
request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256',
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间快照',
channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照',
channel_name VARCHAR(128) NOT NULL COMMENT '渠道名称快照',
cargoes_json JSON NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实',
changes_json JSON NULL COMMENT '本版本申报字段变化摘要',
occurred_at DATETIME(3) NOT NULL COMMENT 'Order事件发生时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id, order_id),
UNIQUE KEY uk_order_declaration_event (tenant_id, event_id),
KEY idx_order_declaration_version (tenant_id, order_id, order_version)
) ENGINE=InnoDB COMMENT='Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离';
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -181,6 +181,37 @@ paths:
schema: {$ref: '#/components/schemas/OrderApprovedInboundCommand'}
responses:
'200': {description: 成功或业务失败;相同客户和订单重放返回已有入库单 ID}
/internal/firstleg/v1/inbounds/order-channel-changes/validate:
post:
summary: Order 审核后变更渠道的 Firstleg 履约只读门禁
description: >-
严格按客户和订单校验同柜组渠道兼容性、分柜、预装、PDA及装柜事实。
已存在不可逆履约事实时返回 A0409;不会拆组、删除计划或修改任何作业事实。
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/OrderChannelChangeValidationRequest'}
responses:
'200': {description: 成功时 data 返回订单版本、新渠道和当前统一履约状态;冲突时响应体 code=A0409}
/internal/firstleg/v1/inbounds/order-declaration-updated:
post:
summary: 按 eventId 幂等、按订单 version 单调消费客户申报快照
description: >-
只更新客户备注、预计到仓时间、渠道镜像和独立货物申报 JSON 快照;入仓码和唛头保持不变,
不覆盖箱码、实测重量体积、托盘、库位、PDA、装柜或已确认计费事实。
相同 eventId 同负载安全重放,旧版本不覆盖新版本,同版本异事件返回 A0409。
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'}
responses:
'200': {description: 成功、幂等重放或旧版本已忽略;冲突时响应体 code=A0409}
/internal/firstleg/v1/inbounds/unified-orders/query:
post:
summary: 按统一订单条件分页查询履约摘要(Order 内部)
@@ -1042,6 +1073,36 @@ components:
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas:
OrderChannelChangeValidationRequest:
type: object
additionalProperties: false
required: [tenantId, orderId, orderVersion, channelCode]
properties:
tenantId: {type: integer, format: int64, minimum: 1}
orderId: {type: integer, format: int64, minimum: 1}
orderVersion: {type: integer, format: int64, minimum: 0}
channelCode: {type: string, minLength: 1, maxLength: 64}
OrderDeclarationUpdatedEvent:
type: object
additionalProperties: false
required: [schemaVersion, eventId, tenantId, orderId, orderNo, inboundCode, version, operatorId, occurredAt, channelCode, channelName, channelChanged, cargoes, changes]
properties:
schemaVersion: {type: integer, enum: [1]}
eventId: {type: string, minLength: 1, maxLength: 128}
tenantId: {type: integer, format: int64, minimum: 1}
orderId: {type: integer, format: int64, minimum: 1}
orderNo: {type: string, minLength: 1}
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 仅作一致性校验,绝不修改 Firstleg 入仓码或唛头}
version: {type: integer, minimum: 0}
operatorId: {type: integer, format: int64, minimum: 1}
occurredAt: {type: string, format: date-time}
customerRemark: {type: string, nullable: true, maxLength: 1000}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
channelCode: {type: string, minLength: 1, maxLength: 64}
channelName: {type: string, minLength: 1, maxLength: 128}
channelChanged: {type: boolean}
cargoes: {type: array, minItems: 1, maxItems: 500, items: {type: object, description: 与 OrderDeclarationDtos.Cargo 字段完全一致}}
changes: {type: array, items: {type: object, description: 与 OrderDeclarationDtos.FieldChange 字段完全一致}}
OrderChannelChangePricingRequest:
type: object
additionalProperties: false
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
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.UnifiedInboundQuery;
import com.baiyue.common.core.api.PageResult;
import jakarta.annotation.Resource;
@@ -31,6 +32,8 @@ class InternalInboundOrderReferenceControllerTest {
private InboundCartonLabelService cartonLabels;
@MockBean
private UnifiedInboundQueryService unifiedQueryService;
@MockBean
private OrderDeclarationSyncService declarationSync;
@Resource
private InternalInboundOrderController controller;
@@ -119,6 +119,9 @@ class PriceBookContractTest {
String firstlegOpenapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(firstlegOpenapi)
.contains("/internal/firstleg/v1/inbounds/order-channel-changes/validate:")
.contains("/internal/firstleg/v1/inbounds/order-declaration-updated:")
.contains("同版本异事件返回 A0409")
.contains("/internal/firstleg/v1/pricing/channels/validate:")
.contains("/internal/firstleg/v1/pricing/order-channel-changes/validate:")
.contains("/internal/firstleg/v1/pricing/order-channel-changes/apply:")
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OrderDeclarationSyncMapperContractTest {
@Test
void mapperIsExplicitAndProjectionUpdateCannotOverwriteOperationalFacts() throws Exception {
assertThat(OrderDeclarationSyncMapper.class).hasAnnotation(Mapper.class);
String sql = String.join(" ", OrderDeclarationSyncMapper.class
.getDeclaredMethod("updateAllowedSnapshot", long.class, long.class, long.class, String.class,
String.class, String.class, java.time.LocalDateTime.class, String.class, String.class, long.class)
.getAnnotation(Update.class).value());
assertThat(sql)
.contains("public_remark", "expected_arrival_at", "channel_code", "channel_name")
.doesNotContain("actual_net_weight", "actual_volume", "fee_status", "SET inbound_code",
"pallet", "box_code", "process_status");
}
}
@@ -0,0 +1,129 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderDeclarationSync;
import com.baiyue.firstleg.mapper.OrderDeclarationSyncMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({OrderDeclarationSyncService.class, OrderDeclarationSyncServiceTest.JsonConfig.class})
class OrderDeclarationSyncServiceTest {
@Configuration
static class JsonConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
@MockBean private OrderDeclarationSyncMapper mapper;
@Resource private OrderDeclarationSyncService service;
@Resource private ObjectMapper json;
@Test
void allowsOnlyCompatibleUnallocatedFulfillment() {
when(mapper.gate(10001L, 77L, "MATSON_NEW")).thenReturn(
row("WAIT_SPLIT", 0, 0, 0, 0));
var response = service.validateChannelChange(
new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "matson_new"));
assertThat(response.channelCode()).isEqualTo("MATSON_NEW");
assertThat(response.mainStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
void rejectsPdaFactWithoutMutatingAnything() {
when(mapper.gate(10001L, 77L, "MATSON_NEW")).thenReturn(
row("WAIT_SPLIT", 0, 0, 1, 1));
assertThatThrownBy(() -> service.validateChannelChange(
new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "MATSON_NEW")))
.isInstanceOf(BizException.class)
.hasMessageContaining("PDA装柜任务");
verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(),
any(), any(), anyString(), anyString(), anyLong());
}
@Test
void persistsVersionedDeclarationAndUpdatesOnlyAllowedInboundProjection() {
var event = event(4, "event-4");
when(mapper.lockInbound(10001L, 77L)).thenReturn(row("WAIT_MEASURE", null, null, null, null));
when(mapper.insertSnapshot(anyLong(), anyLong(), anyString(), anyInt(), anyString(), any(), any(),
anyString(), anyString(), anyString(), anyString(), any())).thenReturn(1);
when(mapper.updateAllowedSnapshot(10001L, 88L, 77L, "FL-77", "RK-77", "客户新备注",
event.expectedArrivalAt(), "MATSON_NEW", "新渠道", 9L)).thenReturn(1);
service.consume(event);
verify(mapper).updateAllowedSnapshot(10001L, 88L, 77L, "FL-77", "RK-77", "客户新备注",
event.expectedArrivalAt(), "MATSON_NEW", "新渠道", 9L);
}
@Test
void ignoresOlderVersionWithoutOverwritingCurrentProjection() {
var event = event(4, "event-4");
when(mapper.lockInbound(10001L, 77L)).thenReturn(row("WAIT_MEASURE", null, null, null, null));
when(mapper.lockSnapshot(10001L, 77L)).thenReturn(
new OrderDeclarationSyncMapper.SnapshotRow(10001L, 77L, "event-5", 5, "hash"));
service.consume(event);
verify(mapper, never()).insertSnapshot(anyLong(), anyLong(), anyString(), anyInt(), anyString(), any(),
any(), anyString(), anyString(), anyString(), anyString(), any());
verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(),
any(), any(), anyString(), anyString(), anyLong());
}
@Test
void sameEventAndPayloadIsIdempotent() throws Exception {
var event = event(4, "event-4");
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json.writeValueAsBytes(event)));
when(mapper.lockInbound(10001L, 77L)).thenReturn(row("WAIT_MEASURE", null, null, null, null));
when(mapper.byEvent(10001L, "event-4")).thenReturn(
new OrderDeclarationSyncMapper.SnapshotRow(10001L, 77L, "event-4", 4, hash));
service.consume(event);
verify(mapper, never()).lockSnapshot(anyLong(), anyLong());
verify(mapper, never()).updateAllowedSnapshot(anyLong(), anyLong(), anyLong(), anyString(), anyString(),
any(), any(), anyString(), anyString(), anyLong());
}
private static OrderDeclarationSyncMapper.GateRow row(String status, Integer group, Integer split,
Integer preload, Integer pda) {
return new OrderDeclarationSyncMapper.GateRow(88L, 77L, "FL-77", "RK-77", status,
"MATSON_OLD", group, split, preload, pda);
}
private static OrderDeclarationSync.UpdatedEvent event(int version, String eventId) {
var cargo = new OrderDeclarationSync.Cargo(1, "衣服", "clothes", "SKU-1", null, 2, 20,
new BigDecimal("10"), new BigDecimal("20"), new BigDecimal("30"), new BigDecimal("37"),
new BigDecimal("0.102"), null, null, null, null, null, null, "CNY",
null, null, null, null);
return new OrderDeclarationSync.UpdatedEvent(1, eventId, 10001L, 77L, "FL-77", "RK-77",
version, 9L, LocalDateTime.of(2026, 9, 2, 10, 0), "客户新备注",
LocalDateTime.of(2026, 9, 3, 10, 0), "MATSON_NEW", "新渠道", true,
List.of(cargo), List.of(new OrderDeclarationSync.FieldChange(null, "channelCode",
"MATSON_OLD", "MATSON_NEW")));
}
}