From 63eefd280b9237e61238db794f847e8e3c2557e6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 16:17:16 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/baiyue/firstleg/BaiyueFirstlegApplication.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 363d0c7eb..c8f221171 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -8,7 +8,10 @@ import org.springframework.cloud.openfeign.EnableFeignClients; /** * 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。 */ -@MapperScan("com.baiyue.firstleg.mapper") +@MapperScan({ + "com.baiyue.firstleg.mapper", + "com.baiyue.firstleg.split.mapper" +}) @EnableFeignClients(basePackages = "com.baiyue.firstleg.client") @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueFirstlegApplication { From 584f1519d1da49943e603a28d74d29f28dcaf7b9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 16:58:33 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java | 1 + 1 file changed, 1 insertion(+) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index c8f221171..f4f7c2abe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -10,6 +10,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients; */ @MapperScan({ "com.baiyue.firstleg.mapper", + "com.baiyue.firstleg.loading.mapper", "com.baiyue.firstleg.split.mapper" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg.client") From 6e9739826ce0760871b2522bf92effc9981e5a68 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 18:01:59 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/web/GlobalExceptionHandler.java | 11 +++- .../web/GlobalExceptionHandlerTest.java | 18 ++++++ deploy/sql/README.md | 22 +++++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 59 +++++++++++++++++- .../PlatformFbaMasterDataController.java | 22 +++---- .../firstleg/entity/CarrierFbaRateEntity.java | 3 + .../repository/FbaPricingRepository.java | 31 +++++++--- .../service/FbaMasterDataService.java | 21 ++++--- .../db/phase2-fba-pricing-migration.sql | 55 +++++++++++++++++ .../resources/db/phase2-inbound-schema.sql | 4 +- .../resources/openapi/fba-pricing-v1.yaml | 10 ++-- .../controller/FbaPricingContractTest.java | 26 ++++---- .../FbaPricingRepositoryRuleListTest.java | 55 ++++++++++++++--- .../InboundWorkflowSqlContractTest.java | 14 +++++ .../service/FbaMasterDataServiceTest.java | 24 ++++++++ .../PlatformFirstLegOrderController.java | 13 ++++ .../com/baiyue/order/dto/OrderRequests.java | 18 +++++- .../service/OrderApplicationService.java | 25 +++++++- .../order/service/OrderMasterDataGuard.java | 31 ++++++++++ .../src/main/resources/openapi/order-v1.yaml | 21 ++++++- .../OrderControllerPermissionTest.java | 2 + .../dto/OrderRequestsValidationTest.java | 41 +++++++++++++ .../OrderApplicationServiceStateTest.java | 60 +++++++++++++++++++ .../service/OrderMasterDataGuardTest.java | 25 ++++++++ 24 files changed, 549 insertions(+), 62 deletions(-) create mode 100644 order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index 8d238f621..cb299bda9 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -94,12 +94,21 @@ public class GlobalExceptionHandler { private ResponseEntity>> validationResponse( Map errors, HttpServletRequest request) { ResultCode code = ResultCode.VALIDATION_FAILED; - ApiResponse> body = ApiResponse.of(code, code.message(), errors, + ApiResponse> body = ApiResponse.of(code, validationMessage(errors), errors, CorrelationContext.requestId(request), CorrelationContext.traceId(request)); recordFailure(code); return ResponseEntity.ok(body); } + private String validationMessage(Map errors) { + if (errors.isEmpty()) { + return ResultCode.VALIDATION_FAILED.message(); + } + return errors.entrySet().stream() + .map(entry -> entry.getKey() + ": " + entry.getValue()) + .collect(java.util.stream.Collectors.joining(";")); + } + private ResponseEntity> failure(ResultCode code, String message, HttpServletRequest request) { ApiResponse body = ApiResponse.failure(code, message, diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index d2c6c808b..0df079856 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -7,6 +7,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.test.util.ReflectionTestUtils; @@ -43,6 +46,21 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); } + @Test + void validationDetailsAreIncludedInMessageAndData() { + MockHttpServletRequest request = new MockHttpServletRequest(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request"); + bindingResult.addError(new FieldError("request", "carrierId", "不能为null")); + bindingResult.addError(new FieldError("request", "carrierName", "不能为空")); + + var response = handler.handleBinding(new BindException(bindingResult), request); + + assertThat(response.getBody().code()).isEqualTo(ResultCode.VALIDATION_FAILED.code()); + assertThat(response.getBody().message()).isEqualTo("carrierId: 不能为null;carrierName: 不能为空"); + assertThat(response.getBody().data()).containsEntry("carrierId", "不能为null") + .containsEntry("carrierName", "不能为空"); + } + @Test void recordsBusinessFailureMetricByFiniteResultCode() { SimpleMeterRegistry registry = new SimpleMeterRegistry(); diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 2c11ba65a..3ebd532c5 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -186,6 +186,28 @@ SELECT 上述三个结果 `public_scope_violations`、`customer_scope_violations`、`parent_child_scope_mismatches` 必须都为 `0`。 +## 船司 FBA 价格公共作用域迁移 + +- `fl_carrier_fba_rate` 的船司 FBA 价格固定归平台作用域 `tenant_id = 0`;客户计费规则仍按客户 `tenant_id > 0` 隔离。 +- 固定入口会先检查存量价格的 `(rate_code, rate_version)` 重复,以及相同匹配维度的有效期重叠;发现冲突时通过 `SQLSTATE 45000` 中止,不会自动覆盖价格。 +- 无冲突时,固定入口才会把存量船司价格迁移到 `tenant_id = 0`,并在末尾验证不存在非平台价格作用域。迁移完成后再启动或重启 `firstleg-service`。 + +执行前可只读盘点: + +```sql +SELECT tenant_id, COUNT(*) AS total + FROM baiyue_firstleg.fl_carrier_fba_rate + GROUP BY tenant_id + ORDER BY tenant_id; + +SELECT rate_code, rate_version, COUNT(*) AS total + FROM baiyue_firstleg.fl_carrier_fba_rate + GROUP BY rate_code, rate_version +HAVING COUNT(*) > 1; +``` + +如果固定入口因价格冲突中止,应先由业务确认保留版本、有效期和价格金额,再清理冲突后重新完整执行脚本;不要直接手工把冲突记录改为 `0`。 + ## 海外仓阶梯费用增量 - `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6ff72d6a6..accdd4f6e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2527,8 +2527,8 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码', rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', @@ -3053,6 +3053,61 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; DROP PROCEDURE IF EXISTS phase2_fba_add_column; +-- 船司 FBA 价格是平台公共主数据;迁移前检查版本和有效期冲突,避免静默覆盖客户价格。 +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; +DELIMITER $$ +CREATE PROCEDURE phase2_fba_migrate_carrier_rate_scope() +BEGIN + DECLARE v_conflicts INT DEFAULT 0; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT rate_code, rate_version + FROM fl_carrier_fba_rate + GROUP BY rate_code, rate_version + HAVING COUNT(*) > 1 + ) AS duplicate_versions; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:不同客户存在重复价格编码版本,请先合并'; + END IF; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT first_rate.id + FROM fl_carrier_fba_rate first_rate + JOIN fl_carrier_fba_rate second_rate + ON first_rate.id < second_rate.id + AND first_rate.status = 'ACTIVE' + AND second_rate.status = 'ACTIVE' + AND first_rate.origin_warehouse_id <=> second_rate.origin_warehouse_id + AND first_rate.business_type <=> second_rate.business_type + AND first_rate.channel_code <=> second_rate.channel_code + AND first_rate.carrier_id <=> second_rate.carrier_id + AND first_rate.destination_code <=> second_rate.destination_code + AND first_rate.charge_unit <=> second_rate.charge_unit + AND first_rate.effective_from < COALESCE(second_rate.effective_to, '9999-12-31 23:59:59.999') + AND second_rate.effective_from < COALESCE(first_rate.effective_to, '9999-12-31 23:59:59.999') + LIMIT 1 + ) AS overlapping_rates; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:公共匹配维度存在有效期重叠,请先合并'; + END IF; + + UPDATE fl_carrier_fba_rate + SET tenant_id = 0 + WHERE tenant_id <> 0; + + IF EXISTS (SELECT 1 FROM fl_carrier_fba_rate WHERE tenant_id <> 0) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:仍存在非平台价格作用域'; + END IF; +END$$ +DELIMITER ; +CALL phase2_fba_migrate_carrier_rate_scope(); +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; + -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java index 4ad0fdf8f..80d64f61b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java @@ -75,19 +75,20 @@ public class PlatformFbaMasterDataController { @GetMapping("/carrier-rates") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')") - @Operation(summary = "查询船司 FBA 价格版本", description = "默认查询全部企业;tenantId 可用于筛选目标企业") + @Operation(summary = "查询船司 FBA 价格版本", description = "查询平台公共价格;所有客户共享") public ApiResponse> rates( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) Long tenantId, @RequestParam(required = false) String rateCode) { - return ApiResponse.success(service.listRates(optionalPlatformTenant(user, tenantId), rateCode)); + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.listRates(rateCode)); } @GetMapping("/carrier-rates/{id}") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')") public ApiResponse rate( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) { - return ApiResponse.success(service.rate(FirstlegScopeGuard.platform(user, tenantId), id)); + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.rate(id)); } @PostMapping("/carrier-rates/versions") @@ -95,10 +96,10 @@ public class PlatformFbaMasterDataController { @BusinessAudit(event = "FBA_RATE_VERSION_CREATE", resourceType = "CARRIER_FBA_RATE", description = "创建船司FBA价格版本") public ApiResponse createRate( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody CarrierRateVersionRequest request) { - long scope = FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.createRateVersion(scope, user.userId(), request)); + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.createRateVersion(user.userId(), request)); } @DeleteMapping("/carrier-rates/{id}") @@ -106,8 +107,9 @@ public class PlatformFbaMasterDataController { @BusinessAudit(event = "FBA_RATE_RETIRE", resourceType = "CARRIER_FBA_RATE", resourceId = "#id", description = "停用船司FBA价格版本") public ApiResponse retireRate(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, @PathVariable long id) { - service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId()); + @PathVariable long id) { + FirstlegScopeGuard.platform(user); + service.retireRate(id, user.userId()); return ApiResponse.success(null); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java index 9dd81bd6a..892552ecc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -15,6 +15,9 @@ import java.time.LocalDateTime; @Setter @TableName("fl_carrier_fba_rate") public class CarrierFbaRateEntity extends BaseEntity { + /** 船司价格是平台公共主数据,不绑定任何客户。 */ + public static final long PLATFORM_SCOPE = 0L; + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long tenantId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 448445e87..82e14bcac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -50,9 +50,9 @@ public class FbaPricingRepository { .eq(CustomerChargeRuleEntity::getId, id)); } - public CarrierFbaRateEntity findRate(long tenantId, long id) { + public CarrierFbaRateEntity findRate(long id) { return rates.selectOne(new LambdaQueryWrapper() - .eq(CarrierFbaRateEntity::getTenantId, tenantId) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(CarrierFbaRateEntity::getId, id)); } @@ -64,21 +64,34 @@ public class FbaPricingRepository { .orderByDesc(CustomerChargeRuleEntity::getRuleVersion)); } - public List listRates(Long tenantId, String rateCode) { + public List listRates(String rateCode) { return rates.selectList(new LambdaQueryWrapper() - .eq(tenantId != null, CarrierFbaRateEntity::getTenantId, tenantId) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(rateCode != null && !rateCode.isBlank(), CarrierFbaRateEntity::getRateCode, rateCode) .orderByAsc(CarrierFbaRateEntity::getRateCode) .orderByDesc(CarrierFbaRateEntity::getRateVersion)); } + /** + * 在创建平台公共价格版本时锁定当前公共价格集合,串行化版本号和有效期检查。 + * 调用方必须在事务中使用该方法。 + */ + public List listRatesForUpdate(String rateCode) { + return rates.selectList(new LambdaQueryWrapper() + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) + .eq(rateCode != null && !rateCode.isBlank(), CarrierFbaRateEntity::getRateCode, rateCode) + .orderByAsc(CarrierFbaRateEntity::getRateCode) + .orderByDesc(CarrierFbaRateEntity::getRateVersion) + .last("FOR UPDATE")); + } + public int nextRuleVersion(long tenantId, String ruleCode) { List values = listRules(tenantId, ruleCode); return values.isEmpty() ? 1 : values.get(0).getRuleVersion() + 1; } - public int nextRateVersion(long tenantId, String rateCode) { - List values = listRates(tenantId, rateCode); + public int nextRateVersion(String rateCode) { + List values = listRatesForUpdate(rateCode); return values.isEmpty() ? 1 : values.get(0).getRateVersion() + 1; } @@ -91,9 +104,9 @@ public class FbaPricingRepository { .set(CustomerChargeRuleEntity::getUpdatedBy, operatorId)); } - public int retireRate(long tenantId, long id, long operatorId) { + public int retireRate(long id, long operatorId) { return rates.update(null, new LambdaUpdateWrapper() - .eq(CarrierFbaRateEntity::getTenantId, tenantId) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(CarrierFbaRateEntity::getId, id) .eq(CarrierFbaRateEntity::getStatus, "ACTIVE") .set(CarrierFbaRateEntity::getStatus, "INACTIVE") @@ -121,7 +134,7 @@ public class FbaPricingRepository { public List matchRates(InboundPricingEntity inbound, String chargeUnit, LocalDateTime at) { return rates.selectList(new LambdaQueryWrapper() - .eq(CarrierFbaRateEntity::getTenantId, inbound.getTenantId()) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(CarrierFbaRateEntity::getOriginWarehouseId, inbound.getWarehouseId()) .eq(CarrierFbaRateEntity::getBusinessType, inbound.getBusinessType()) .eq(CarrierFbaRateEntity::getChannelCode, inbound.getChannelCode()) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java index 59b60be6a..4a9afefe0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java @@ -59,10 +59,9 @@ public class FbaMasterDataService { } @Transactional - public CarrierFbaRateEntity createRateVersion(long tenantId, long operatorId, - CarrierRateVersionRequest request) { + public CarrierFbaRateEntity createRateVersion(long operatorId, CarrierRateVersionRequest request) { validatePeriod(request.effectiveFrom(), request.effectiveTo()); - boolean overlaps = repository.listRates(tenantId, null).stream() + boolean overlaps = repository.listRatesForUpdate(null).stream() .filter(v -> "ACTIVE".equals(v.getStatus())) .filter(v -> Objects.equals(v.getOriginWarehouseId(), request.originWarehouseId())) .filter(v -> Objects.equals(v.getBusinessType(), request.businessType())) @@ -75,9 +74,9 @@ public class FbaMasterDataService { if (overlaps) throw conflict("船司 FBA 价格有效期重叠,无法保证唯一匹配"); CarrierFbaRateEntity value = new CarrierFbaRateEntity(); - value.setTenantId(tenantId); + value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setRateCode(request.rateCode()); - value.setRateVersion(repository.nextRateVersion(tenantId, request.rateCode())); + value.setRateVersion(repository.nextRateVersion(request.rateCode())); value.setOriginWarehouseId(request.originWarehouseId()); value.setOriginWarehouseName(request.originWarehouseName()); value.setBusinessType(request.businessType()); @@ -103,8 +102,8 @@ public class FbaMasterDataService { return repository.listRules(tenantId, code); } - public List listRates(Long tenantId, String code) { - return repository.listRates(tenantId, code); + public List listRates(String code) { + return repository.listRates(code); } public CustomerChargeRuleEntity rule(long tenantId, long id) { @@ -113,8 +112,8 @@ public class FbaMasterDataService { return value; } - public CarrierFbaRateEntity rate(long tenantId, long id) { - CarrierFbaRateEntity value = repository.findRate(tenantId, id); + public CarrierFbaRateEntity rate(long id) { + CarrierFbaRateEntity value = repository.findRate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司 FBA 价格版本不存在"); return value; } @@ -125,8 +124,8 @@ public class FbaMasterDataService { } @Transactional - public void retireRate(long tenantId, long id, long operatorId) { - if (repository.retireRate(tenantId, id, operatorId) != 1) throw conflict("价格已停用或不存在"); + public void retireRate(long id, long operatorId) { + if (repository.retireRate(id, operatorId) != 1) throw conflict("价格已停用或不存在"); } private static void validatePeriod(LocalDateTime from, LocalDateTime to) { diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql index 5024450c5..5c5cb23d0 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -56,6 +56,61 @@ SET @sql = IF(@resource_tenant_needs_modify > 0, 'SELECT 1'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- 船司 FBA 价格是平台公共主数据;迁移前检查版本和有效期冲突,避免静默覆盖客户价格。 +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; +DELIMITER $$ +CREATE PROCEDURE phase2_fba_migrate_carrier_rate_scope() +BEGIN + DECLARE v_conflicts INT DEFAULT 0; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT rate_code, rate_version + FROM fl_carrier_fba_rate + GROUP BY rate_code, rate_version + HAVING COUNT(*) > 1 + ) AS duplicate_versions; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:不同客户存在重复价格编码版本,请先合并'; + END IF; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT first_rate.id + FROM fl_carrier_fba_rate first_rate + JOIN fl_carrier_fba_rate second_rate + ON first_rate.id < second_rate.id + AND first_rate.status = 'ACTIVE' + AND second_rate.status = 'ACTIVE' + AND first_rate.origin_warehouse_id <=> second_rate.origin_warehouse_id + AND first_rate.business_type <=> second_rate.business_type + AND first_rate.channel_code <=> second_rate.channel_code + AND first_rate.carrier_id <=> second_rate.carrier_id + AND first_rate.destination_code <=> second_rate.destination_code + AND first_rate.charge_unit <=> second_rate.charge_unit + AND first_rate.effective_from < COALESCE(second_rate.effective_to, '9999-12-31 23:59:59.999') + AND second_rate.effective_from < COALESCE(first_rate.effective_to, '9999-12-31 23:59:59.999') + LIMIT 1 + ) AS overlapping_rates; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:公共匹配维度存在有效期重叠,请先合并'; + END IF; + + UPDATE fl_carrier_fba_rate + SET tenant_id = 0 + WHERE tenant_id <> 0; + + IF EXISTS (SELECT 1 FROM fl_carrier_fba_rate WHERE tenant_id <> 0) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:仍存在非平台价格作用域'; + END IF; +END$$ +DELIMITER ; +CALL phase2_fba_migrate_carrier_rate_scope(); +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; + DROP PROCEDURE IF EXISTS phase2_fba_add_column; -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index aaf7ad059..21c363eb4 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -309,8 +309,8 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码', rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index c0038edae..cdf5048db 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -32,26 +32,24 @@ paths: responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/carrier-rates: get: - summary: 查询船司 FBA 价格版本 + summary: 查询平台公共船司 FBA 价格版本 description: 需要 firstleg:fba-pricing:rate:view 权限 parameters: - - {$ref: '#/components/parameters/OptionalTenantId'} - {name: rateCode, in: query, schema: {type: string, maxLength: 50}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/carrier-rates/versions: post: - summary: 新建船司 FBA 价格版本 - parameters: [{$ref: '#/components/parameters/TenantId'}] + summary: 新建平台公共船司 FBA 价格版本 requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CarrierRateVersionRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/carrier-rates/{id}: get: summary: 查询船司 FBA 价格版本详情 - parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + parameters: [{$ref: '#/components/parameters/Id'}] responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} delete: summary: 停用船司 FBA 价格版本 - parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + parameters: [{$ref: '#/components/parameters/Id'}] responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index cf912bf50..c689185fe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -13,20 +13,26 @@ import static org.assertj.core.api.Assertions.assertThat; @DisplayName("FBA计费OpenAPI契约") class FbaPricingContractTest { @Test - @DisplayName("只有基础资料列表允许省略企业参数") - void onlyMasterDataListsAllowOptionalTenant() { - for (String listMethod : new String[] {"rules", "rates"}) { - Method method = method(listMethod); - assertThat(method.getParameterTypes()[1]).isEqualTo(Long.class); - assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); - } + @DisplayName("客户规则保留客户参数,船司价格使用平台公共作用域") + void keepsCustomerRuleScopeAndRemovesCarrierRateTenantScope() { + Method rules = method("rules"); + assertThat(rules.getParameterTypes()[1]).isEqualTo(Long.class); + assertThat(rules.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); - for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", - "rate", "createRate", "retireRate"}) { + Method rates = method("rates"); + assertThat(rates.getParameterTypes()).doesNotContain(Long.class); + + for (String scopedMethod : new String[] {"rule", "createRule", "retireRule"}) { Method method = method(scopedMethod); assertThat(method.getParameterTypes()[1]).isEqualTo(long.class); assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); } + + for (String publicMethod : new String[] {"rate", "createRate", "retireRate"}) { + assertThat(java.util.Arrays.stream(method(publicMethod).getParameters()) + .map(parameter -> parameter.getAnnotation(RequestParam.class))) + .allMatch(annotation -> annotation == null); + } } @Test @@ -62,7 +68,7 @@ class FbaPricingContractTest { openapi.indexOf("/api/platform/v1/fba-pricing/carrier-rates:"), openapi.indexOf("/api/platform/v1/fba-pricing/carrier-rates/versions:")); assertThat(carrierRates) - .contains("OptionalTenantId") + .doesNotContain("OptionalTenantId") .contains("name: rateCode"); assertThat(openapi) .contains("OptionalTenantId: {name: tenantId, in: query, required: false") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 653c42a8b..454cfe888 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper; import com.baiyue.firstleg.mapper.InboundFeeItemMapper; @@ -48,6 +49,8 @@ class FbaPricingRepositoryRuleListTest { CustomerChargeRuleEntity.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), CarrierFbaRateEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), + InboundPricingEntity.class); } @Test @@ -61,14 +64,52 @@ class FbaPricingRepositoryRuleListTest { } @Test - void platformRateListOmitsTenantConditionWhenTenantIsAbsent() { - assertThat(rateQueryValues(null, null)).isEmpty(); + void platformRateListAlwaysUsesPublicScope() { + assertThat(rateQueryValues(null)).containsExactly(CarrierFbaRateEntity.PLATFORM_SCOPE); } @Test - void platformRateListKeepsTenantAndRateCodeFiltersWhenProvided() { - assertThat(rateQueryValues(10001L, "RATE-1")) - .containsExactlyInAnyOrder(10001L, "RATE-1"); + void platformRateListKeepsPublicScopeAndRateCodeFiltersWhenProvided() { + assertThat(rateQueryValues("RATE-1")) + .containsExactlyInAnyOrder(CarrierFbaRateEntity.PLATFORM_SCOPE, "RATE-1"); + } + + @Test + void platformRateCreateQueryLocksPublicScope() { + when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.listRatesForUpdate(null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rates).selectList(wrapper.capture()); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("FOR UPDATE"); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .containsExactly(CarrierFbaRateEntity.PLATFORM_SCOPE); + } + + @Test + void carrierRateMatchingAlwaysUsesPlatformScope() { + InboundPricingEntity inbound = new InboundPricingEntity(); + inbound.setTenantId(10001L); + inbound.setWarehouseId(21L); + inbound.setBusinessType("FBA_SEA"); + inbound.setChannelCode("SEA-TAX"); + inbound.setCarrierId(31L); + inbound.setDestinationCode("ONT8"); + when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.matchRates(inbound, "KG", java.time.LocalDateTime.of(2026, 8, 1, 0, 0)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rates).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .contains(CarrierFbaRateEntity.PLATFORM_SCOPE) + .doesNotContain(10001L); } @Test @@ -122,10 +163,10 @@ class FbaPricingRepositoryRuleListTest { return wrapper.getValue().getParamNameValuePairs().values(); } - private Collection rateQueryValues(Long tenantId, String rateCode) { + private Collection rateQueryValues(String rateCode) { when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); - repository.listRates(tenantId, rateCode); + repository.listRates(rateCode); @SuppressWarnings("unchecked") ArgumentCaptor> wrapper = diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 36a47d521..fec46d05c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -38,4 +38,18 @@ class InboundWorkflowSqlContractTest { .contains("SET warehouse_resource_tenant_id = 0") .contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL"); } + + @Test + @DisplayName("船司价格存量迁移固定到平台作用域并拒绝冲突") + void shouldMigrateCarrierRatesToPublicScopeWithoutOverwritingConflicts() throws Exception { + String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(migration) + .contains("phase2_fba_migrate_carrier_rate_scope") + .contains("GROUP BY rate_code, rate_version") + .contains("公共匹配维度存在有效期重叠") + .contains("SET tenant_id = 0") + .contains("WHERE tenant_id <> 0"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java index b0e85db55..dec7edf1d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; +import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.repository.FbaPricingRepository; import jakarta.annotation.Resource; @@ -73,11 +75,33 @@ class FbaMasterDataServiceTest { .hasMessageContaining("有效期重叠"); } + @Test + @DisplayName("船司价格版本固定写入平台公共作用域") + void createsCarrierRateInPlatformScope() { + when(repository.listRatesForUpdate(null)).thenReturn(List.of()); + when(repository.nextRateVersion("RATE-1")).thenReturn(1); + doAnswer(invocation -> { ((CarrierFbaRateEntity) invocation.getArgument(0)).setId(20L); return null; }) + .when(repository).insertRate(any()); + + CarrierFbaRateEntity created = service.createRateVersion(2L, carrierRateRequest()); + + assertThat(created.getTenantId()).isEqualTo(CarrierFbaRateEntity.PLATFORM_SCOPE); + verify(repository).listRatesForUpdate(null); + verify(repository).nextRateVersion("RATE-1"); + verify(repository).insertRate(any()); + } + private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to) { return new CustomerRuleVersionRequest("CR-1", 100L, "客户A", "FBA_SEA", "KG", "TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null); } + private CarrierRateVersionRequest carrierRateRequest() { + return new CarrierRateVersionRequest("RATE-1", 21L, "义乌仓", "FBA_SEA", "SEA-TAX", + 31L, "船司A", "ONT8", "KG", new BigDecimal("2.50"), "USD", new BigDecimal("10"), + AUGUST, null, null); + } + private CustomerChargeRuleEntity activeRule(LocalDateTime from, LocalDateTime to) { CustomerChargeRuleEntity value = new CustomerChargeRuleEntity(); value.setRuleCode("CR-1"); value.setRuleVersion(1); value.setCustomerId(100L); diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 481e3cc3b..06ec9cda8 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -155,6 +155,19 @@ public class PlatformFirstLegOrderController { return transit(user, tenantId, id, OrderStatus.APPROVED, null); } + @BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商") + @Operation(summary = "指派承运商", description = "为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填。") + @PutMapping("/{id}/carrier") + @PreAuthorize("hasAuthority('platform:order:approve')") + public ApiResponse assignCarrier(@AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, + @Parameter(description = "订单 ID", required = true) @PathVariable long id, + @Valid @RequestBody OrderRequests.CarrierRequest request) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + service.assignCarrier(scopedTenant, user.userId(), id, request); + return ApiResponse.success(null); + } + @BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单") @Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。") @PostMapping("/{id}/reject") diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 8e66b50ca..ab1d9fc81 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -3,6 +3,7 @@ package com.baiyue.order.dto; import com.baiyue.order.entity.OrderStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; @@ -55,8 +56,10 @@ public final class OrderRequests { @NotBlank @Size(max = 128) String receivingWarehouseName, @NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 128) String channelName, - @NotNull Long carrierId, - @NotBlank @Size(max = 128) String carrierName, + @Schema(description = "Carrier ID snapshot; optional until the order channel resolves a carrier / 承运商 ID 快照,渠道明确承运商后可传") + @Min(1) Long carrierId, + @Schema(description = "Carrier name snapshot; optional until the order channel resolves a carrier / 承运商名称快照,渠道明确承运商后可传", maxLength = 128) + @Size(max = 128) String carrierName, @NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode, @Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode, @NotNull Boolean customsInspection, @@ -64,6 +67,17 @@ public final class OrderRequests { @Size(max = 32) String amazonWarehouseCode, @Size(max = 64) String shipmentId, @Size(max = 64) String referenceId + ) { + @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") + public boolean isCarrierSnapshotComplete() { + return (carrierId == null) == (carrierName == null || carrierName.isBlank()); + } + } + + @Schema(name = "FirstLegOrderCarrierRequest", description = "Platform carrier assignment / 平台指派承运商") + public record CarrierRequest( + @Schema(description = "Active public carrier ID / 启用的公共承运商 ID", example = "193000000000300001", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull @Min(1) Long carrierId ) { } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 9c036b27e..ed44baa9e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -73,6 +73,19 @@ public class OrderApplicationService { updateOrderData(tenantId, userId, id, request, true); } + @Transactional + public void assignCarrier(long tenantId, long userId, long id, OrderRequests.CarrierRequest request) { + OrderEntity order = require(tenantId, id); + if (order.getStatus() != OrderStatus.SUBMITTED) { + throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "仅待审核订单可以指派承运商"); + } + var carrier = masterDataGuard.validateCarrier(request.carrierId()); + order.setCarrierId(carrier.id()); + order.setCarrierName(carrier.partnerName()); + order.setUpdatedBy(userId); + if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); + } + private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request, boolean allowSubmitted) { OrderEntity order = require(tenantId, id); @@ -105,6 +118,14 @@ public class OrderApplicationService { masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); } + if (target == OrderStatus.APPROVED) { + if (order.getCarrierId() == null || !StringUtils.hasText(order.getCarrierName())) { + throw new BizException(com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, + "请先选择有效承运商后再审核通过"); + } + var carrier = masterDataGuard.validateCarrier(order.getCarrierId()); + order.setCarrierName(carrier.partnerName()); + } if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) { throw new BizException("Reason is required"); } @@ -327,7 +348,9 @@ public class OrderApplicationService { order.setReceivingTime(formatReceivingTime(snapshots.receiving())); order.setChannelCode(request.channelCode()); order.setChannelName(request.channelName()); - order.setCarrierId(request.carrierId()); order.setCarrierName(request.carrierName()); + order.setCarrierId(request.carrierId()); + order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName()) + ? null : request.carrierName().trim()); order.setTaxMode(request.taxMode()); order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection()); order.setDestinationType(request.destinationType()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 3840cb691..3bee96ab3 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -78,6 +78,37 @@ public class OrderMasterDataGuard { throw downstreamFailure(exception); } } + public PartnerClient.Partner validateCarrier(long id) { + try { + ApiResponse response; + try { + response = partners.active(token, publicTenantId, id); + } catch (FeignException.NotFound exception) { + throw new BizException(ResultCode.NOT_FOUND, "承运商不存在或不可用"); + } + requireSuccessful(response, "承运商"); + if (response.data() == null) throw unavailable("承运商"); + var value = response.data(); + if (!Long.valueOf(id).equals(value.id())) throw unavailable("承运商"); + if (!Long.valueOf(publicTenantId).equals(value.tenantId())) + throw new BizException(ResultCode.FORBIDDEN, "承运商不属于平台公共主数据"); + if (!"ACTIVE".equals(value.status())) + throw new BizException(ResultCode.CONFLICT, "承运商已停用"); + boolean hasCarrierType = value.types() != null && value.types().contains("CARRIER"); + boolean legacyCarrierType = (value.types() == null || value.types().isEmpty()) + && "CARRIER".equals(value.partnerType()); + if (!hasCarrierType && !legacyCarrierType) + throw new BizException(ResultCode.VALIDATION_FAILED, "所选往来单位不具备承运商类型"); + if (!StringUtils.hasText(value.partnerName())) throw unavailable("承运商"); + return value; + } catch (FeignException exception) { + throw downstreamFailure(exception); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw downstreamFailure(exception); + } + } private PartnerClient.Partner loadPartner(long id) { ApiResponse response; try { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 5be30c370..408cbd66d 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 柏悦头程订单 API description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。 - version: 1.3.0 + version: 1.4.0 security: - bearerAuth: [] paths: @@ -200,6 +200,25 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:edit 权限} + /api/platform/v1/orders/first-leg/{id}/carrier: + put: + summary: 为待审核订单指派承运商 + description: 只接收承运商 ID;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照。审核通过前必须完成指派。 + parameters: + - $ref: '#/components/parameters/OrderId' + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [carrierId] + properties: + carrierId: {type: string, pattern: '^[1-9][0-9]*$'} + responses: + '200': {description: 指派成功或业务失败,按响应体 code 判断} + '403': {description: 需要 platform:order:approve 权限} components: securitySchemes: bearerAuth: diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 03870fd24..80b72d7d4 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -41,6 +41,8 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')", AuthenticatedUser.class, long.class, long.class, String.class); + assertAuthority(PlatformFirstLegOrderController.class, "assignCarrier", "hasAuthority('platform:order:approve')", + AuthenticatedUser.class, long.class, long.class, OrderRequests.CarrierRequest.class); } @Test diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java new file mode 100644 index 000000000..308fee7fe --- /dev/null +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -0,0 +1,41 @@ +package com.baiyue.order.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderRequestsValidationTest { + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() { + assertThat(validator.validate(request(null, null))).isEmpty(); + } + + @Test + void carrierSnapshotMustBeBothPresentOrBothAbsent() { + assertThat(validator.validate(request(9L, null))) + .anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空")); + assertThat(validator.validate(request(null, "船司"))) + .anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空")); + assertThat(validator.validate(request(9L, "船司"))).isEmpty(); + } + + private OrderRequests.SaveRequest request(Long carrierId, String carrierName) { + var cargo = new OrderRequests.Cargo( + "货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, + null, null, null, null, null, false, null, null, null, null, + null, null, null, null, null, null, null, null, null); + var request = new OrderRequests.SaveRequest( + null, "当前企业", "SEA", "FOB", "US", 193000000000200003L, "洛杉矶仓", + null, null, null, null, List.of(cargo), "SELF_EXPRESS", 193000000000200001L, + "义乌仓", "SEA-US-GENERAL", "美线普货专线", carrierId, carrierName, "BUY_DECLARATION", + "FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789"); + return request; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index d04e34ebc..9a24965f5 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -101,6 +101,58 @@ class OrderApplicationServiceStateTest { && "UPDATE".equals(history.getAction()))); } + @Test + void platformAssignsTrustedCarrierSnapshotToSubmittedOrder() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); + when(masterData.validateCarrier(9L)).thenReturn( + new PartnerClient.Partner(9L, 0L, "服务端船司名", "ACTIVE", "CARRIER", List.of("CARRIER"))); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .assignCarrier(10001L, 2L, 1L, new OrderRequests.CarrierRequest(9L)); + + assertThat(order.getCarrierId()).isEqualTo(9L); + assertThat(order.getCarrierName()).isEqualTo("服务端船司名"); + verify(repository).saveOrder(10001L, order); + } + + @Test + void approvalRequiresCarrierAssignment() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("请先选择有效承运商"); + + verify(repository, never()).saveOrder(anyLong(), any()); + } + + @Test + void approvalRevalidatesCarrierAndRefreshesItsName() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + order.setCarrierId(9L); + order.setCarrierName("旧名称"); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); + when(masterData.validateCarrier(9L)).thenReturn( + new PartnerClient.Partner(9L, 0L, "最新船司名", "ACTIVE", "CARRIER", List.of("CARRIER"))); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED); + assertThat(order.getCarrierName()).isEqualTo("最新船司名"); + verify(masterData).validateCarrier(9L); + } + @Test void merchantDraftEditorStillCannotEditSubmittedOrder() { OrderRepository repository = mock(OrderRepository.class); @@ -297,4 +349,12 @@ class OrderApplicationServiceStateTest { order.setDestinationType(destinationType); return order; } + + private OrderEntity persistedOrder(OrderStatus status) { + OrderEntity order = new OrderEntity(); + order.setId(1L); + order.setTenantId(10001L); + order.setStatus(status); + return order; + } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 1bf86be13..48e0a44c4 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -45,6 +45,31 @@ class OrderMasterDataGuardTest { assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓"); } @Test + void carrierValidationReturnsTrustedPublicCarrierSnapshot() { + var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "服务端船司名", "ACTIVE", "CARRIER", List.of("SUPPLIER", "CARRIER")); + when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier)); + + assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("服务端船司名"); + } + @Test + void carrierValidationSupportsLegacySingleTypeSnapshot() { + var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "旧数据船司", "ACTIVE", "CARRIER", null); + when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier)); + + assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("旧数据船司"); + } + @Test + void partnerWithoutCarrierTypeCannotBeAssignedToOrder() { + var supplier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "普通供应商", "ACTIVE", "SUPPLIER", List.of("SUPPLIER")); + when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(supplier)); + + assertThatThrownBy(() -> guard.validateCarrier(9L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不具备承运商类型") + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + } + @Test void inactiveOrCrossTenantMasterDataIsRejected() { when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null)); assertThatThrownBy(() -> guard.validate(10002,request(7L,8L))) From d2e3fef8f81dcebb6411ddf278ad9debbe0c4a00 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 18:14:29 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 24 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 55 --- .../db/phase2-fba-pricing-migration.sql | 55 --- .../InboundWorkflowSqlContractTest.java | 14 - .../二期测试环境API验收-20260806180457.json | 96 ++++ .../二期测试环境API验收-20260806180542.json | 57 +++ .../二期测试环境API验收-20260806180635.json | 45 ++ .../二期测试环境API验收-20260806180746.json | 101 ++++ .../二期测试环境API验收-20260806180915.json | 201 ++++++++ .../二期测试环境API验收-20260806181135.json | 92 ++++ .../二期测试环境API验收-20260806181211.json | 103 +++++ .../二期测试环境API验收-20260806181252.json | 103 +++++ .../二期测试环境API验收-20260806181400.json | 390 ++++++++++++++++ scripts/phase2-test-env-acceptance.ps1 | 431 ++++++++++++++++++ 14 files changed, 1623 insertions(+), 144 deletions(-) create mode 100644 reports/二期测试环境API验收-20260806180457.json create mode 100644 reports/二期测试环境API验收-20260806180542.json create mode 100644 reports/二期测试环境API验收-20260806180635.json create mode 100644 reports/二期测试环境API验收-20260806180746.json create mode 100644 reports/二期测试环境API验收-20260806180915.json create mode 100644 reports/二期测试环境API验收-20260806181135.json create mode 100644 reports/二期测试环境API验收-20260806181211.json create mode 100644 reports/二期测试环境API验收-20260806181252.json create mode 100644 reports/二期测试环境API验收-20260806181400.json create mode 100644 scripts/phase2-test-env-acceptance.ps1 diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 3ebd532c5..406fc1eb1 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -186,27 +186,11 @@ SELECT 上述三个结果 `public_scope_violations`、`customer_scope_violations`、`parent_child_scope_mismatches` 必须都为 `0`。 -## 船司 FBA 价格公共作用域迁移 +## 船司 FBA 价格公共作用域 -- `fl_carrier_fba_rate` 的船司 FBA 价格固定归平台作用域 `tenant_id = 0`;客户计费规则仍按客户 `tenant_id > 0` 隔离。 -- 固定入口会先检查存量价格的 `(rate_code, rate_version)` 重复,以及相同匹配维度的有效期重叠;发现冲突时通过 `SQLSTATE 45000` 中止,不会自动覆盖价格。 -- 无冲突时,固定入口才会把存量船司价格迁移到 `tenant_id = 0`,并在末尾验证不存在非平台价格作用域。迁移完成后再启动或重启 `firstleg-service`。 - -执行前可只读盘点: - -```sql -SELECT tenant_id, COUNT(*) AS total - FROM baiyue_firstleg.fl_carrier_fba_rate - GROUP BY tenant_id - ORDER BY tenant_id; - -SELECT rate_code, rate_version, COUNT(*) AS total - FROM baiyue_firstleg.fl_carrier_fba_rate - GROUP BY rate_code, rate_version -HAVING COUNT(*) > 1; -``` - -如果固定入口因价格冲突中止,应先由业务确认保留版本、有效期和价格金额,再清理冲突后重新完整执行脚本;不要直接手工把冲突记录改为 `0`。 +- 当前测试环境 `fl_carrier_fba_rate` 为空表,不执行存量价格迁移。 +- 初始化表结构将船司 FBA 价格定义为平台公共价格,Firstleg 创建、查询和计费匹配固定使用 `tenant_id = 0`;客户计费规则仍按客户 `tenant_id > 0` 隔离。 +- 初始化或重建测试库后,再启动或重启 `firstleg-service`。 ## 海外仓阶梯费用增量 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index accdd4f6e..905ab22c9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3053,61 +3053,6 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; DROP PROCEDURE IF EXISTS phase2_fba_add_column; --- 船司 FBA 价格是平台公共主数据;迁移前检查版本和有效期冲突,避免静默覆盖客户价格。 -DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; -DELIMITER $$ -CREATE PROCEDURE phase2_fba_migrate_carrier_rate_scope() -BEGIN - DECLARE v_conflicts INT DEFAULT 0; - - SELECT COUNT(*) INTO v_conflicts - FROM ( - SELECT rate_code, rate_version - FROM fl_carrier_fba_rate - GROUP BY rate_code, rate_version - HAVING COUNT(*) > 1 - ) AS duplicate_versions; - IF v_conflicts > 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'FBA价格迁移失败:不同客户存在重复价格编码版本,请先合并'; - END IF; - - SELECT COUNT(*) INTO v_conflicts - FROM ( - SELECT first_rate.id - FROM fl_carrier_fba_rate first_rate - JOIN fl_carrier_fba_rate second_rate - ON first_rate.id < second_rate.id - AND first_rate.status = 'ACTIVE' - AND second_rate.status = 'ACTIVE' - AND first_rate.origin_warehouse_id <=> second_rate.origin_warehouse_id - AND first_rate.business_type <=> second_rate.business_type - AND first_rate.channel_code <=> second_rate.channel_code - AND first_rate.carrier_id <=> second_rate.carrier_id - AND first_rate.destination_code <=> second_rate.destination_code - AND first_rate.charge_unit <=> second_rate.charge_unit - AND first_rate.effective_from < COALESCE(second_rate.effective_to, '9999-12-31 23:59:59.999') - AND second_rate.effective_from < COALESCE(first_rate.effective_to, '9999-12-31 23:59:59.999') - LIMIT 1 - ) AS overlapping_rates; - IF v_conflicts > 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'FBA价格迁移失败:公共匹配维度存在有效期重叠,请先合并'; - END IF; - - UPDATE fl_carrier_fba_rate - SET tenant_id = 0 - WHERE tenant_id <> 0; - - IF EXISTS (SELECT 1 FROM fl_carrier_fba_rate WHERE tenant_id <> 0) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'FBA价格迁移失败:仍存在非平台价格作用域'; - END IF; -END$$ -DELIMITER ; -CALL phase2_fba_migrate_carrier_rate_scope(); -DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; - -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match'); diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql index 5c5cb23d0..5024450c5 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -56,61 +56,6 @@ SET @sql = IF(@resource_tenant_needs_modify > 0, 'SELECT 1'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; --- 船司 FBA 价格是平台公共主数据;迁移前检查版本和有效期冲突,避免静默覆盖客户价格。 -DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; -DELIMITER $$ -CREATE PROCEDURE phase2_fba_migrate_carrier_rate_scope() -BEGIN - DECLARE v_conflicts INT DEFAULT 0; - - SELECT COUNT(*) INTO v_conflicts - FROM ( - SELECT rate_code, rate_version - FROM fl_carrier_fba_rate - GROUP BY rate_code, rate_version - HAVING COUNT(*) > 1 - ) AS duplicate_versions; - IF v_conflicts > 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'FBA价格迁移失败:不同客户存在重复价格编码版本,请先合并'; - END IF; - - SELECT COUNT(*) INTO v_conflicts - FROM ( - SELECT first_rate.id - FROM fl_carrier_fba_rate first_rate - JOIN fl_carrier_fba_rate second_rate - ON first_rate.id < second_rate.id - AND first_rate.status = 'ACTIVE' - AND second_rate.status = 'ACTIVE' - AND first_rate.origin_warehouse_id <=> second_rate.origin_warehouse_id - AND first_rate.business_type <=> second_rate.business_type - AND first_rate.channel_code <=> second_rate.channel_code - AND first_rate.carrier_id <=> second_rate.carrier_id - AND first_rate.destination_code <=> second_rate.destination_code - AND first_rate.charge_unit <=> second_rate.charge_unit - AND first_rate.effective_from < COALESCE(second_rate.effective_to, '9999-12-31 23:59:59.999') - AND second_rate.effective_from < COALESCE(first_rate.effective_to, '9999-12-31 23:59:59.999') - LIMIT 1 - ) AS overlapping_rates; - IF v_conflicts > 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'FBA价格迁移失败:公共匹配维度存在有效期重叠,请先合并'; - END IF; - - UPDATE fl_carrier_fba_rate - SET tenant_id = 0 - WHERE tenant_id <> 0; - - IF EXISTS (SELECT 1 FROM fl_carrier_fba_rate WHERE tenant_id <> 0) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'FBA价格迁移失败:仍存在非平台价格作用域'; - END IF; -END$$ -DELIMITER ; -CALL phase2_fba_migrate_carrier_rate_scope(); -DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; - DROP PROCEDURE IF EXISTS phase2_fba_add_column; -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index fec46d05c..36a47d521 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -38,18 +38,4 @@ class InboundWorkflowSqlContractTest { .contains("SET warehouse_resource_tenant_id = 0") .contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL"); } - - @Test - @DisplayName("船司价格存量迁移固定到平台作用域并拒绝冲突") - void shouldMigrateCarrierRatesToPublicScopeWithoutOverwritingConflicts() throws Exception { - String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql") - .getContentAsString(StandardCharsets.UTF_8); - - assertThat(migration) - .contains("phase2_fba_migrate_carrier_rate_scope") - .contains("GROUP BY rate_code, rate_version") - .contains("公共匹配维度存在有效期重叠") - .contains("SET tenant_id = 0") - .contains("WHERE tenant_id <> 0"); - } } diff --git a/reports/二期测试环境API验收-20260806180457.json b/reports/二期测试环境API验收-20260806180457.json new file mode 100644 index 000000000..2f0f04838 --- /dev/null +++ b/reports/二期测试环境API验收-20260806180457.json @@ -0,0 +1,96 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806180457", + "startedAt": "2026-08-06T18:04:57.0000000", + "finishedAt": "2026-08-06T18:04:59.2138202+08:00", + "success": false, + "passedCount": 4, + "failedCount": 1, + "ids": { + "carrierId": "2085306128758538241", + "orderId": null, + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "创建头程订单草稿 失败:HTTP=200 code=A0422 message=Validation failed", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "" + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "" + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "" + }, + { + "name": "创建公共测试承运商", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "" + }, + { + "name": "创建头程订单草稿", + "passed": false, + "http": 200, + "code": "A0422", + "message": "Validation failed", + "requestId": "347014a0-f9e9-466a-8cc2-ff947cf946d3", + "traceId": "946f25aaf3824d029c5228b1fd4b52f8", + "evidence": "" + } + ] +} diff --git a/reports/二期测试环境API验收-20260806180542.json b/reports/二期测试环境API验收-20260806180542.json new file mode 100644 index 000000000..36e440c3f --- /dev/null +++ b/reports/二期测试环境API验收-20260806180542.json @@ -0,0 +1,57 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806180542", + "startedAt": "2026-08-06T18:05:42.0000000", + "finishedAt": "2026-08-06T18:05:42.1924377+08:00", + "success": false, + "passedCount": 0, + "failedCount": 1, + "ids": { + "carrierId": null, + "orderId": null, + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "平台登录 失败:HTTP=503 code= message=", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": false, + "http": 503, + "code": "", + "message": "", + "requestId": "93c3a0a1-684", + "traceId": null, + "evidence": "", + "details": null + } + ] +} diff --git a/reports/二期测试环境API验收-20260806180635.json b/reports/二期测试环境API验收-20260806180635.json new file mode 100644 index 000000000..c8ca59d8b --- /dev/null +++ b/reports/二期测试环境API验收-20260806180635.json @@ -0,0 +1,45 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806180635", + "startedAt": "2026-08-06T18:06:35.0000000", + "finishedAt": "2026-08-06T18:06:37.8507638+08:00", + "success": false, + "passedCount": 0, + "failedCount": 0, + "ids": { + "carrierId": null, + "orderId": null, + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "由于目标计算机积极拒绝,无法连接。 (8.163.30.128:9000)", + "billingDefect": null, + "steps": [] +} diff --git a/reports/二期测试环境API验收-20260806180746.json b/reports/二期测试环境API验收-20260806180746.json new file mode 100644 index 000000000..d6ee9f0c2 --- /dev/null +++ b/reports/二期测试环境API验收-20260806180746.json @@ -0,0 +1,101 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806180746", + "startedAt": "2026-08-06T18:07:46.0000000", + "finishedAt": "2026-08-06T18:07:49.7438673+08:00", + "success": false, + "passedCount": 4, + "failedCount": 1, + "ids": { + "carrierId": "2085306838682238977", + "orderId": null, + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "创建头程订单草稿 失败:HTTP=200 code=A0422 message=仓库未启用收货能力", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建公共测试承运商", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建头程订单草稿", + "passed": false, + "http": 200, + "code": "A0422", + "message": "仓库未启用收货能力", + "requestId": "879f056b-40c2-486b-b643-c6ff7ab1a224", + "traceId": "282c6a4652d649608deaa0989f1109f8", + "evidence": "", + "details": null + } + ] +} diff --git a/reports/二期测试环境API验收-20260806180915.json b/reports/二期测试环境API验收-20260806180915.json new file mode 100644 index 000000000..df2299ccc --- /dev/null +++ b/reports/二期测试环境API验收-20260806180915.json @@ -0,0 +1,201 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806180915", + "startedAt": "2026-08-06T18:09:15.0000000", + "finishedAt": "2026-08-06T18:09:18.4509682+08:00", + "success": false, + "passedCount": 13, + "failedCount": 1, + "ids": { + "warehouseId": "2085307213141311489", + "carrierId": "2085307212440862722", + "orderId": "2085307214373949442", + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "商户提交订单 失败:HTTP=200 code=A0403 message=All required enterprise qualifications must be approved", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建公共测试承运商", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建启用收货能力的公共测试仓", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建头程订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "重放创建订单幂等请求", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建订单幂等结果一致", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "orderId=2085307214373949442", + "details": null + }, + { + "name": "查询订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "订单初始状态为 DRAFT", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "status=DRAFT", + "details": null + }, + { + "name": "编辑头程订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "回查编辑后的订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "订单编辑内容已保存", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "customerRemark=API-E2E-20260806180915 编辑后草稿", + "details": null + }, + { + "name": "商户提交订单", + "passed": false, + "http": 200, + "code": "A0403", + "message": "All required enterprise qualifications must be approved", + "requestId": "43731de1-a837-47ed-944e-74a52c778c0d", + "traceId": "d65dce17b1974c0e85987a1f5b7b38c0", + "evidence": "", + "details": null + } + ] +} diff --git a/reports/二期测试环境API验收-20260806181135.json b/reports/二期测试环境API验收-20260806181135.json new file mode 100644 index 000000000..af961b56e --- /dev/null +++ b/reports/二期测试环境API验收-20260806181135.json @@ -0,0 +1,92 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806181135", + "startedAt": "2026-08-06T18:11:35.0000000", + "finishedAt": "2026-08-06T18:11:36.8065276+08:00", + "success": false, + "passedCount": 4, + "failedCount": 0, + "ids": { + "qualificationReviewId": null, + "warehouseId": null, + "carrierId": null, + "orderId": null, + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "The process cannot access the file 'C:\\Users\\BY-OY\\Desktop\\code\\chongchong-global\\apps\\pda-uniapp\\static\\c7.png' because it is being used by another process.", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "查询商户必备资质", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + } + ] +} diff --git a/reports/二期测试环境API验收-20260806181211.json b/reports/二期测试环境API验收-20260806181211.json new file mode 100644 index 000000000..b454c8b20 --- /dev/null +++ b/reports/二期测试环境API验收-20260806181211.json @@ -0,0 +1,103 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806181211", + "startedAt": "2026-08-06T18:12:11.0000000", + "finishedAt": "2026-08-06T18:12:12.6225155+08:00", + "success": false, + "passedCount": 4, + "failedCount": 1, + "ids": { + "qualificationReviewId": null, + "warehouseId": null, + "carrierId": null, + "orderId": null, + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "补齐商户营业执照与法人身份证测试包 失败:HTTP=200 code=B0001 message=Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "查询商户必备资质", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "补齐商户营业执照与法人身份证测试包", + "passed": false, + "http": 200, + "code": "B0001", + "message": "Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件", + "requestId": "a3322a13-862f-4176-b36a-7f5bf48dabbc", + "traceId": "158c6783070f4605b966a8bbb5fd624d", + "evidence": "", + "details": null + } + ] +} diff --git a/reports/二期测试环境API验收-20260806181252.json b/reports/二期测试环境API验收-20260806181252.json new file mode 100644 index 000000000..b89eb5aa9 --- /dev/null +++ b/reports/二期测试环境API验收-20260806181252.json @@ -0,0 +1,103 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806181252", + "startedAt": "2026-08-06T18:12:52.0000000", + "finishedAt": "2026-08-06T18:12:53.8062380+08:00", + "success": false, + "passedCount": 4, + "failedCount": 1, + "ids": { + "qualificationReviewId": null, + "warehouseId": null, + "carrierId": null, + "orderId": null, + "inboundId": null, + "measurementId": null, + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": null, + "measurement": null, + "weightResult": null + }, + "fatal": "补齐商户营业执照与法人身份证测试包 失败:HTTP=200 code=A0409 message=Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "查询商户必备资质", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "补齐商户营业执照与法人身份证测试包", + "passed": false, + "http": 200, + "code": "A0409", + "message": "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质", + "requestId": "c3dcb898-0edd-4244-97a0-e3b399a334ed", + "traceId": "18334f1be9ca4ee8ae0a88f98724cc51", + "evidence": "", + "details": null + } + ] +} diff --git a/reports/二期测试环境API验收-20260806181400.json b/reports/二期测试环境API验收-20260806181400.json new file mode 100644 index 000000000..3fb479e61 --- /dev/null +++ b/reports/二期测试环境API验收-20260806181400.json @@ -0,0 +1,390 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806181400", + "startedAt": "2026-08-06T18:14:00.0000000", + "finishedAt": "2026-08-06T18:14:07.3773798+08:00", + "success": false, + "passedCount": 30, + "failedCount": 1, + "ids": { + "tenantId": "2085308406756851714", + "qualificationReviewId": "2085308409122439169", + "warehouseId": "2085308410753515522", + "carrierId": "2085308410510245890", + "orderId": "2085308411180847106", + "inboundId": "2085308426690875393", + "measurementId": "2085308428624449538", + "weightResultId": null, + "fileId": null + }, + "versions": { + "inbound": 0, + "measurement": 0, + "weightResult": null + }, + "fatal": "上传称重图片并触发 OCR 失败:HTTP=200 code=A0400 message=称重图片仅支持JPG、PNG、BMP", + "billingDefect": null, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "查询商户必备资质", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "注册隔离的 API E2E 测试企业", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "登录隔离的 API E2E 测试企业", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "补齐商户营业执照与法人身份证测试包", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "提交商户资质审核", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "查询平台待审核企业", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "平台审核通过商户资质", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建公共测试承运商", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建启用收货能力的公共测试仓", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建头程订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "重放创建订单幂等请求", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建订单幂等结果一致", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "orderId=2085308411180847106", + "details": null + }, + { + "name": "查询订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "订单初始状态为 DRAFT", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "status=DRAFT", + "details": null + }, + { + "name": "编辑头程订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "回查编辑后的订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "订单编辑内容已保存", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "customerRemark=API-E2E-20260806181400 编辑后草稿", + "details": null + }, + { + "name": "商户提交订单", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "统一订单详情查询待审核状态", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "统一订单主状态为 PENDING_REVIEW", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "mainStatus=PENDING_REVIEW", + "details": null + }, + { + "name": "平台指派承运商", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "平台审核通过订单", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "异步生成入库单", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "inboundId=2085308426690875393", + "details": null + }, + { + "name": "查询入库单详情", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "入库单进入待测量状态", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "processStatus=WAIT_MEASURE, version=0", + "details": null + }, + { + "name": "查询初始测量详情", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "保存测量草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "上传称重图片并触发 OCR", + "passed": false, + "http": 200, + "code": "A0400", + "message": "称重图片仅支持JPG、PNG、BMP", + "requestId": "b527475c-3624-4050-8cb1-13ec7ec48fa5", + "traceId": "81f92a40cd554334a987d4c2ff4a8679", + "evidence": "", + "details": null + } + ] +} diff --git a/scripts/phase2-test-env-acceptance.ps1 b/scripts/phase2-test-env-acceptance.ps1 new file mode 100644 index 000000000..5bae36ab7 --- /dev/null +++ b/scripts/phase2-test-env-acceptance.ps1 @@ -0,0 +1,431 @@ +param( + [string]$GatewayBaseUrl = 'http://8.163.30.128:9000', + [string]$PlatformUsername = 'platform_admin', + [string]$MerchantUsername = 'merchant_admin', + [string]$Password = 'ChangeMe123!', + [long]$TenantId = 10001, + [string]$OutputPath = '' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$marker = "API-E2E-$stamp" +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $repoRoot "reports\二期测试环境API验收-$stamp.json" +} + +$steps = [System.Collections.Generic.List[object]]::new() +$ids = [ordered]@{ tenantId = [string]$TenantId; qualificationReviewId = $null; warehouseId = $null; carrierId = $null; orderId = $null; inboundId = $null; measurementId = $null; weightResultId = $null; fileId = $null } +$versions = [ordered]@{ inbound = $null; measurement = $null; weightResult = $null } +$fatal = $null +$billingDefect = $null + +function New-Key([string]$Action) { + return "$marker-$Action-$([guid]::NewGuid().ToString('N'))" +} + +function Invoke-Api( + [string]$Method, + [string]$Path, + [string]$Token = '', + [object]$Body = $null, + [hashtable]$ExtraHeaders = @{} +) { + $headers = @{} + if (-not [string]::IsNullOrWhiteSpace($Token)) { $headers.Authorization = "Bearer $Token" } + foreach ($entry in $ExtraHeaders.GetEnumerator()) { $headers[$entry.Key] = $entry.Value } + $args = @{ Method = $Method; Uri = "$GatewayBaseUrl$Path"; Headers = $headers; TimeoutSec = 30; SkipHttpErrorCheck = $true } + if ($null -ne $Body) { + $args.ContentType = 'application/json; charset=utf-8' + $jsonText = $Body | ConvertTo-Json -Depth 20 -Compress + $args.Body = [Text.Encoding]::UTF8.GetBytes($jsonText) + } + $response = Invoke-WebRequest @args + $json = $null + if (-not [string]::IsNullOrWhiteSpace($response.Content)) { + try { $json = $response.Content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = [int]$response.StatusCode + body = $json + raw = if ($null -eq $json) { $response.Content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $response.Headers['X-Request-Id'] } + traceId = if ($null -ne $json) { $json.traceId } else { $response.Headers['X-Trace-Id'] } + } +} + +function Invoke-Upload([string]$Path, [string]$Token, [string]$FilePath) { + $response = Invoke-WebRequest -Method POST -Uri "$GatewayBaseUrl$Path" ` + -Headers @{ Authorization = "Bearer $Token" } -Form @{ file = Get-Item -LiteralPath $FilePath } ` + -TimeoutSec 60 -SkipHttpErrorCheck + $json = $null + if (-not [string]::IsNullOrWhiteSpace($response.Content)) { + try { $json = $response.Content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = [int]$response.StatusCode + body = $json + raw = if ($null -eq $json) { $response.Content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $response.Headers['X-Request-Id'] } + traceId = if ($null -ne $json) { $json.traceId } else { $response.Headers['X-Trace-Id'] } + } +} + +function Invoke-RequiredQualificationUpload([string]$Token, [string]$FilePath, [string]$CreditCode) { + $query = "unifiedSocialCreditCode=$CreditCode&replaceBusinessLicense=true&replaceLegalPersonId=false" + $businessFile = Get-Item -LiteralPath $FilePath + $legalPersonFile = Get-Item -LiteralPath (Join-Path $businessFile.DirectoryName 'c8.png') + $content = & curl.exe -sS -X POST "$GatewayBaseUrl/api/merchant/v1/files/qualifications/required-package?$query" ` + -H "Authorization: Bearer $Token" ` + -F "businessLicense=@$($businessFile.FullName);type=image/png" ` + -F "legalPersonId=@$($legalPersonFile.FullName);type=image/png" + if ($LASTEXITCODE -ne 0) { throw '必备资质包上传发生传输错误' } + $json = $null + if (-not [string]::IsNullOrWhiteSpace($content)) { + try { $json = $content | ConvertFrom-Json } catch { } + } + return [pscustomobject]@{ + http = 200 + body = $json + raw = if ($null -eq $json) { [string]$content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $null } + traceId = if ($null -ne $json) { $json.traceId } else { $null } + } +} + +function Add-Step([string]$Name, $Response, [bool]$Passed, [string]$Evidence = '') { + $code = if ($null -ne $Response.body) { [string]$Response.body.code } else { $null } + $message = if ($null -ne $Response.body) { [string]$Response.body.message } else { [string]$Response.raw } + $steps.Add([pscustomobject]@{ + name = $Name + passed = $Passed + http = $Response.http + code = $code + message = $message + requestId = $Response.requestId + traceId = $Response.traceId + evidence = $Evidence + details = if ($Passed -or $null -eq $Response.body) { $null } else { $Response.body.data } + }) + Write-Host "[$(if ($Passed) { '通过' } else { '失败' })] $Name HTTP=$($Response.http) code=$code $Evidence" +} + +function Require-Success([string]$Name, $Response, [string]$Evidence = '') { + $passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200' + Add-Step $Name $Response $passed $Evidence + if (-not $passed) { throw "$Name 失败:HTTP=$($Response.http) code=$($Response.body.code) message=$($Response.body.message)" } + return $Response.body.data +} + +function Require-Condition([string]$Name, $Response, [bool]$Condition, [string]$Evidence) { + $passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200' -and $Condition + Add-Step $Name $Response $passed $Evidence + if (-not $passed) { throw "$Name 断言失败:$Evidence" } +} + +try { + $platformLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $PlatformUsername; password = $Password } + $platformLogin = Require-Success '平台登录' $platformLoginResponse + $platformToken = $platformLogin.accessToken + + $merchantLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $MerchantUsername; password = $Password } + $merchantLogin = Require-Success '商户登录' $merchantLoginResponse + $merchantToken = $merchantLogin.accessToken + + $healthResponse = Invoke-Api GET '/api/platform/v1/first-leg/status' $platformToken + $null = Require-Success 'Firstleg 服务健康检查' $healthResponse + + $qualificationsResponse = Invoke-Api GET '/api/merchant/v1/iam/tenant/qualifications' $merchantToken + $qualifications = @(Require-Success '查询商户必备资质' $qualificationsResponse) + $approvedTypes = @($qualifications | Where-Object { $_.status -eq 'APPROVED' } | ForEach-Object { $_.qualificationType }) + if ('BUSINESS_LICENSE' -notin $approvedTypes -or 'LEGAL_PERSON_ID' -notin $approvedTypes) { + $e2eUsername = "api_e2e_$stamp" + $e2ePassword = 'ApiE2e123!' + $registerResponse = Invoke-Api POST '/api/auth/register' '' @{ + username = $e2eUsername; password = $e2ePassword; displayName = 'API E2E Tester' + companyName = "API E2E 测试企业 $stamp"; legalPerson = 'API Tester'; legalPhone = '13800138000' + contactEmail = "api-e2e-$stamp@example.com"; countryCode = 'CN'; address = 'API E2E 测试地址' + } + $registration = Require-Success '注册隔离的 API E2E 测试企业' $registerResponse + $TenantId = [long]$registration.tenantId + $ids.tenantId = [string]$TenantId + + $e2eLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $e2eUsername; password = $e2ePassword } + $e2eLogin = Require-Success '登录隔离的 API E2E 测试企业' $e2eLoginResponse + $merchantToken = $e2eLogin.accessToken + + $qualificationImage = Join-Path (Split-Path -Parent $repoRoot) 'apps\pda-uniapp\static\c7.png' + $creditCode = "91${stamp}A1" + $qualificationUploadResponse = Invoke-RequiredQualificationUpload $merchantToken $qualificationImage $creditCode + $null = Require-Success '补齐商户营业执照与法人身份证测试包' $qualificationUploadResponse + + $reviewSubmitResponse = Invoke-Api POST '/api/merchant/v1/iam/tenant/qualifications/submit-review' $merchantToken + $null = Require-Success '提交商户资质审核' $reviewSubmitResponse + $reviewsResponse = Invoke-Api GET '/api/platform/v1/iam/reviews' $platformToken + $reviews = @(Require-Success '查询平台待审核企业' $reviewsResponse) + $review = @($reviews | Where-Object { [string]$_.tenantId -eq [string]$TenantId } | Select-Object -First 1) + if ($review.Count -eq 0) { throw "未找到 tenantId=$TenantId 的资质审核单" } + $ids.qualificationReviewId = [string]$review[0].id + $reviewApproveResponse = Invoke-Api POST "/api/platform/v1/iam/reviews/$($ids.qualificationReviewId)/approve" $platformToken @{ comment = "$marker 测试环境验收补齐" } + $null = Require-Success '平台审核通过商户资质' $reviewApproveResponse + } + + $carrierPayload = [ordered]@{ + partnerCode = "API-E2E-CARRIER-$stamp" + partnerName = "API E2E 测试承运商 $stamp" + partnerType = 'CARRIER' + types = @('CARRIER') + contactName = 'API Tester' + contactPhone = '13800138000' + contactEmail = 'api-e2e@example.com' + countryCode = 'CN' + province = '广东省' + city = '深圳市' + district = '南山区' + addressLine = 'API E2E 测试地址' + postalCode = '518000' + status = 'ACTIVE' + remark = $marker + } + $carrierResponse = Invoke-Api POST '/api/platform/v1/partners?tenantId=0' $platformToken $carrierPayload + $ids.carrierId = [string](Require-Success '创建公共测试承运商' $carrierResponse) + + $warehousePayload = [ordered]@{ + tenantId = 0 + warehouseCode = "API-E2E-WH-$stamp" + warehouseName = "API E2E 收货仓 $stamp" + warehouseType = 'DOMESTIC' + countryCode = 'CN' + timezone = 'UTC+8' + contactName = 'API Tester' + contactPhone = '13800138000' + contactEmail = 'api-e2e@example.com' + status = 'ACTIVE' + remark = $marker + address = [ordered]@{ + addressType = 'WAREHOUSE'; countryCode = 'CN'; stateProvince = '广东省'; city = '深圳市' + district = '南山区'; postalCode = '518000'; addressLine1 = 'API E2E 测试收货仓地址' + addressLine2 = $null; latitude = $null; longitude = $null + } + capabilities = @([ordered]@{ + capabilityCode = 'RECEIVING'; capabilityName = '头程收货'; enabled = $true + cutoffTime = '16:00:00'; description = $marker + }) + } + $warehouseResponse = Invoke-Api POST '/api/platform/v1/warehouse-admin' $platformToken $warehousePayload + $ids.warehouseId = [string](Require-Success '创建启用收货能力的公共测试仓' $warehouseResponse) + + $sender = [ordered]@{ + companyName = '深圳示例贸易有限公司'; contactName = 'API Tester'; phone = '13800138000' + email = 'api-e2e@example.com'; countryCode = 'CN'; province = '广东省'; city = '深圳市' + district = '南山区'; addressLine = 'API E2E 测试地址'; postalCode = '518000' + } + $orderPayload = [ordered]@{ + customerId = '191000000000000001' + customerName = '深圳示例贸易有限公司' + transportMode = 'SEA' + tradeTerm = 'FOB' + destinationCountryCode = 'US' + destinationWarehouseId = '193000000000000001' + destinationWarehouseName = 'ABE2' + expectedShipDate = (Get-Date).AddDays(10).ToString('yyyy-MM-dd') + customerRemark = "$marker 初始草稿" + sender = $sender + receiver = $null + cargoes = @([ordered]@{ + cargoName = 'API E2E 测试货物'; cargoNameEn = 'API E2E Test Cargo'; hsCode = '8518210000' + customsHsCode = '8518210000'; packageType = 'CARTON'; packageCount = 1 + grossWeightKg = 10; netWeightKg = 9; volumeCbm = 0.06 + lengthCm = 50; widthCm = 40; heightCm = 30; declaredValue = 100; unitDeclaredValue = 100 + currency = 'USD'; dangerousGoods = $false; cargoRemark = $marker + skuNo = "SKU-$stamp"; upcNo = "UPC-$stamp"; unitsPerPackage = 1; totalQuantity = 1 + material = '塑料'; usage = '测试'; model = 'E2E'; brand = 'API-E2E' + }) + pickupMethod = 'PICKUP' + receivingWarehouseId = $ids.warehouseId + receivingWarehouseName = $warehousePayload.warehouseName + channelCode = 'API-E2E-SEA' + channelName = 'API E2E 海运渠道' + carrierId = $null + carrierName = $null + taxMode = 'BUY_DECLARATION' + containerMode = 'LCL' + customsInspection = $false + destinationType = 'FBA' + amazonWarehouseCode = 'ABE2' + shipmentId = "SHIP-$stamp" + referenceId = $marker + } + + $createKey = New-Key 'order-create' + $createResponse = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $merchantToken $orderPayload @{ 'Idempotency-Key' = $createKey } + $ids.orderId = [string](Require-Success '创建头程订单草稿' $createResponse) + + $replayResponse = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $merchantToken $orderPayload @{ 'Idempotency-Key' = $createKey } + $replayedOrderId = [string](Require-Success '重放创建订单幂等请求' $replayResponse) + Require-Condition '创建订单幂等结果一致' $replayResponse ($replayedOrderId -eq $ids.orderId) "orderId=$replayedOrderId" + + $draftDetailResponse = Invoke-Api GET "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken + $draftDetail = Require-Success '查询订单草稿' $draftDetailResponse + Require-Condition '订单初始状态为 DRAFT' $draftDetailResponse ($draftDetail.order.status -eq 'DRAFT') "status=$($draftDetail.order.status)" + + $orderPayload.customerRemark = "$marker 编辑后草稿" + $updateResponse = Invoke-Api PUT "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken $orderPayload + $null = Require-Success '编辑头程订单草稿' $updateResponse + $editedDetailResponse = Invoke-Api GET "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken + $editedDetail = Require-Success '回查编辑后的订单草稿' $editedDetailResponse + Require-Condition '订单编辑内容已保存' $editedDetailResponse ($editedDetail.order.customerRemark -eq $orderPayload.customerRemark) "customerRemark=$($editedDetail.order.customerRemark)" + + $submitResponse = Invoke-Api POST "/api/merchant/v1/orders/first-leg/$($ids.orderId)/submit" $merchantToken $null @{ 'Idempotency-Key' = (New-Key 'order-submit') } + $null = Require-Success '商户提交订单' $submitResponse + + $unifiedPendingResponse = Invoke-Api GET "/api/platform/v1/orders/$($ids.orderId)?tenantId=$TenantId" $platformToken + $unifiedPending = Require-Success '统一订单详情查询待审核状态' $unifiedPendingResponse + Require-Condition '统一订单主状态为 PENDING_REVIEW' $unifiedPendingResponse ($unifiedPending.summary.mainStatus -eq 'PENDING_REVIEW') "mainStatus=$($unifiedPending.summary.mainStatus)" + + $carrierAssignResponse = Invoke-Api PUT "/api/platform/v1/orders/first-leg/$($ids.orderId)/carrier?tenantId=$TenantId" $platformToken @{ carrierId = $ids.carrierId } + $null = Require-Success '平台指派承运商' $carrierAssignResponse + + $approveOrderResponse = Invoke-Api POST "/api/platform/v1/orders/first-leg/$($ids.orderId)/approve?tenantId=$TenantId" $platformToken + $null = Require-Success '平台审核通过订单' $approveOrderResponse + + $unifiedApprovedResponse = $null + $unifiedApproved = $null + for ($attempt = 1; $attempt -le 20; $attempt++) { + $unifiedApprovedResponse = Invoke-Api GET "/api/platform/v1/orders/$($ids.orderId)?tenantId=$TenantId" $platformToken + if ($unifiedApprovedResponse.http -eq 200 -and [string]$unifiedApprovedResponse.body.code -eq '200' -and $null -ne $unifiedApprovedResponse.body.data.summary.inboundId) { + $unifiedApproved = $unifiedApprovedResponse.body.data + break + } + Start-Sleep -Seconds 3 + } + $inboundReady = $null -ne $unifiedApproved -and $null -ne $unifiedApproved.summary.inboundId + Add-Step '异步生成入库单' $unifiedApprovedResponse $inboundReady $(if ($inboundReady) { "inboundId=$($unifiedApproved.summary.inboundId)" } else { '60 秒内未取得 inboundId' }) + if (-not $inboundReady) { throw '订单审核后 60 秒内未异步生成入库单' } + $ids.inboundId = [string]$unifiedApproved.summary.inboundId + $versions.inbound = [int]$unifiedApproved.summary.inboundVersion + + $inboundDetailResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken + $inboundDetail = Require-Success '查询入库单详情' $inboundDetailResponse + $versions.inbound = [int]$inboundDetail.summary.version + Require-Condition '入库单进入待测量状态' $inboundDetailResponse ($inboundDetail.summary.processStatus -eq 'WAIT_MEASURE') "processStatus=$($inboundDetail.summary.processStatus), version=$($versions.inbound)" + + $measurementDetailResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement?tenantId=$TenantId" $platformToken + $null = Require-Success '查询初始测量详情' $measurementDetailResponse + + $draftPayload = [ordered]@{ + measurementId = $null; version = 0; originalDocumentNo = $marker + originalGrossWeightKg = 10; originalVolumeCbm = 0.06; actualGoodsName = 'API E2E 测试货物' + items = @([ordered]@{ + itemNo = 'ITEM-1'; boxSpecification = '50x40x30cm'; packageCount = 1 + lengthCm = 50; widthCm = 40; heightCm = 30; unitWeightKg = 10 + intelligentImageFileId = $null; overLimitWarning = $null + outerLengthCm = 50; outerWidthCm = 40; outerHeightCm = 30; remark = $marker + }) + } + $saveDraftResponse = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement/draft?tenantId=$TenantId" $platformToken $draftPayload + $savedDraft = Require-Success '保存测量草稿' $saveDraftResponse + $ids.measurementId = [string]$savedDraft.measurementId + $versions.measurement = [int]$savedDraft.version + + $imagePath = Join-Path (Split-Path -Parent $repoRoot) 'apps\pda-uniapp\static\c7.png' + if (-not (Test-Path -LiteralPath $imagePath)) { throw "找不到测试称重图片:$imagePath" } + $uploadResponse = Invoke-Upload "/api/platform/v1/files/measurements/$TenantId/$($ids.inboundId)" $platformToken $imagePath + $uploaded = Require-Success '上传称重图片并触发 OCR' $uploadResponse + $ids.fileId = [string]$uploaded.fileId + + $weightPayload = [ordered]@{ + fileId = $ids.fileId + ocrProvider = 'API_E2E_MANUAL' + ocrRawText = if ($uploaded.weightValue) { [string]$uploaded.weightValue } else { 'OCR unavailable; manual verification required' } + ocrWeightValue = $uploaded.weightValue + ocrWeightUnit = if ($uploaded.weightValue) { 'KG' } else { $null } + confidence = $uploaded.confidence + } + $weightCreateResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results?tenantId=$TenantId" $platformToken $weightPayload + $weightResult = Require-Success '登记称重结果' $weightCreateResponse + $ids.weightResultId = [string]$weightResult.id + $versions.weightResult = [int]$weightResult.version + + $correctResponse = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results/$($ids.weightResultId)?tenantId=$TenantId" $platformToken @{ + version = $versions.weightResult; adjustedWeightValue = 10; adjustedWeightUnit = 'KG'; adjustmentReason = "$marker 人工确认" + } + $corrected = Require-Success '人工修正称重结果' $correctResponse + $versions.weightResult = [int]$corrected.version + + $confirmResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results/$($ids.weightResultId)/confirm?tenantId=$TenantId" $platformToken @{ + version = $versions.weightResult + } + $confirmed = Require-Success '确认称重结果' $confirmResponse + $versions.weightResult = [int]$confirmed.version + + $measurementBeforeSubmitResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement?tenantId=$TenantId" $platformToken + $measurementBeforeSubmit = Require-Success '提交前回查测量详情' $measurementBeforeSubmitResponse + $versions.measurement = [int]$measurementBeforeSubmit.version + Require-Condition '确认重量汇总为 10KG' $measurementBeforeSubmitResponse ([decimal]$measurementBeforeSubmit.actualGrossWeightKg -eq 10) "actualGrossWeightKg=$($measurementBeforeSubmit.actualGrossWeightKg)" + + $measurementSubmitResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement/submit?tenantId=$TenantId" $platformToken @{ + measurementId = $ids.measurementId; version = $versions.measurement + } + $measurementSubmitted = Require-Success '提交测量审核' $measurementSubmitResponse + $versions.measurement = [int]$measurementSubmitted.version + Require-Condition '测量进入待审核状态' $measurementSubmitResponse ($measurementSubmitted.processStatus -eq 'MEASURE_PENDING' -and $measurementSubmitted.measureReviewStatus -eq 'PENDING') "processStatus=$($measurementSubmitted.processStatus), reviewStatus=$($measurementSubmitted.measureReviewStatus)" + + $pendingInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken + $pendingInbound = Require-Success '审核前回查入库单版本' $pendingInboundResponse + $versions.inbound = [int]$pendingInbound.summary.version + + $measurementApproveResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/measurements/approve?tenantId=$TenantId" $platformToken @{ + measurementId = $ids.measurementId; expectedVersion = $versions.inbound; reason = $null + } @{ 'Idempotency-Key' = (New-Key 'measurement-approve') } + $null = Require-Success '平台审核通过测量' $measurementApproveResponse + + $approvedInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken + $approvedInbound = Require-Success '审核后回查入库单' $approvedInboundResponse + $versions.inbound = [int]$approvedInbound.summary.version + Require-Condition '二期边界停在 WAIT_SPLIT' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'WAIT_SPLIT' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)" + + $feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/calculate?tenantId=$TenantId" $platformToken @{} @{ 'Idempotency-Key' = (New-Key 'fee-calculate') } + $feePassed = $feeResponse.http -eq 200 -and [string]$feeResponse.body.code -eq '200' + Add-Step '二期计费接口试算' $feeResponse $feePassed "inboundStatus=$($approvedInbound.summary.processStatus)" + if (-not $feePassed) { + $billingDefect = [ordered]@{ + severity = 'P0' + summary = '测量审核后进入 WAIT_SPLIT,但计费接口不接受该状态,二期正常链路无法计费' + http = $feeResponse.http + code = [string]$feeResponse.body.code + message = [string]$feeResponse.body.message + requestId = $feeResponse.requestId + traceId = $feeResponse.traceId + } + } +} +catch { + $fatal = $_.Exception.Message + Write-Host "[中止] $fatal" +} +finally { + $passedCount = @($steps | Where-Object passed).Count + $failedCount = @($steps | Where-Object { -not $_.passed }).Count + $report = [ordered]@{ + reportName = '二期测试环境 API 验收' + scope = @('头程下单', '商户提交', '平台审核', '异步生成入库单', '测量草稿', '称重图片与结果', '人工修正与确认', '测量提交与审核', '计费试算') + excluded = @('分柜', '预装', 'PDA 拣货', '装柜证据', '装柜完成', '派送') + gateway = $GatewayBaseUrl + marker = $marker + startedAt = [datetime]::ParseExact($stamp, 'yyyyMMddHHmmss', $null).ToString('o') + finishedAt = (Get-Date).ToString('o') + success = $null -eq $fatal -and $null -eq $billingDefect -and $failedCount -eq 0 + passedCount = $passedCount + failedCount = $failedCount + ids = $ids + versions = $versions + fatal = $fatal + billingDefect = $billingDefect + steps = $steps + } + $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding utf8 + Write-Host "报告:$OutputPath" + $report | ConvertTo-Json -Depth 20 +} From 74db8e165db75b47ef1a53c2c0bcb896a5793789 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 18:34:52 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 28 + .../repository/FbaPricingRepository.java | 10 +- .../firstleg/service/FbaPricingService.java | 15 +- .../resources/openapi/fba-pricing-v1.yaml | 7 +- .../controller/FbaPricingContractTest.java | 5 +- .../service/FbaPricingServiceTest.java | 52 +- .../二期测试环境API验收-20260806181448.json | 530 ++++++++++++++++++ scripts/phase2-test-env-acceptance.ps1 | 70 ++- .../db/warehouse-public-master-data.sql | 28 + .../WarehouseSchemaContractTest.java | 15 + 10 files changed, 717 insertions(+), 43 deletions(-) create mode 100644 reports/二期测试环境API验收-20260806181448.json diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 905ab22c9..c735d3801 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3961,6 +3961,24 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +INSERT INTO wh_service_capability ( + id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, + description, created_by, created_at, updated_by, updated_at, deleted, version +) +VALUES + (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', + '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) +ON DUPLICATE KEY UPDATE + capability_name = VALUES(capability_name), + enabled = VALUES(enabled), + cutoff_time = VALUES(cutoff_time), + description = VALUES(description), + deleted = 0, + updated_at = CURRENT_TIMESTAMP(3); + -- 平台托盘初始化主数据;内部状态仅供预占/占用,不作为平台维护字段。 INSERT INTO wh_pallet ( id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, @@ -4471,6 +4489,16 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败'; END IF; + + IF (SELECT COUNT(*) + FROM wh_service_capability capability + WHERE capability.tenant_id = 0 + AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.capability_code = 'RECEIVING' + AND capability.enabled = 1 + AND capability.deleted = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + END IF; END// DELIMITER ; CALL verify_wh_fixed_platform_warehouse(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 82e14bcac..c110d52c0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -201,15 +201,17 @@ public class FbaPricingRepository { .set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now())); } - public int markInboundCalculated(long tenantId, long inboundId, String chargeMode, + public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode, java.math.BigDecimal chargeable) { LambdaUpdateWrapper update = new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId) .eq(InboundPricingEntity::getId, inboundId) - .in(InboundPricingEntity::getProcessStatus, List.of("WAIT_PRICING", "FEE_CONFIRMING")) + .eq(InboundPricingEntity::getProcessStatus, processStatus) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED")) - .set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING") .set(InboundPricingEntity::getFeeStatus, "CALCULATED"); + if (!"WAIT_SPLIT".equals(processStatus)) { + update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING"); + } if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable); return inbounds.update(null, update); @@ -227,7 +229,7 @@ public class FbaPricingRepository { public int markInboundConfirmed(long tenantId, long inboundId) { return inbounds.update(null, new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) - .eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING") + .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT")) .eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 60bef7c98..450a2d49a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -47,7 +47,7 @@ public class FbaPricingService { String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString()); InboundPricingIdempotencyEntity token = beginIdempotency( tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash); - if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING"); + if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); List matchedRules = repository.matchRules(inbound, pricingAt); @@ -88,11 +88,14 @@ public class FbaPricingService { repository.insertFee(fee); repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now)); if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now)); - if (repository.markInboundCalculated(tenantId, inboundId, rule.getChargeMode(), chargeable) != 1) { + if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(), + rule.getChargeMode(), chargeable) != 1) { throw conflict("入库单状态已变化,费用试算未保存"); } complete(token, fee.getId(), now); - return response(inbound, fee, rate.getUnitPrice(), "FEE_CONFIRMING"); + String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus()) + ? "WAIT_SPLIT" : "FEE_CONFIRMING"; + return response(inbound, fee, rate.getUnitPrice(), nextStatus); } @Transactional @@ -103,14 +106,14 @@ public class FbaPricingService { String hash = hash("CONFIRM", inboundId, current.getId().toString()); InboundPricingIdempotencyEntity token = beginIdempotency( tenantId, operatorId, inboundId, "FEE_CONFIRM", idempotencyKey, hash); - if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING"); + if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); LocalDateTime now = LocalDateTime.now(clock); if (repository.confirmFee(tenantId, current.getId(), operatorId, now) != 1 || repository.markInboundConfirmed(tenantId, inboundId) != 1) { throw conflict("费用或入库单状态已变化,不能重复确认"); } complete(token, current.getId(), now); - return response(tenantId, current.getId(), "FEE_CONFIRMING"); + return response(tenantId, current.getId(), null); } @Transactional @@ -232,7 +235,7 @@ public class FbaPricingService { private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) { InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); - if (!List.of("WAIT_PRICING", "FEE_CONFIRMING").contains(inbound.getProcessStatus()) + if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus()) || !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) { throw conflict("当前入库单状态不允许费用试算"); } diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index cdf5048db..796de5b83 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -54,19 +54,20 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: summary: 按审核通过测量快照试算 FBA 费用 - description: 精确匹配唯一客户规则和唯一船司价格;需要 firstleg:fba-pricing:calculate 权限。 + description: 精确匹配唯一客户规则和唯一船司价格;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 + description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/send: post: - summary: 手工恢复发送费用快照 - description: 仅作为异常恢复接口;正常业务由客服测量审核通过后自动计费、发布并完成入库。 + summary: 发送已确认费用快照 + description: 该动作涉及后续仓储流程,本轮二期验收不调用。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/latest: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index c689185fe..1b2365bd7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -36,7 +36,7 @@ class FbaPricingContractTest { } @Test - @DisplayName("契约声明自动发布、已发布费用调整和幂等头") + @DisplayName("契约声明二期边界计费、已发布费用调整和幂等头") void documentsPricingLifecycle() throws Exception { String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); @@ -48,7 +48,8 @@ class FbaPricingContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/fees/send:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("Idempotency-Key") - .contains("客服测量审核通过后自动计费、发布并完成入库") + .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") + .contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("不回退 INBOUNDED 状态") .doesNotContain("应收") .doesNotContain("应付") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 57aa55e9f..9ddda2d3c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -100,6 +100,51 @@ class FbaPricingServiceTest { assertThat(response.totalAmount()).isEqualByComparingTo("100.01"); } + @Test + @DisplayName("待分柜边界可独立计费且不回退主流程状态") + void calculatesAtWaitSplitWithoutRollingBackProcessStatus() { + readyRepository(); + InboundPricingEntity inbound = inbound("100.000", "1.0000"); + inbound.setProcessStatus("WAIT_SPLIT"); + CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING"); + CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); + when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate)); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); + + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG", + new BigDecimal("167.000")); + } + + @Test + @DisplayName("待分柜边界确认费用后仍保持主流程状态") + void confirmsAtWaitSplitWithoutRollingBackProcessStatus() { + InboundPricingEntity inbound = inbound("10.000", "1.0000"); + inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity fee = savedFee("CALCULATED"); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-wait-split")) + .thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(fee); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + + FeeSnapshotResponse response = service.confirm( + TENANT, OPERATOR, INBOUND, "confirm-wait-split"); + + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).markInboundConfirmed(TENANT, INBOUND); + } + @Test @DisplayName("审核未通过的测量快照禁止计费") void rejectsUnapprovedMeasurementSnapshot() { @@ -137,15 +182,18 @@ class FbaPricingServiceTest { @DisplayName("同一幂等键重复试算直接返回原费用快照") void returnsOriginalSnapshotForRepeatedCalculation() { InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED")); - when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("10", "1")); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); when(repository.findBaseItem(TENANT, 501L)).thenReturn(item); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same"); assertThat(response.feeId()).isEqualTo(501L); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository, never()).matchRules(any(), any()); verify(repository, never()).insertFee(any()); } @@ -233,7 +281,7 @@ class FbaPricingServiceTest { doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; }) .when(repository).insertFee(any()); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1); - when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any())).thenReturn(1); + when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any())).thenReturn(1); when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); } diff --git a/reports/二期测试环境API验收-20260806181448.json b/reports/二期测试环境API验收-20260806181448.json new file mode 100644 index 000000000..efd745555 --- /dev/null +++ b/reports/二期测试环境API验收-20260806181448.json @@ -0,0 +1,530 @@ +{ + "reportName": "二期测试环境 API 验收", + "scope": [ + "头程下单", + "商户提交", + "平台审核", + "异步生成入库单", + "测量草稿", + "称重图片与结果", + "人工修正与确认", + "测量提交与审核", + "计费试算" + ], + "excluded": [ + "分柜", + "预装", + "PDA 拣货", + "装柜证据", + "装柜完成", + "派送" + ], + "gateway": "http://8.163.30.128:9000", + "marker": "API-E2E-20260806181448", + "startedAt": "2026-08-06T18:14:48.0000000", + "finishedAt": "2026-08-06T18:14:56.1155716+08:00", + "success": false, + "passedCount": 42, + "failedCount": 1, + "ids": { + "tenantId": "2085308606829346818", + "qualificationReviewId": "2085308609115242497", + "warehouseId": "2085308610297528322", + "carrierId": "2085308609974566913", + "orderId": "2085308610682916865", + "inboundId": "2085308616747372546", + "measurementId": "2085308626759176194", + "weightResultId": "2085308631419047938", + "fileId": "2085308627513511938" + }, + "versions": { + "inbound": 3, + "measurement": 1, + "weightResult": 2 + }, + "fatal": null, + "billingDefect": { + "severity": "P0", + "summary": "测量审核后进入 WAIT_SPLIT,但计费接口不接受该状态,二期正常链路无法计费", + "http": 200, + "code": "A0409", + "message": "当前入库单状态不允许费用试算", + "requestId": "b55826a9-c964-4bb5-91c6-1b862a469eee", + "traceId": "44c08299046a4c5ab095b39463091218" + }, + "steps": [ + { + "name": "平台登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "商户登录", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "Firstleg 服务健康检查", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "查询商户必备资质", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "注册隔离的 API E2E 测试企业", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "登录隔离的 API E2E 测试企业", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "补齐商户营业执照与法人身份证测试包", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "提交商户资质审核", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "查询平台待审核企业", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "平台审核通过商户资质", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建公共测试承运商", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建启用收货能力的公共测试仓", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建头程订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "重放创建订单幂等请求", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "创建订单幂等结果一致", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "orderId=2085308610682916865", + "details": null + }, + { + "name": "查询订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "订单初始状态为 DRAFT", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "status=DRAFT", + "details": null + }, + { + "name": "编辑头程订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "回查编辑后的订单草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "订单编辑内容已保存", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "customerRemark=API-E2E-20260806181448 编辑后草稿", + "details": null + }, + { + "name": "商户提交订单", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "统一订单详情查询待审核状态", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "统一订单主状态为 PENDING_REVIEW", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "mainStatus=PENDING_REVIEW", + "details": null + }, + { + "name": "平台指派承运商", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "平台审核通过订单", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "异步生成入库单", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "inboundId=2085308616747372546", + "details": null + }, + { + "name": "查询入库单详情", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "入库单进入待测量状态", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "processStatus=WAIT_MEASURE, version=0", + "details": null + }, + { + "name": "查询初始测量详情", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "保存测量草稿", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "上传称重图片并触发 OCR", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "登记称重结果", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "人工修正称重结果", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "确认称重结果", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "提交前回查测量详情", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "确认重量汇总为 10KG", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "actualGrossWeightKg=10", + "details": null + }, + { + "name": "提交测量审核", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "测量进入待审核状态", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "processStatus=MEASURE_PENDING, reviewStatus=PENDING", + "details": null + }, + { + "name": "审核前回查入库单版本", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "平台审核通过测量", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "审核后回查入库单", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "", + "details": null + }, + { + "name": "二期边界停在 WAIT_SPLIT", + "passed": true, + "http": 200, + "code": "200", + "message": "success", + "requestId": null, + "traceId": null, + "evidence": "processStatus=WAIT_SPLIT, reviewStatus=APPROVED", + "details": null + }, + { + "name": "二期计费接口试算", + "passed": false, + "http": 200, + "code": "A0409", + "message": "当前入库单状态不允许费用试算", + "requestId": "b55826a9-c964-4bb5-91c6-1b862a469eee", + "traceId": "44c08299046a4c5ab095b39463091218", + "evidence": "inboundStatus=WAIT_SPLIT", + "details": null + } + ] +} diff --git a/scripts/phase2-test-env-acceptance.ps1 b/scripts/phase2-test-env-acceptance.ps1 index 5bae36ab7..73f36eb68 100644 --- a/scripts/phase2-test-env-acceptance.ps1 +++ b/scripts/phase2-test-env-acceptance.ps1 @@ -2,7 +2,8 @@ param( [string]$GatewayBaseUrl = 'http://8.163.30.128:9000', [string]$PlatformUsername = 'platform_admin', [string]$MerchantUsername = 'merchant_admin', - [string]$Password = 'ChangeMe123!', + [string]$PlatformPassword = 'ChangeMe123!', + [string]$MerchantPassword = 'ChangeMe123!', [long]$TenantId = 10001, [string]$OutputPath = '' ) @@ -16,7 +17,7 @@ if ([string]::IsNullOrWhiteSpace($OutputPath)) { } $steps = [System.Collections.Generic.List[object]]::new() -$ids = [ordered]@{ tenantId = [string]$TenantId; qualificationReviewId = $null; warehouseId = $null; carrierId = $null; orderId = $null; inboundId = $null; measurementId = $null; weightResultId = $null; fileId = $null } +$ids = [ordered]@{ tenantId = [string]$TenantId; qualificationReviewId = $null; warehouseId = $null; carrierId = $null; customerRuleId = $null; carrierRateId = $null; orderId = $null; inboundId = $null; measurementId = $null; weightResultId = $null; fileId = $null; feeId = $null } $versions = [ordered]@{ inbound = $null; measurement = $null; weightResult = $null } $fatal = $null $billingDefect = $null @@ -56,19 +57,20 @@ function Invoke-Api( } function Invoke-Upload([string]$Path, [string]$Token, [string]$FilePath) { - $response = Invoke-WebRequest -Method POST -Uri "$GatewayBaseUrl$Path" ` - -Headers @{ Authorization = "Bearer $Token" } -Form @{ file = Get-Item -LiteralPath $FilePath } ` - -TimeoutSec 60 -SkipHttpErrorCheck + $file = Get-Item -LiteralPath $FilePath + $content = & curl.exe -sS -X POST "$GatewayBaseUrl$Path" ` + -H "Authorization: Bearer $Token" -F "file=@$($file.FullName);type=image/png" + if ($LASTEXITCODE -ne 0) { throw '称重图片上传发生传输错误' } $json = $null - if (-not [string]::IsNullOrWhiteSpace($response.Content)) { - try { $json = $response.Content | ConvertFrom-Json } catch { } + if (-not [string]::IsNullOrWhiteSpace($content)) { + try { $json = $content | ConvertFrom-Json } catch { } } return [pscustomobject]@{ - http = [int]$response.StatusCode + http = 200 body = $json - raw = if ($null -eq $json) { $response.Content } else { $null } - requestId = if ($null -ne $json) { $json.requestId } else { $response.Headers['X-Request-Id'] } - traceId = if ($null -ne $json) { $json.traceId } else { $response.Headers['X-Trace-Id'] } + raw = if ($null -eq $json) { [string]$content } else { $null } + requestId = if ($null -ne $json) { $json.requestId } else { $null } + traceId = if ($null -ne $json) { $json.traceId } else { $null } } } @@ -125,11 +127,11 @@ function Require-Condition([string]$Name, $Response, [bool]$Condition, [string]$ } try { - $platformLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $PlatformUsername; password = $Password } + $platformLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $PlatformUsername; password = $PlatformPassword } $platformLogin = Require-Success '平台登录' $platformLoginResponse $platformToken = $platformLogin.accessToken - $merchantLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $MerchantUsername; password = $Password } + $merchantLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $MerchantUsername; password = $MerchantPassword } $merchantLogin = Require-Success '商户登录' $merchantLoginResponse $merchantToken = $merchantLogin.accessToken @@ -216,6 +218,28 @@ try { $warehouseResponse = Invoke-Api POST '/api/platform/v1/warehouse-admin' $platformToken $warehousePayload $ids.warehouseId = [string](Require-Success '创建启用收货能力的公共测试仓' $warehouseResponse) + $effectiveFrom = (Get-Date).AddDays(-1).ToString('yyyy-MM-ddTHH:mm:ss') + $customerRuleResponse = Invoke-Api POST "/api/platform/v1/fba-pricing/customer-rules/versions?tenantId=$TenantId" $platformToken @{ + ruleCode = "API-E2E-RULE-$stamp"; customerId = '191000000000000001' + customerName = '深圳示例贸易有限公司'; businessType = 'FBA_SEA'; chargeMode = 'KG' + taxMode = 'TAX_INCLUDED'; conversionFactor = 167; roundingScale = 3 + roundingMode = 'HALF_UP'; currency = 'CNY'; effectiveFrom = $effectiveFrom + effectiveTo = $null; remark = $marker + } + $customerRule = Require-Success '创建客户计费规则版本' $customerRuleResponse + $ids.customerRuleId = [string]$customerRule.id + + $carrierRateResponse = Invoke-Api POST '/api/platform/v1/fba-pricing/carrier-rates/versions' $platformToken @{ + rateCode = "API-E2E-RATE-$stamp"; originWarehouseId = $ids.warehouseId + originWarehouseName = $warehousePayload.warehouseName; businessType = 'FBA_SEA' + channelCode = 'API-E2E-SEA'; carrierId = $ids.carrierId + carrierName = $carrierPayload.partnerName; destinationCode = 'ABE2'; chargeUnit = 'KG' + unitPrice = 2; currency = 'CNY'; additionalFee = 10; effectiveFrom = $effectiveFrom + effectiveTo = $null; remark = $marker + } + $carrierRate = Require-Success '创建承运商 FBA 费率版本' $carrierRateResponse + $ids.carrierRateId = [string]$carrierRate.id + $sender = [ordered]@{ companyName = '深圳示例贸易有限公司'; contactName = 'API Tester'; phone = '13800138000' email = 'api-e2e@example.com'; countryCode = 'CN'; province = '广东省'; city = '深圳市' @@ -387,19 +411,13 @@ try { Require-Condition '二期边界停在 WAIT_SPLIT' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'WAIT_SPLIT' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)" $feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/calculate?tenantId=$TenantId" $platformToken @{} @{ 'Idempotency-Key' = (New-Key 'fee-calculate') } - $feePassed = $feeResponse.http -eq 200 -and [string]$feeResponse.body.code -eq '200' - Add-Step '二期计费接口试算' $feeResponse $feePassed "inboundStatus=$($approvedInbound.summary.processStatus)" - if (-not $feePassed) { - $billingDefect = [ordered]@{ - severity = 'P0' - summary = '测量审核后进入 WAIT_SPLIT,但计费接口不接受该状态,二期正常链路无法计费' - http = $feeResponse.http - code = [string]$feeResponse.body.code - message = [string]$feeResponse.body.message - requestId = $feeResponse.requestId - traceId = $feeResponse.traceId - } - } + $calculatedFee = Require-Success '二期计费接口试算' $feeResponse + $ids.feeId = [string]$calculatedFee.feeId + Require-Condition '计费试算保持 WAIT_SPLIT 主流程边界' $feeResponse ($calculatedFee.nextProcessStatus -eq 'WAIT_SPLIT') "nextProcessStatus=$($calculatedFee.nextProcessStatus)" + + $feeConfirmResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/confirm?tenantId=$TenantId" $platformToken $null @{ 'Idempotency-Key' = (New-Key 'fee-confirm') } + $confirmedFee = Require-Success '确认二期费用快照' $feeConfirmResponse + Require-Condition '费用确认后仍保持 WAIT_SPLIT 主流程边界' $feeConfirmResponse ($confirmedFee.feeStatus -eq 'CONFIRMED' -and $confirmedFee.nextProcessStatus -eq 'WAIT_SPLIT') "feeStatus=$($confirmedFee.feeStatus), nextProcessStatus=$($confirmedFee.nextProcessStatus)" } catch { $fatal = $_.Exception.Message diff --git a/warehouse/src/main/resources/db/warehouse-public-master-data.sql b/warehouse/src/main/resources/db/warehouse-public-master-data.sql index 942fd0a05..6ea011f7c 100644 --- a/warehouse/src/main/resources/db/warehouse-public-master-data.sql +++ b/warehouse/src/main/resources/db/warehouse-public-master-data.sql @@ -162,6 +162,24 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +INSERT INTO wh_service_capability ( + id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, + description, created_by, created_at, updated_by, updated_at, deleted, version +) +VALUES + (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', + '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) +ON DUPLICATE KEY UPDATE + capability_name = VALUES(capability_name), + enabled = VALUES(enabled), + cutoff_time = VALUES(cutoff_time), + description = VALUES(description), + deleted = 0, + updated_at = CURRENT_TIMESTAMP(3); + DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data; CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data ( warehouse_id_seed BIGINT UNSIGNED NOT NULL, @@ -655,6 +673,16 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败'; END IF; + + IF (SELECT COUNT(*) + FROM wh_service_capability capability + WHERE capability.tenant_id = 0 + AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.capability_code = 'RECEIVING' + AND capability.enabled = 1 + AND capability.deleted = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + END IF; END// DELIMITER ; CALL verify_wh_fixed_platform_warehouse(); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 3894f5d18..0a90de248 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -97,6 +97,21 @@ class WarehouseSchemaContractTest { .contains("idx_wh_express_tenant_status"); } + @Test + void shouldSeedReceivingCapabilityForDomesticOperatingWarehouses() throws IOException { + String masterData = read("warehouse-public-master-data.sql"); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + for (String sql : java.util.List.of(masterData, unified)) { + assertThat(sql) + .contains("193000000000200001,'RECEIVING','头程收货',1") + .contains("193000000000200002,'RECEIVING','头程收货',1") + .contains("国内固定作业仓收货能力写入校验失败") + .contains("warehouse.warehouse_type = 'FBA'") + .contains("FBA公共地址不得创建平台作业能力"); + } + } + private static String read(String file) throws IOException { return Files.readString(Path.of("src/main/resources/db", file)); } From 6964e3afa3a223049fe10c4793d18e573c61328e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 19:03:09 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformUnifiedOrderController.java | 19 +++++++++++++++++++ .../src/main/resources/openapi/order-v1.yaml | 19 +++++++++++++++++++ .../OrderControllerPermissionTest.java | 6 ++++++ 3 files changed, 44 insertions(+) diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 30a503f59..b01f39eab 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -2,9 +2,12 @@ package com.baiyue.order.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.audit.annotation.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.UnifiedOrderQueryService; import io.swagger.v3.oas.annotations.Operation; @@ -17,6 +20,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; /** Platform product-level order query. Legacy first-leg paths remain available. */ @@ -25,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/orders") public class PlatformUnifiedOrderController { @Resource private UnifiedOrderQueryService service; + @Resource private OrderApplicationService orderService; @GetMapping @PreAuthorize("hasAuthority('platform:order:list')") @@ -47,4 +53,17 @@ public class PlatformUnifiedOrderController { @PathVariable long orderId) { return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true)); } + + @PostMapping("/{orderId}/internal-notes") + @BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "Add internal note / 添加内部备注") + @PreAuthorize("hasAuthority('platform:order:note')") + @Operation(summary = "添加订单内部备注", description = "添加仅平台内部可见的订单备注。") + public ApiResponse addInternalNote( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long orderId, + @Valid @RequestBody OrderRequests.NoteRequest request) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(orderService.addNote(scopedTenant, user.userId(), orderId, request.content())); + } } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 408cbd66d..c952b3ae2 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -52,6 +52,25 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要平台身份和 platform:order:list 权限} + /api/platform/v1/orders/{orderId}/internal-notes: + post: + summary: 添加订单内部备注 + description: 添加仅平台内部可见的订单备注,需要 platform:order:note 权限。 + parameters: + - $ref: '#/components/parameters/UnifiedOrderId' + - $ref: '#/components/parameters/UnifiedTenantId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [content] + properties: + content: {type: string, minLength: 1, maxLength: 2000} + responses: + '200': {description: 备注添加成功或业务失败,按响应体 code 判断} + '403': {description: 需要 platform:order:note 权限} /api/merchant/v1/orders: get: summary: 客户统一订单列表 diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 80b72d7d4..f1716f81d 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -57,6 +57,12 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class); } + @Test + void unifiedPlatformInternalNoteRequiresNotePermission() throws Exception { + assertAuthority(PlatformUnifiedOrderController.class, "addInternalNote", "hasAuthority('platform:order:note')", + AuthenticatedUser.class, long.class, long.class, OrderRequests.NoteRequest.class); + } + private void assertAuthority(Class controller, String methodName, String expected, Class... parameterTypes) throws NoSuchMethodException { Method method = controller.getDeclaredMethod(methodName, parameterTypes); From a5dc0f78c35ba6626da3565879fa99cfccc4222e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 19:21:09 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/order/controller/PlatformUnifiedOrderController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index b01f39eab..f060d74cc 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -2,7 +2,7 @@ package com.baiyue.order.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.audit.annotation.BusinessAudit; +import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; From 897f91af20568143967c709c8b714bc164c04f3e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 7 Aug 2026 09:06:19 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PlatformTenantController.java | 13 +++++++ .../iam/dto/PlatformCustomerMemberDtos.java | 13 +++++++ .../com/baiyue/iam/service/MemberService.java | 32 ++++++++++++++++ iam/src/main/resources/openapi/iam-v1.yaml | 38 +++++++++++++++++++ .../baiyue/iam/service/MemberServiceTest.java | 25 ++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 iam/src/main/java/com/baiyue/iam/dto/PlatformCustomerMemberDtos.java diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 1999e1ad6..373035190 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -12,6 +12,8 @@ import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; +import com.baiyue.iam.service.MemberService; +import com.baiyue.iam.dto.PlatformCustomerMemberDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -38,6 +40,8 @@ import java.util.List; public class PlatformTenantController { @Resource private TenantService service; + @Resource + private MemberService memberService; @GetMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:list')") @@ -69,6 +73,15 @@ public class PlatformTenantController { return ApiResponse.success(service.merchant(tenantId)); } + @GetMapping("/tenants/{tenantId:\\d+}/members") + @PreAuthorize("hasAuthority('platform:customer:view')") + @Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。") + public ApiResponse> members( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(memberService.platformList(tenantId)); + } + @PostMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:create')") @Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft only. Saving the customer does not create a review ticket. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。") diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformCustomerMemberDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformCustomerMemberDtos.java new file mode 100644 index 000000000..44b7c67af --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformCustomerMemberDtos.java @@ -0,0 +1,13 @@ +package com.baiyue.iam.dto; + +import java.util.List; + +/** 平台查看客户企业成员时使用的只读契约,补充认证账号信息。 */ +public final class PlatformCustomerMemberDtos { + private PlatformCustomerMemberDtos() { } + + public record MemberResponse(Long membershipId, Long userId, Long authUserId, + String username, String displayName, String phone, String email, + String memberType, String memberNo, String status, + List roleIds, List roleNames) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 99e405f5a..c5a9f0396 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -11,6 +11,8 @@ import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.dto.PlatformCustomerMemberDtos; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; import jakarta.annotation.Resource; @@ -33,6 +35,8 @@ public class MemberService { @Resource private MemberRepository repository; @Resource + private TenantRepository tenantRepository; + @Resource private IamRoleMapper roleMapper; @Resource private AuthAccountClient authClient; @@ -43,6 +47,34 @@ public class MemberService { return repository.findByTenant(tenantId).stream().map(m -> response(tenantId, m)).toList(); } + /** 平台只读查询客户成员;显式校验客户主档,并包含 OWNER 管理员。 */ + public List platformList(long tenantId) { + var tenant = tenantRepository.findTenant(tenantId); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "企业客户不存在"); + } + return repository.findByTenant(tenantId).stream().map(m -> platformResponse(tenantId, m)).toList(); + } + + private PlatformCustomerMemberDtos.MemberResponse platformResponse(long tenantId, IamMembership membership) { + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary account = accountSummary(user.getAuthUserId()); + List roleIds = repository.findRoleIds(tenantId, membership.getId()); + List roleNames = roleIds.stream() + .map(roleMapper::selectById) + .filter(role -> role != null && Long.valueOf(tenantId).equals(role.getTenantId())) + .map(IamRole::getRoleName) + .toList(); + return new PlatformCustomerMemberDtos.MemberResponse(membership.getId(), user.getId(), + user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), + membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), + roleIds, roleNames); + } + + private AuthAccountClient.AccountSummary accountSummary(Long authUserId) { + return data(call(() -> authClient.summary(internalToken, authUserId))); + } + public MemberResponse detail(long tenantId, Long membershipId) { return response(tenantId, required(tenantId, membershipId)); } diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 7ab2688fc..cea557b7a 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -88,6 +88,22 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/members: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户成员及账号 + description: 平台只读查询指定客户的全部企业成员,包含 OWNER 企业管理员并返回登录账号;需要 `platform:customer:view` 权限。 + responses: + '200': + description: 企业成员查询成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformCustomerMembersApiResponse' + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/platform/v1/iam/tenants/{tenantId}/submit-review: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -802,6 +818,28 @@ components: - type: object properties: data: {$ref: '#/components/schemas/InternalUserResponse'} + PlatformCustomerMemberResponse: + type: object + required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds, roleNames] + properties: + membershipId: {type: string, pattern: '^\d+$'} + userId: {type: string, pattern: '^\d+$'} + authUserId: {type: string, pattern: '^\d+$'} + username: {type: string} + displayName: {type: string} + phone: {type: string} + email: {type: string, format: email} + memberType: {type: string, enum: [OWNER, EMPLOYEE]} + memberNo: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + roleIds: {type: array, items: {type: string, pattern: '^\d+$'}} + roleNames: {type: array, items: {type: string}} + PlatformCustomerMembersApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/PlatformCustomerMemberResponse'}} PlatformManagedUserPageApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 179c23c79..c91a6f31e 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -7,9 +7,11 @@ import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.dto.MemberDtos.*; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; @@ -28,6 +30,7 @@ import static org.mockito.Mockito.*; @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MemberServiceTest { @MockBean private MemberRepository repository; + @MockBean private TenantRepository tenantRepository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; @Resource private MemberService service; @@ -52,6 +55,28 @@ class MemberServiceTest { verifyNoInteractions(auth); } + @Test void shouldReturnOwnerAccountForPlatformCustomerDetail() { + IamTenant tenant = new IamTenant(); + tenant.setId(100L); tenant.setTenantType("MERCHANT"); + member.setMemberType("OWNER"); + user.setPhone("13800138000"); user.setEmail("owner@example.com"); + when(tenantRepository.findTenant(100L)).thenReturn(tenant); + when(repository.findByTenant(100L)).thenReturn(List.of(member)); + IamRole ownerRole = new IamRole(); + ownerRole.setId(50L); ownerRole.setTenantId(100L); ownerRole.setRoleName("企业管理员"); + when(roleMapper.selectById(50L)).thenReturn(ownerRole); + when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(40L, "merchant_owner", "企业管理员", "ACTIVE"))); + + var result = service.platformList(100L); + + assertEquals(1, result.size()); + assertEquals("merchant_owner", result.get(0).username()); + assertEquals("OWNER", result.get(0).memberType()); + assertEquals("ACTIVE", result.get(0).status()); + assertEquals(List.of("企业管理员"), result.get(0).roleNames()); + } + @Test void shouldHandleCase02() { when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); assertThrows(BizException.class, () -> service.detail(200L, 20L)); From 830575cc92b8cacbccd809c3ba4c56210a817233 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 7 Aug 2026 10:45:09 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 48 +++++++++++++++ .../InternalInboundOrderController.java | 4 +- .../dto/OrderApprovedInboundCommand.java | 1 + .../service/InboundOrderCreationService.java | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 47 +++++++++++++++ ...alInboundOrderReferenceControllerTest.java | 25 ++++++++ .../InboundOrderCreationServiceTest.java | 59 +++++++++++++++++++ .../order/client/FirstlegInboundClient.java | 2 +- .../PlatformFirstLegOrderController.java | 4 +- .../com/baiyue/order/entity/OrderEntity.java | 1 + .../order/repository/OrderRepository.java | 1 + .../service/OrderApplicationService.java | 16 ++--- .../service/UnifiedOrderQueryService.java | 13 ++-- order/src/main/resources/db/order.sql | 2 + .../src/main/resources/openapi/order-v1.yaml | 9 +-- .../OrderApplicationServiceStateTest.java | 37 +++++++++--- .../service/UnifiedOrderQueryServiceTest.java | 52 ++++++++++++++++ 17 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c735d3801..a64511126 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -411,6 +411,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号', + inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态', @@ -449,6 +450,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), + UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) @@ -2213,6 +2215,30 @@ PREPARE carrier_name_statement FROM @carrier_name_sql; EXECUTE carrier_name_statement; DEALLOCATE PREPARE carrier_name_statement; +-- 客户提交时由 Order 固定入仓码;审核通过后 Firstleg 沿用该码创建正式入库单。 +SET @inbound_code_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'inbound_code' +); +SET @inbound_code_sql := IF(@inbound_code_exists = 0, + 'ALTER TABLE ord_order ADD COLUMN inbound_code VARCHAR(40) NULL COMMENT ''客户提交后生成的入仓码,草稿为空'' AFTER order_no', + 'SELECT 1'); +PREPARE inbound_code_statement FROM @inbound_code_sql; +EXECUTE inbound_code_statement; +DEALLOCATE PREPARE inbound_code_statement; + +SET @inbound_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code' +); +SET @inbound_code_index_sql := IF(@inbound_code_index_exists = 0, + 'ALTER TABLE ord_order ADD UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)', + 'SELECT 1'); +PREPARE inbound_code_index_statement FROM @inbound_code_index_sql; +EXECUTE inbound_code_index_statement; +DEALLOCATE PREPARE inbound_code_index_statement; + -- ============================================================================ -- 来源:firstleg\src\main\resources\db\phase2-inbound-schema.sql -- ============================================================================ @@ -2294,6 +2320,12 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +UPDATE baiyue_order.ord_order o +LEFT JOIN baiyue_firstleg.fl_inbound_order i + ON i.tenant_id = o.tenant_id AND i.order_id = o.id AND i.deleted = 0 + SET o.inbound_code = COALESCE(i.inbound_code, CONCAT('RK-', o.id)) + WHERE o.deleted = 0 AND o.status <> 'DRAFT' AND o.inbound_code IS NULL; + CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', @@ -3440,6 +3472,7 @@ CALL unified_assert_table('baiyue_audit', 'sys_audit_log'); CALL unified_assert_table('baiyue_event_bridge', 'bridge_inbox'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at'); @@ -4733,6 +4766,21 @@ DROP PROCEDURE IF EXISTS unified_assert_final_extensions; DELIMITER $$ CREATE PROCEDURE unified_assert_final_extensions() BEGIN + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code') <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:订单入仓码唯一索引未正确创建'; + END IF; + IF EXISTS ( + SELECT 1 FROM baiyue_order.ord_order + WHERE deleted = 0 + AND ((status = 'DRAFT' AND inbound_code IS NOT NULL) + OR (status <> 'DRAFT' AND inbound_code IS NULL)) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:订单状态与入仓码数据不一致'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name IN ('fl_preload_plan', 'fl_split_operation', 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 71a1caa22..1e71e89d9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -53,7 +53,9 @@ public class InternalInboundOrderController { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); } if (command.tenantId() <= 0 || command.orderId() <= 0 || command.warehouseId() == null - || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()) { + || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank() + || command.inboundCode() == null || command.inboundCode().isBlank() + || command.inboundCode().length() > 40) { throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整"); } return ApiResponse.success(service.createFromApprovedOrder(command)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 7f9a0857a..8d81f3d8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -7,6 +7,7 @@ public record OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, + String inboundCode, Long customerId, String customerName, Long warehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 5f51a2aab..619c0168d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -38,7 +38,7 @@ public class InboundOrderCreationService { inbound.setTenantId(command.tenantId()); inbound.setOrderId(command.orderId()); inbound.setOrderNo(command.orderNo()); - inbound.setInboundCode("RK-" + command.orderId()); + inbound.setInboundCode(command.inboundCode()); inbound.setProcessStatus("WAIT_MEASURE"); inbound.setOrderStatusSnapshot("APPROVED"); inbound.setMeasureReviewStatus("NOT_SUBMITTED"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index d237a107b..f1b696cc2 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -6,6 +6,19 @@ info: security: - bearerAuth: [] paths: + /internal/firstleg/v1/inbounds/order-approved: + post: + summary: 订单审核通过后幂等创建入库单 + description: 仅供订单服务调用;入仓码由订单提交时生成,Firstleg 必须原样沿用。承运商可在后续履约或计费前补充。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderApprovedInboundCommand'} + responses: + '200': {description: 成功或业务失败;相同客户和订单重放返回已有入库单 ID} /api/platform/v1/first-leg/tasks: get: summary: 分页查询履约任务 @@ -224,6 +237,10 @@ components: in: header name: X-Signature description: HMAC-SHA256 小写十六进制签名 + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token parameters: XTimestamp: name: X-Timestamp @@ -259,6 +276,36 @@ components: in: query schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} schemas: + OrderApprovedInboundCommand: + type: object + required: [tenantId, orderId, inboundCode, customerName, warehouseId] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string, nullable: true} + inboundCode: {type: string, minLength: 1, maxLength: 40, example: RK-2085308606829346818} + customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + customerName: {type: string, minLength: 1} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + warehouseName: {type: string, nullable: true} + deliveryWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + deliveryWarehouseName: {type: string, nullable: true} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + businessType: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 可选,可在后续履约或计费前补充} + carrierName: {type: string, nullable: true} + taxMode: {type: string, nullable: true} + containerMode: {type: string, nullable: true} + customsInspection: {type: boolean, nullable: true} + destinationType: {type: string, nullable: true} + destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + destinationCode: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + publicRemark: {type: string, nullable: true} + expressCompanyName: {type: string, nullable: true} + expressTrackingNo: {type: string, nullable: true} MerchantInboundBillingEnvelope: type: object properties: 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 a24689cdc..2522afe1a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.InboundOrderReference; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.service.InboundOrderCreationService; import com.baiyue.firstleg.service.UnifiedInboundQueryService; import com.baiyue.firstleg.dto.UnifiedInboundQuery; @@ -63,4 +64,28 @@ class InternalInboundOrderReferenceControllerTest { .isInstanceOf(BizException.class); verify(unifiedQueryService).page(request); } + + @Test + void approvedOrderRequiresInboundCode() { + assertThatThrownBy(() -> controller.createFromApprovedOrder( + "internal-token", approvedCommand(" ", null))) + .isInstanceOf(BizException.class); + } + + @Test + void approvedOrderAllowsMissingCarrier() { + OrderApprovedInboundCommand command = approvedCommand("RK-77", null); + when(service.createFromApprovedOrder(command)).thenReturn(88L); + + assertThat(controller.createFromApprovedOrder("internal-token", command).data()).isEqualTo(88L); + verify(service).createFromApprovedOrder(command); + } + + private static OrderApprovedInboundCommand approvedCommand(String inboundCode, Long carrierId) { + return new OrderApprovedInboundCommand( + 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓", + null, null, null, "FBA", "AIR", "空运", carrierId, + carrierId == null ? null : "承运商", null, null, null, null, + null, null, null, null, null, null); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java new file mode 100644 index 000000000..14e42fd17 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundOrderCreationServiceTest { + @Mock + private InboundOrderMapper orders; + @Mock + private InboundStatusHistoryMapper histories; + @InjectMocks + private InboundOrderCreationService service; + + @Test + void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() { + OrderApprovedInboundCommand command = approvedCommand("RK-CUSTOM-77"); + InboundOrderEntity existing = new InboundOrderEntity(); + existing.setId(900L); + existing.setTenantId(10001L); + existing.setOrderId(77L); + existing.setInboundCode("RK-CUSTOM-77"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing); + + long createdId = service.createFromApprovedOrder(command); + long replayedId = service.createFromApprovedOrder(command); + + ArgumentCaptor inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class); + verify(orders, times(1)).insert(inboundCaptor.capture()); + verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); + assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77"); + assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); + assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId()); + assertThat(replayedId).isEqualTo(900L); + } + + private static OrderApprovedInboundCommand approvedCommand(String inboundCode) { + return new OrderApprovedInboundCommand( + 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓", + null, null, null, "FBA", "AIR", "空运", null, null, + null, null, null, null, null, null, null, null, null, null); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 6ba498b8f..9a58afa5b 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -29,7 +29,7 @@ public interface FirstlegInboundClient { } record OrderApprovedInboundCommand( - long tenantId, long orderId, String orderNo, Long customerId, String customerName, + long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName, Long carrierId, String carrierName, String taxMode, String containerMode, diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 06ec9cda8..6a7b85119 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -146,7 +146,7 @@ public class PlatformFirstLegOrderController { } @BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过") - @Operation(summary = "Approve / 审核通过", description = "Approves a submitted order and records its status history. / 审核通过已提交订单,并记录状态历史。") + @Operation(summary = "Approve / 审核通过", description = "Approves a submitted order without requiring carrier assignment and records its status history. / 审核通过已提交订单,不要求预先指派承运商,并记录状态历史。") @PostMapping("/{id}/approve") @PreAuthorize("hasAuthority('platform:order:approve')") public ApiResponse approve(@AuthenticationPrincipal AuthenticatedUser user, @@ -156,7 +156,7 @@ public class PlatformFirstLegOrderController { } @BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商") - @Operation(summary = "指派承运商", description = "为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填。") + @Operation(summary = "指派承运商", description = "可选地为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填;该动作不是审核前置条件。") @PutMapping("/{id}/carrier") @PreAuthorize("hasAuthority('platform:order:approve')") public ApiResponse assignCarrier(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 44d97d8eb..6f0262346 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -21,6 +21,7 @@ public class OrderEntity extends BaseEntity { private Long id; private Long tenantId; private String orderNo; + private String inboundCode; private Long customerId; private String customerName; private OrderStatus status; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index c2d9b2eab..e1f3fd187 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -161,6 +161,7 @@ public class OrderRepository { .eq(tenantId != null, OrderEntity::getTenantId, tenantId) .in(OrderEntity::getStatus, statuses) .like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo()) + .like(StringUtils.hasText(query.inboundCode()), OrderEntity::getInboundCode, query.inboundCode()) .eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId()) .eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId, query.receivingWarehouseId()) diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index ed44baa9e..90632951c 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -97,6 +97,7 @@ public class OrderApplicationService { throw new BizException("当前订单状态不允许编辑订单资料"); } apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); + if (before == OrderStatus.REJECTED) order.setInboundCode(null); if (before == OrderStatus.SUBMITTED) { order.setStatus(OrderStatus.SUBMITTED); } @@ -118,11 +119,7 @@ public class OrderApplicationService { masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); } - if (target == OrderStatus.APPROVED) { - if (order.getCarrierId() == null || !StringUtils.hasText(order.getCarrierName())) { - throw new BizException(com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, - "请先选择有效承运商后再审核通过"); - } + if (target == OrderStatus.APPROVED && order.getCarrierId() != null) { var carrier = masterDataGuard.validateCarrier(order.getCarrierId()); order.setCarrierName(carrier.partnerName()); } @@ -130,7 +127,11 @@ public class OrderApplicationService { throw new BizException("Reason is required"); } order.setStatus(target); order.setUpdatedBy(userId); - if (target == OrderStatus.SUBMITTED) order.setSubmittedAt(LocalDateTime.now()); + if (target == OrderStatus.SUBMITTED) { + if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode("RK-" + order.getId()); + order.setSubmittedAt(LocalDateTime.now()); + } + if (target == OrderStatus.DRAFT) order.setInboundCode(null); if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); @@ -423,7 +424,8 @@ public class OrderApplicationService { } private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) { return new FirstlegInboundClient.OrderApprovedInboundCommand( - order.getTenantId(), order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), + order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(), + order.getCustomerId(), order.getCustomerName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(), order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(), diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index e45b24dd9..ac48ef282 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -51,8 +51,9 @@ public class UnifiedOrderQueryService { List statuses = "PENDING_REVIEW".equals(query.mainStatus()) ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; PageResult source = orders.unifiedPage(tenantId, query, statuses); - Map inboundByOrder = summaries( - tenantId, source.records().stream().map(OrderEntity::getId).toList()); + Map inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) + ? Map.of() + : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); List rows = source.records().stream() .map(order -> row(order, inboundByOrder.get(order.getId()))).toList(); return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); @@ -64,7 +65,8 @@ public class UnifiedOrderQueryService { OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId), orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(), orders.findHistory(tenantId, orderId)); - FirstlegUnifiedOrderClient.Detail inbound = firstlegDetail(tenantId, orderId); + FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED + ? null : firstlegDetail(tenantId, orderId); UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary()); List timeline = new ArrayList<>(); orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( @@ -121,7 +123,8 @@ public class UnifiedOrderQueryService { order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null, order.getStatus(), order.getCustomerId(), order.getCustomerName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), - order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, null, + order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, + order.getInboundCode(), order.getExpectedShipDate(), null, null, null, null, null, null, null, null, order.getCreatedAt(), order.getUpdatedAt()); } @@ -213,7 +216,7 @@ public class UnifiedOrderQueryService { private boolean requiresFirstlegQuery(UnifiedOrderQuery query) { return (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) - || StringUtils.hasText(query.inboundCode()) || query.expectedArrivalDate() != null + || query.expectedArrivalDate() != null || StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo()) || StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null || StringUtils.hasText(query.measurementReviewStatus()); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 47368a380..2f8bae6d5 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号', + inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态', @@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), + UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c952b3ae2..f3182588a 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -11,7 +11,8 @@ paths: summary: 平台统一订单列表 description: >- 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 - 有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion + 草稿不生成 inboundCode,客户提交进入待审核后即返回固定 inboundCode;有入库单时同时返回 + inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 @@ -45,7 +46,7 @@ paths: /api/platform/v1/orders/{orderId}: get: summary: 平台统一订单详情 - description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;平台可见内部备注。 + description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;待审核订单直接返回 Order 在提交时生成的 inboundCode;平台可见内部备注。 parameters: - $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedTenantId' @@ -222,7 +223,7 @@ paths: /api/platform/v1/orders/first-leg/{id}/carrier: put: summary: 为待审核订单指派承运商 - description: 只接收承运商 ID;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照。审核通过前必须完成指派。 + description: 可选的独立指派动作;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照,不作为审核通过的前置条件。 parameters: - $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/TenantId' @@ -263,7 +264,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] UnifiedInboundCode: - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 64}} + {name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: name: internalOrderStatus in: query diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 9a24965f5..86d0cd632 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -54,7 +54,8 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); - verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT)); + verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT + && order.getInboundCode() == null)); } @Test @@ -120,17 +121,20 @@ class OrderApplicationServiceStateTest { } @Test - void approvalRequiresCarrierAssignment() { + void approvalDoesNotRequireCarrierAssignmentAndPublishesInboundCode() { OrderRepository repository = mock(OrderRepository.class); OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + order.setOrderNo("FL1"); + order.setInboundCode("RK-1"); when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); - assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) - .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null)) - .isInstanceOf(BizException.class) - .hasMessageContaining("请先选择有效承运商"); + service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null); - verify(repository, never()).saveOrder(anyLong(), any()); + assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED); + verify(repository).insertOutbox(argThat(event -> event.getPayloadJson().contains("\"inboundCode\":\"RK-1\"") + && event.getPayloadJson().contains("\"carrierId\":null"))); } @Test @@ -153,6 +157,25 @@ class OrderApplicationServiceStateTest { verify(masterData).validateCarrier(9L); } + @Test + void submittingDraftGeneratesAndPersistsInboundCode() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(new OrderCargoEntity())); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + when(repository.saveOrder(20002L, order)).thenReturn(1); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); + assertThat(order.getInboundCode()).isEqualTo("RK-1"); + assertThat(order.getSubmittedAt()).isNotNull(); + verify(repository).saveOrder(20002L, order); + } + @Test void merchantDraftEditorStillCannotEditSubmittedOrder() { OrderRepository repository = mock(OrderRepository.class); diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 246bcec01..59df0cb15 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -38,6 +38,7 @@ class UnifiedOrderQueryServiceTest { void pendingReviewIsPagedFromOrderAndMappedToApproveAction() { UnifiedOrderQuery query = query("PENDING_REVIEW"); OrderEntity order = order(OrderStatus.SUBMITTED); + order.setInboundCode("RK-77"); when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); when(firstleg.summaries(eq("internal-token"), any())) @@ -48,7 +49,58 @@ class UnifiedOrderQueryServiceTest { assertThat(result.records()).singleElement().satisfies(row -> { assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); + assertThat(row.inboundCode()).isEqualTo("RK-77"); + assertThat(row.inboundId()).isNull(); }); + verify(firstleg, never()).summaries(any(), any()); + } + + @Test + void pendingReviewInboundCodeFilterStaysInOrderQuery() { + UnifiedOrderQuery query = new UnifiedOrderQuery( + null, null, "PENDING_REVIEW", null, null, null, "RK-77", null, + null, null, null, null, null, null, null, null, null, null, + null, null, 1, 20); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.page(10001L, query); + + verify(orders).unifiedPage(10001L, query, List.of(OrderStatus.SUBMITTED)); + verify(firstleg, never()).page(any(), any()); + verify(firstleg, never()).summaries(any(), any()); + } + + @Test + void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() { + UnifiedOrderQuery query = new UnifiedOrderQuery( + null, null, null, null, null, null, "RK-77", null, + null, null, null, null, null, null, null, null, null, null, + null, null, 1, 20); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.page(10001L, query); + + verify(orders).unifiedPage(eq(10001L), eq(query), any()); + verify(firstleg, never()).page(any(), any()); + } + + @Test + void pendingReviewDetailUsesOrderInboundCodeWithoutCallingFirstleg() { + OrderEntity order = order(OrderStatus.SUBMITTED); + order.setInboundCode("RK-77"); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); + + var result = service.detail(10001L, 77L, true); + + assertThat(result.summary().inboundCode()).isEqualTo("RK-77"); + assertThat(result.order().order().getInboundCode()).isEqualTo("RK-77"); + verify(firstleg, never()).detail(any(), any()); } @Test