From 8e7835fe86f9153b12feec847cf8465cb1c15f9d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 10:41:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalInboundOrderController.java | 21 +++ .../firstleg/dto/OrderDeclarationSync.java | 27 ++++ .../mapper/OrderDeclarationSyncMapper.java | 107 ++++++++++++++ .../service/OrderDeclarationSyncService.java | 135 ++++++++++++++++++ .../resources/db/phase2-inbound-schema.sql | 20 +++ .../main/resources/openapi/firstleg-v1.yaml | 61 ++++++++ ...alInboundOrderReferenceControllerTest.java | 3 + .../controller/PriceBookContractTest.java | 3 + ...rderDeclarationSyncMapperContractTest.java | 23 +++ .../OrderDeclarationSyncServiceTest.java | 129 +++++++++++++++++ 10 files changed, 529 insertions(+) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/OrderDeclarationSync.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 4e299343f..e32e176a7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -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 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 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> unifiedPage( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderDeclarationSync.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderDeclarationSync.java new file mode 100644 index 000000000..32e56a8e8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderDeclarationSync.java @@ -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 cargoes, List changes) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapper.java new file mode 100644 index 000000000..ac4285799 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapper.java @@ -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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java new file mode 100644 index 000000000..ef66a9966 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java @@ -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); } +} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 902aede82..a1dca5c1b 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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和数据隔离键', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index ce4a0753f..d496ba5af 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index f84cebd7c..80f3bc3b6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -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; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index c9480da62..ae0a6d745 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -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:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapperContractTest.java new file mode 100644 index 000000000..be6d65a6f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapperContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java new file mode 100644 index 000000000..b7fe1b347 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java @@ -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"))); + } +}