diff --git a/AGENTS.md b/AGENTS.md index 65e070ee7..4eddc75d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ | 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | +| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 6672fdf90..0ac4a941c 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -14,6 +14,7 @@ |---|---| | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | +| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | | 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据、客户海外仓 `merchant:warehouse:*` 及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | @@ -29,6 +30,7 @@ - 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 +- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 ## 全量验收导航 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 01a61303f..a9cf17536 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -14,6 +14,7 @@ | 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | +| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 | | 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | @@ -60,6 +61,7 @@ - 一件代发以 `fl_inbound_carton_label.sku_code` 作为不可变 SKU 快照;PDA 仍只扫描稳定 `box_code`,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;历史箱唛仅能在同客户、同入库单范围内按 `inbound_detail_id` 回填,禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 +- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 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..4e3cad5d9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapper.java @@ -0,0 +1,116 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; + +@Mapper +public interface OrderDeclarationSyncMapper { + @Select(""" + SELECT inbound.id inboundId,inbound.order_id orderId,inbound.order_no orderNo, + inbound.inbound_code inboundCode,inbound.process_status processStatus, + inbound.channel_code channelCode, + (SELECT COUNT(*) FROM fl_coload_group_member member + JOIN fl_coload_group group_row ON group_row.id=member.group_id + WHERE member.tenant_id=inbound.tenant_id AND member.inbound_id=inbound.id + AND member.active_flag=1 AND group_row.group_status IN ('ACTIVE','LOCKED') + AND UPPER(group_row.channel_code)<>UPPER(#{newChannelCode})) incompatibleGroupCount, + (SELECT COUNT(*) FROM fl_inbound_split_batch batch + WHERE batch.tenant_id=inbound.tenant_id AND batch.source_inbound_id=inbound.id + AND batch.status='APPLIED') splitCount, + (SELECT COUNT(*) FROM fl_loading_plan_item item + WHERE item.account_id=inbound.tenant_id AND item.inbound_id=inbound.id AND item.deleted=0) preloadCount, + (SELECT COUNT(*) FROM fl_loading_pick_item pick_item + JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id + WHERE plan_item.account_id=inbound.tenant_id AND plan_item.inbound_id=inbound.id) pdaCount + FROM fl_inbound_order inbound + WHERE inbound.tenant_id=#{tenantId} AND inbound.order_id=#{orderId} AND inbound.deleted=0 + ORDER BY inbound.created_at DESC,inbound.id DESC LIMIT 1 + """) + GateRow gate(@Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("newChannelCode") String newChannelCode); + + @Select(""" + SELECT inbound.id inboundId,inbound.order_id orderId,inbound.order_no orderNo, + inbound.inbound_code inboundCode,inbound.process_status processStatus,inbound.channel_code channelCode + FROM fl_inbound_order inbound + WHERE inbound.tenant_id=#{tenantId} AND inbound.order_id=#{orderId} AND inbound.deleted=0 + ORDER BY inbound.created_at DESC,inbound.id DESC LIMIT 1 FOR UPDATE + """) + InboundRow lockInbound(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + + @Select("SELECT tenant_id tenantId,order_id orderId,event_id eventId,order_version orderVersion,request_hash requestHash " + + "FROM fl_order_declaration_snapshot WHERE tenant_id=#{tenantId} AND order_id=#{orderId} FOR UPDATE") + SnapshotRow lockSnapshot(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + + @Select("SELECT tenant_id tenantId,order_id orderId,event_id eventId,order_version orderVersion,request_hash requestHash " + + "FROM fl_order_declaration_event WHERE tenant_id=#{tenantId} AND event_id=#{eventId}") + SnapshotRow byEvent(@Param("tenantId") long tenantId, @Param("eventId") String eventId); + + @Insert("INSERT INTO fl_order_declaration_event " + + "(tenant_id,event_id,order_id,order_version,request_hash,consumed_at) VALUES " + + "(#{tenantId},#{eventId},#{orderId},#{orderVersion},#{requestHash},CURRENT_TIMESTAMP(3))") + int insertConsumedEvent(@Param("tenantId") long tenantId, @Param("eventId") String eventId, + @Param("orderId") long orderId, @Param("orderVersion") int orderVersion, + @Param("requestHash") String requestHash); + + @Insert(""" + INSERT INTO fl_order_declaration_snapshot + (tenant_id,order_id,event_id,order_version,request_hash,customer_remark,expected_arrival_at, + channel_code,channel_name,cargoes_json,changes_json,occurred_at,created_at,updated_at) + VALUES + (#{tenantId},#{orderId},#{eventId},#{orderVersion},#{requestHash},#{customerRemark},#{expectedArrivalAt}, + #{channelCode},#{channelName},#{cargoesJson},#{changesJson},#{occurredAt},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertSnapshot(@Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("eventId") String eventId, @Param("orderVersion") int orderVersion, + @Param("requestHash") String requestHash, @Param("customerRemark") String customerRemark, + @Param("expectedArrivalAt") LocalDateTime expectedArrivalAt, + @Param("channelCode") String channelCode, @Param("channelName") String channelName, + @Param("cargoesJson") String cargoesJson, @Param("changesJson") String changesJson, + @Param("occurredAt") LocalDateTime occurredAt); + + @Update(""" + UPDATE fl_order_declaration_snapshot + SET event_id=#{eventId},order_version=#{nextVersion},request_hash=#{requestHash}, + customer_remark=#{customerRemark},expected_arrival_at=#{expectedArrivalAt}, + channel_code=#{channelCode},channel_name=#{channelName},cargoes_json=#{cargoesJson}, + changes_json=#{changesJson},occurred_at=#{occurredAt},updated_at=CURRENT_TIMESTAMP(3) + WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND order_version=#{currentVersion} + """) + int advanceSnapshot(@Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("currentVersion") int currentVersion, @Param("nextVersion") int nextVersion, + @Param("eventId") String eventId, @Param("requestHash") String requestHash, + @Param("customerRemark") String customerRemark, + @Param("expectedArrivalAt") LocalDateTime expectedArrivalAt, + @Param("channelCode") String channelCode, @Param("channelName") String channelName, + @Param("cargoesJson") String cargoesJson, @Param("changesJson") String changesJson, + @Param("occurredAt") LocalDateTime occurredAt); + + @Update(""" + UPDATE fl_inbound_order + SET public_remark=#{customerRemark},expected_arrival_at=#{expectedArrivalAt}, + channel_code=#{channelCode},channel_name=#{channelName},updated_by=#{operatorId}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 + AND order_id=#{orderId} AND order_no=#{orderNo} AND inbound_code=#{inboundCode} + """) + int updateAllowedSnapshot(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("orderId") long orderId, @Param("orderNo") String orderNo, + @Param("inboundCode") String inboundCode, + @Param("customerRemark") String customerRemark, + @Param("expectedArrivalAt") LocalDateTime expectedArrivalAt, + @Param("channelCode") String channelCode, @Param("channelName") String channelName, + @Param("operatorId") long operatorId); + + record GateRow(Long inboundId, Long orderId, String orderNo, String inboundCode, String processStatus, + String channelCode, Integer incompatibleGroupCount, Integer splitCount, + Integer preloadCount, Integer pdaCount) {} + record InboundRow(Long inboundId, Long orderId, String orderNo, String inboundCode, + String processStatus, String channelCode) {} + record SnapshotRow(Long tenantId, Long orderId, String eventId, Integer orderVersion, String requestHash) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java index 6b1d7c4f8..49614ba3d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java @@ -159,6 +159,7 @@ public class OrderChannelChangePricingService { try { String value = request.tenantId() + ":" + request.orderId() + ":" + request.orderVersion() + ":" + request.originWarehouseId() + ":" + request.channelCode().trim().toUpperCase() + ":" + + request.channelName().trim() + ":" + request.destinationType() + ":" + request.destinationWarehouseId() + ":" + request.destinationCode() + ":" + request.pricingTaxMode(); return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") 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..1230bc021 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java @@ -0,0 +1,152 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderDeclarationSync.ChannelChangeValidationRequest; +import com.baiyue.firstleg.dto.OrderDeclarationSync.ChannelChangeValidationResponse; +import com.baiyue.firstleg.dto.OrderDeclarationSync.UpdatedEvent; +import com.baiyue.firstleg.mapper.OrderDeclarationSyncMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.dao.DuplicateKeyException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Objects; + +@Service +public class OrderDeclarationSyncService { + @Resource private OrderDeclarationSyncMapper mapper; + @Resource private ObjectMapper json; + + @Transactional(readOnly = true) + public ChannelChangeValidationResponse validateChannelChange(ChannelChangeValidationRequest request) { + requireValidationRequest(request); + String channelCode = normalizeChannel(request.channelCode()); + var row = mapper.gate(request.tenantId(), request.orderId(), channelCode); + if (row == null) throw new BizException(ResultCode.NOT_FOUND, "订单对应入库单不存在"); + if (isLoadingOrLater(row.processStatus())) { + throw conflict("订单已进入装柜或后续履约阶段,渠道不可变更"); + } + if (positive(row.pdaCount())) throw conflict("订单已生成PDA装柜任务,渠道不可变更"); + if (positive(row.preloadCount())) throw conflict("订单已进入预装计划,渠道不可变更"); + if (positive(row.splitCount())) throw conflict("订单已产生分柜事实,渠道不可变更"); + if (positive(row.incompatibleGroupCount())) { + throw conflict("订单所属同柜组与新渠道不兼容,渠道不可变更"); + } + return new ChannelChangeValidationResponse(request.orderId(), request.orderVersion(), channelCode, + mainStatus(row.processStatus())); + } + + @Transactional + public void consume(UpdatedEvent event) { + requireEvent(event); + String hash = hash(event); + var inbound = mapper.lockInbound(event.tenantId(), event.orderId()); + if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单对应入库单不存在"); + if (!Objects.equals(inbound.orderNo(), event.orderNo()) + || !Objects.equals(inbound.inboundCode(), event.inboundCode())) { + throw conflict("订单号或入仓码与 Firstleg 入库快照不一致"); + } + var eventReplay = mapper.byEvent(event.tenantId(), event.eventId()); + if (eventReplay != null) { + if (eventReplay.orderId() != event.orderId() || !hash.equals(eventReplay.requestHash())) { + throw conflict("eventId已用于不同申报事件"); + } + return; + } + var current = mapper.lockSnapshot(event.tenantId(), event.orderId()); + if (current != null && event.version() < current.orderVersion()) { + recordEvent(event, hash); + return; + } + if (current != null && event.version() == current.orderVersion()) { + throw conflict("同一订单版本已存在不同申报事件"); + } + String cargoesJson = write(event.cargoes()); + String changesJson = write(event.changes()); + recordEvent(event, hash); + int saved = current == null + ? mapper.insertSnapshot(event.tenantId(), event.orderId(), event.eventId(), event.version(), hash, + event.customerRemark(), event.expectedArrivalAt(), normalizeChannel(event.channelCode()), + event.channelName(), cargoesJson, changesJson, event.occurredAt()) + : mapper.advanceSnapshot(event.tenantId(), event.orderId(), current.orderVersion(), event.version(), + event.eventId(), hash, event.customerRemark(), event.expectedArrivalAt(), + normalizeChannel(event.channelCode()), event.channelName(), cargoesJson, changesJson, event.occurredAt()); + if (saved != 1) throw conflict("申报事件版本已发生并发变化"); + if (mapper.updateAllowedSnapshot(event.tenantId(), inbound.inboundId(), event.orderId(), event.orderNo(), + event.inboundCode(), event.customerRemark(), event.expectedArrivalAt(), + normalizeChannel(event.channelCode()), event.channelName(), event.operatorId()) != 1) { + throw conflict("Firstleg 入库申报快照已发生变化"); + } + } + + private void recordEvent(UpdatedEvent event, String hash) { + try { + if (mapper.insertConsumedEvent(event.tenantId(), event.eventId(), event.orderId(), + event.version(), hash) != 1) throw conflict("订单申报事件幂等记录未保存"); + } catch (DuplicateKeyException duplicate) { + throw conflict("eventId已用于其他申报事件"); + } + } + + private static void requireValidationRequest(ChannelChangeValidationRequest request) { + if (request == null || request.tenantId() <= 0 || request.orderId() <= 0 || request.orderVersion() < 0 + || !StringUtils.hasText(request.channelCode()) || request.channelCode().length() > 64) { + throw new BizException(ResultCode.BAD_REQUEST, "渠道变更履约校验参数不完整"); + } + } + + private static void requireEvent(UpdatedEvent event) { + if (event == null || event.schemaVersion() != 1 || !StringUtils.hasText(event.eventId()) + || event.eventId().length() > 128 || event.tenantId() <= 0 || event.orderId() <= 0 + || event.version() < 0 || event.operatorId() <= 0 || event.occurredAt() == null + || !StringUtils.hasText(event.orderNo()) || !StringUtils.hasText(event.inboundCode()) + || event.cargoes() == null || event.cargoes().isEmpty() || event.cargoes().size() > 500 + || event.changes() == null || !StringUtils.hasText(event.channelCode()) || event.channelCode().length() > 64 + || !StringUtils.hasText(event.channelName()) || event.channelName().length() > 128 + || event.inboundCode().length() > 40 + || event.customerRemark() != null && event.customerRemark().length() > 1000) { + throw new BizException(ResultCode.BAD_REQUEST, "订单申报更新事件不完整"); + } + } + + private String hash(UpdatedEvent event) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json.writeValueAsBytes(event))); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("无法计算订单申报事件摘要", exception); + } + } + + private String write(Object value) { + try { + return json.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法持久化订单申报快照", exception); + } + } + + private static String normalizeChannel(String value) { + return value.trim().toUpperCase(Locale.ROOT); + } + + private static boolean positive(Integer value) { return value != null && value > 0; } + private static boolean isLoadingOrLater(String value) { + return "LOADING".equals(value) || "LOADED".equals(value) || "DELIVERED".equals(value); + } + private static String mainStatus(String value) { + if ("WAIT_SPLIT".equals(value) || "LOADING".equals(value) + || "LOADED".equals(value) || "DELIVERED".equals(value)) return value; + return "WAIT_MEASURE"; + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 7c6a1d249..b85383573 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -821,6 +821,37 @@ 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_order_declaration_event ( + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', + event_id VARCHAR(128) NOT NULL COMMENT '永久保留的Order Outbox事件ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order订单ID', + order_version INT NOT NULL COMMENT '事件携带的Order聚合版本', + request_hash CHAR(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝', + consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (tenant_id, event_id), + KEY idx_order_declaration_event_order (tenant_id, order_id, order_version) +) ENGINE=InnoDB COMMENT='Order客户申报事件永久幂等账本'; + 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 8d71aa160..041ba50b4 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..93c24882d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -6,7 +6,9 @@ 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.firstleg.dto.OrderDeclarationSync; import com.baiyue.common.core.api.PageResult; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; @@ -15,6 +17,7 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; +import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -31,6 +34,8 @@ class InternalInboundOrderReferenceControllerTest { private InboundCartonLabelService cartonLabels; @MockBean private UnifiedInboundQueryService unifiedQueryService; + @MockBean + private OrderDeclarationSyncService declarationSync; @Resource private InternalInboundOrderController controller; @@ -85,6 +90,26 @@ class InternalInboundOrderReferenceControllerTest { verify(cartonLabels).getOrCreate(10001L, 88L); } + @Test + void exposesExactOrderChannelValidationContract() { + var request = new OrderDeclarationSync.ChannelChangeValidationRequest(10001L, 77L, 4L, "MATSON_NEW"); + var result = new OrderDeclarationSync.ChannelChangeValidationResponse(77L, 4L, "MATSON_NEW", "WAIT_SPLIT"); + when(declarationSync.validateChannelChange(request)).thenReturn(result); + + assertThat(controller.validateOrderChannelChange("internal-token", request).data()).isEqualTo(result); + verify(declarationSync).validateChannelChange(request); + } + + @Test + void exposesExactDeclarationEventConsumerContract() { + var event = new OrderDeclarationSync.UpdatedEvent(1, "event-4", 10001L, 77L, "FL-77", "RK-77", + 4, 9L, LocalDateTime.of(2026, 9, 2, 10, 0), null, null, "MATSON_NEW", "新渠道", + true, List.of(), List.of()); + + assertThat(controller.updateOrderDeclaration("internal-token", event).successful()).isTrue(); + verify(declarationSync).consume(event); + } + private static OrderApprovedInboundCommand approvedCommand(String inboundCode, Long carrierId) { return new OrderApprovedInboundCommand( 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓", 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..5c39d7002 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/mapper/OrderDeclarationSyncMapperContractTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderDeclarationSyncMapperContractTest { + @Test + void mapperIsExplicitAndProjectionUpdateCannotOverwriteOperationalFacts() throws Exception { + assertThat(OrderDeclarationSyncMapper.class).hasAnnotation(Mapper.class); + assertThat(OrderDeclarationSyncMapper.class + .getDeclaredMethod("lockInbound", long.class, long.class).getReturnType()) + .isEqualTo(OrderDeclarationSyncMapper.InboundRow.class); + assertThat(OrderDeclarationSyncMapper.InboundRow.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .containsExactly("inboundId", "orderId", "orderNo", "inboundCode", "processStatus", "channelCode"); + 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"); + } + + @Test + void sqlBaselineKeepsCurrentSnapshotAndPermanentEventLedgerSeparate() throws Exception { + String sql = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_order_declaration_snapshot") + .contains("CREATE TABLE IF NOT EXISTS fl_order_declaration_event") + .contains("PRIMARY KEY (tenant_id, event_id)") + .contains("不覆盖实测事实"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java index 25685cfe4..e21fcfdaf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -96,6 +96,22 @@ class OrderChannelChangePricingServiceTest { verify(repository, never()).applyOrderChannelChange(any(), any(), any()); } + @Test + void sameOrderVersionRejectsDifferentChannelNamePayload() { + InboundPricingEntity inbound = inbound("UNPRICED"); + InboundPricingIdempotencyEntity token = token(4L, "COMPLETED"); + token.setRequestHash(requestHash(request(4))); + when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); + when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token); + Request changedName = new Request(10001L, 77L, 4L, 9L, "MATSON_NEW", "伪造名称", + "FBA", 501L, "ONT8", "TAX_INCLUDED"); + + assertThatThrownBy(() -> service.apply(changedName)) + .isInstanceOf(BizException.class) + .hasMessageContaining("同一订单版本已用于不同渠道变更"); + verify(estimator, never()).estimate(any()); + } + @Test void rejectsOlderOrderVersionAfterNewerVersionWasApplied() { InboundPricingEntity inbound = inbound("UNPRICED"); 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..98445bcd8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderDeclarationSyncServiceTest.java @@ -0,0 +1,137 @@ +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(inboundRow("WAIT_MEASURE")); + when(mapper.insertSnapshot(anyLong(), anyLong(), anyString(), anyInt(), anyString(), any(), any(), + anyString(), anyString(), anyString(), anyString(), any())).thenReturn(1); + when(mapper.insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString())).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(inboundRow("WAIT_MEASURE")); + when(mapper.lockSnapshot(10001L, 77L)).thenReturn( + new OrderDeclarationSyncMapper.SnapshotRow(10001L, 77L, "event-5", 5, "hash")); + when(mapper.insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString())).thenReturn(1); + + 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()); + verify(mapper).insertConsumedEvent(anyLong(), anyString(), anyLong(), anyInt(), anyString()); + } + + @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(inboundRow("WAIT_MEASURE")); + 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 OrderDeclarationSyncMapper.InboundRow inboundRow(String status) { + return new OrderDeclarationSyncMapper.InboundRow( + 88L, 77L, "FL-77", "RK-77", status, "MATSON_OLD"); + } + + 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"))); + } +} diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 459032d0b..90cdc0d53 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -12,9 +12,9 @@ | 目标 | 入口 | |---|---| -| 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` | +| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | | 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` | -| 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` | +| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | @@ -29,6 +29,7 @@ - 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。 - 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。 +- 注册创建时可用同一手机号初始化公司联系电话与首位管理员登录手机号,但此后两者独立:企业资料更新不得探测 Auth 唯一性或修改任何成员账号;成员登录手机号修改必须检查 Auth 唯一性、同步账号并撤销该账号会话。企业审核不得把法人身份证恢复为必填资质。 - 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 - 国内头程 PDA 使用 `CN_FIRSTLEG_PDA_OPERATOR`,海外仓 PDA 使用 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`;两个角色互斥且权限不得交叉。只有海外角色可保存 `overseasWarehouseId`,且必须为单个有效海外仓;国内角色及其他角色必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 diff --git a/order/AGENTS.md b/order/AGENTS.md index 6022f661f..f0ed703e7 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -13,6 +13,8 @@ |---|---| | 平台订单 API | `controller/PlatformFirstLegOrderController.java` | | 统一订单查询与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导 | +| 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` | +| 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg | | 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | | 商户订单列表与批量草稿 | `service/MerchantInboundOrderListService.java`、`service/MerchantBatchDraftService.java`、`service/MerchantBatchDraftItemService.java` | @@ -32,6 +34,8 @@ - `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。 - 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。 - Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。 +- 平台只能在 `DRAFT/REJECTED/SUBMITTED/APPROVED` 版本化修改客户原始申报;不得修改客户、国内收货仓、订单号、入仓码、状态或仓库/PDA/设备实测、测量审核和计费事实,不得增删或重排货物行。国内收货仓或中转仓变化必须重新下单。 +- 只有渠道发生变化才同时调用 Firstleg 履约可逆性与重新计价门禁,任一内部校验不可用或失败均不写入;成功后 Outbox 必须携带完整申报快照和字段变化。Firstleg 消费按 `eventId + payload hash` 幂等、按订单版本单调推进,渠道变化才使未确认价格预估失效。 - 对 `ONE_CLICK_DROPSHIP`,货物明细 SKU 是箱唛绑定及海外一件代发库存追踪的前置条件,不得依赖货名、箱规或其他展示字段推导 SKU。修改 `CargoLabelBinding` 时同步更新 Firstleg 客户端、相关测试与服务间契约。 ## 全量验收导航 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 64db103b5..616c061f8 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -166,6 +166,48 @@ $orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md') Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码' Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口' Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口' + +# 平台修改客户原始申报必须能从各层索引直达,并锁住 Order -> Firstleg 的版本化同步与渠道门禁。 +$orderOpenApi = Read-RequiredText (Join-Path $backendRoot 'order/src/main/resources/openapi/order-v1.yaml') +$firstlegOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/firstleg-v1.yaml') +$orderDeclarationService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java') +$firstlegDeclarationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java') +$adminDeclarationEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue') +Assert-Contains $rootAgents '平台修改客户原始申报与履约渠道' '根订单申报快速路由' +Assert-Contains $adminAgents '客户原始申报资料编辑与渠道变更' 'Admin 订单申报快速路由' +Assert-Contains $backendAgents '平台修改客户原始申报、履约渠道变更与跨服务同步' '后端订单申报快速路由' +Assert-Contains $orderAgents '平台版本化修改客户原始申报' 'Order 申报编辑入口' +Assert-Contains $firstlegAgents 'Order 申报同步与渠道变更履约门禁' 'Firstleg 申报同步入口' +Assert-Contains $orderOpenApi '/api/platform/v1/orders/{orderId}/declaration:' 'Order 申报编辑 OpenAPI' +Assert-Contains $orderOpenApi '不覆盖实测、箱码或已确认费用' 'Order 申报与履约事实边界' +Assert-Contains $firstlegOpenApi '/internal/firstleg/v1/inbounds/order-declaration-updated:' 'Firstleg 申报同步 OpenAPI' +Assert-Contains $orderDeclarationService 'OrderStatus.DRAFT, OrderStatus.REJECTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED' 'Order 申报可编辑状态' +Assert-Contains $orderDeclarationService 'ORDER_DECLARATION_UPDATED_V1' 'Order 申报版本事件' +Assert-Contains $firstlegDeclarationService 'mapper.byEvent(event.tenantId(), event.eventId())' 'Firstleg 申报 eventId 幂等' +Assert-Contains $firstlegDeclarationService 'event.version() < current.orderVersion()' 'Firstleg 申报版本单调性' +Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' 'Admin 申报事实边界提示' + +# 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。 +$authAgents = Read-RequiredText (Join-Path $backendRoot 'auth/AGENTS.md') +$iamAgents = Read-RequiredText (Join-Path $backendRoot 'iam/AGENTS.md') +$iamOpenApi = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/resources/openapi/iam-v1.yaml') +$authAccountService = Read-RequiredText (Join-Path $backendRoot 'auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java') +$iamMemberService = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/java/com/baiyue/iam/service/MemberService.java') +$adminCustomersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/customers/pages/CustomersView.vue') +$clientCompanyProfile = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileView.vue') +$clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MembersView.vue') +Assert-Contains $rootAgents '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界' +Assert-Contains $adminAgents '公司联系电话只作企业联系信息,不修改管理员/成员登录手机号' 'Admin 企业联系人边界' +Assert-Contains $clientAgents '成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计' 'Client 成员登录手机号边界' +Assert-Contains $authAgents '登录标识与企业联系人解耦' 'Auth 登录标识入口' +Assert-Contains $iamAgents '公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一' 'IAM 企业联系人边界' +Assert-Contains $iamAgents '审核必填资质仅营业执照与统一社会信用代码,法人身份证选填' 'IAM 必填资质边界' +Assert-Contains $iamOpenApi '公司联系电话,不作为登录标识且不要求跨企业唯一' 'IAM 公司联系电话 OpenAPI' +Assert-Contains $authAccountService 'tokenSessionStore.revokeAll(account.getId())' 'Auth 登录标识变更会话撤销' +Assert-Contains $iamMemberService 'authClient.revokeSessions' 'IAM 成员变更会话撤销' +Assert-Contains $adminCustomersView '不会修改企业管理员登录手机号' 'Admin 公司联系电话提示' +Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机号' 'Client 公司联系电话提示' +Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示' $fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') $truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml') $loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml') @@ -795,5 +837,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、脚本目录治理、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、脚本目录治理、企业联系信息与登录标识解耦、订单申报版本同步与渠道门禁、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0