优化
This commit is contained in:
+21
@@ -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
|
||||
|
||||
+3
@@ -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:")
|
||||
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
+129
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user