From 87e919f3c48cb59d7fa1eb5f9d895c8eef65688d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 16:06:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/audit/dto/AuditLogResponses.java | 1 + .../audit/service/AuditLogQueryService.java | 2 +- ...latformAuditLogControllerContractTest.java | 13 ++ .../common/audit/BusinessAuditAspectTest.java | 28 +++- .../audit/SensitiveDataSanitizerTest.java | 14 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 8 +- .../finance/service/FinanceService.java | 46 +++++++ .../baiyue/finance/FinanceContractTest.java | 3 + .../finance/FinanceServiceValidationTest.java | 54 ++++++++ .../PlatformLoadingPdaController.java | 3 +- .../baiyue/firstleg/dto/LoadingPickDtos.java | 9 +- .../repository/OceanBookingRepository.java | 4 + .../service/OceanBookingService.java | 4 + .../firstleg/service/ContainerService.java | 15 +-- .../service/InboundMeasurementService.java | 4 + .../firstleg/service/LoadingPickService.java | 43 +++--- .../service/MerchantPriceBookService.java | 4 +- .../firstleg/service/SmartLoadingService.java | 9 +- .../resources/db/phase2-inbound-schema.sql | 4 +- .../main/resources/openapi/firstleg-v1.yaml | 20 +++ .../openapi/phase3-pda-convergence-v1.yaml | 2 - .../PdaConvergenceOpenApiContractTest.java | 13 ++ ...tformLoadingPdaControllerContractTest.java | 2 +- .../controller/PriceBookContractTest.java | 30 ++++- .../service/OceanBookingServiceTest.java | 21 +++ .../service/ContainerServiceTest.java | 7 +- .../InboundMeasurementServiceTest.java | 22 +++ .../service/LoadingPickServiceTest.java | 87 ++++++++---- .../service/SmartLoadingServiceTest.java | 4 +- .../GatewayFileRouteConfigurationTest.java | 9 +- .../controller/InternalTenantController.java | 2 +- .../controller/PlatformTenantController.java | 2 +- .../PlatformNotificationController.java | 2 + .../notification/dto/NotificationDtos.java | 12 +- .../mapper/NotificationTaskMapper.java | 68 +++++++++- .../repository/NotificationRepository.java | 48 +++++-- ...otificationDispatchTransactionService.java | 74 ++++++++++ .../service/NotificationService.java | 76 ++++++++--- .../CustomerListExportProviderService.java | 3 +- .../com/baiyue/iam/service/TenantService.java | 12 +- iam/src/main/resources/db/iam.sql | 2 +- iam/src/main/resources/openapi/iam-v1.yaml | 4 +- .../resources/openapi/notification-v1.yaml | 24 +++- .../PlatformTenantControllerContractTest.java | 2 +- ...icationDispatchTransactionServiceTest.java | 126 ++++++++++++++++++ ...tificationInboxPermissionContractTest.java | 10 ++ .../NotificationOpenApiContractTest.java | 10 ++ .../NotificationSchedulerRecoveryTest.java | 38 +++++- .../notification/NotificationServiceTest.java | 77 +++++++++-- .../baiyue/iam/service/TenantServiceTest.java | 36 ++++- .../phase3-container-loading.mjs | 29 +++- ...se3-container-regression-contract.test.mjs | 8 ++ .../phase3-producer-chain-contract.test.mjs | 7 +- .../phase3-warehouse-rbac.mjs | 2 +- 54 files changed, 984 insertions(+), 175 deletions(-) create mode 100644 iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java create mode 100644 iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java index def500edf..8f43954e7 100644 --- a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java +++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java @@ -24,6 +24,7 @@ public final class AuditLogResponses { String username, String appCode, String clientType, + String clientIp, String requestId, String traceId, String serviceName, diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java index b16fec9e1..d89b6527d 100644 --- a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java @@ -41,7 +41,7 @@ public class AuditLogQueryService { return new AuditLogResponses.ListItem( entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(), entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(), - entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(), + entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(), entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt()); } diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java index 95f9dc7c9..b7f14cbab 100644 --- a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java +++ b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.security.access.prepost.PreAuthorize; import java.util.List; import java.util.Set; @@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest { verify(service).detail(8L); } + @Test + void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException { + String expected = "hasAuthority('platform:audit:list')"; + + assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class, + long.class, long.class, Long.class, Long.class, String.class, String.class, String.class, + Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } + private static AuthenticatedUser user(String appCode) { return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list")); } diff --git a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java index b76468b14..792347408 100644 --- a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java +++ b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java @@ -48,7 +48,26 @@ class BusinessAuditAspectTest { assertThat(event.operatorTenantId()).isEqualTo(10001L); } + @Test + void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable { + AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L, + "platform.admin", "PLATFORM", "WEB", Set.of()), 10001L); + + assertThat(event.success()).isFalse(); + assertThat(event.errorCode()).isEqualTo("IllegalStateException"); + assertThat(event.beforeData()).isEqualTo("[10001]"); + assertThat(event.afterData()).isNull(); + } + private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable { + return invoke(user, requestedTenantId, null); + } + + private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable { + return invoke(user, requestedTenantId, new IllegalStateException("validation failed")); + } + + private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable { AuditSink sink = mock(AuditSink.class); BusinessAuditAspect aspect = new BusinessAuditAspect(); ReflectionTestUtils.setField(aspect, "sink", sink); @@ -68,9 +87,14 @@ class BusinessAuditAspectTest { when(joinPoint.getSignature()).thenReturn(signature); when(joinPoint.getTarget()).thenReturn(target); when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId}); - when(joinPoint.proceed()).thenReturn("ok"); + if (failure == null) when(joinPoint.proceed()).thenReturn("ok"); + else when(joinPoint.proceed()).thenThrow(failure); - aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class)); + try { + aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class)); + } catch (Throwable error) { + if (failure == null) throw error; + } ArgumentCaptor event = ArgumentCaptor.forClass(AuditEvent.class); verify(sink).publish(event.capture()); diff --git a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java index 76be507e3..ab9ee8e3a 100644 --- a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java +++ b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java @@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest { assertThat(parsed.path("_truncated").asBoolean()).isTrue(); assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40); } + + @Test + void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception { + SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024); + + String sanitized = sanitizer.sanitize(Map.of( + "profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"), + "contacts", java.util.List.of(Map.of("emailAddress", "user@example.com")))); + JsonNode parsed = mapper.readTree(sanitized); + + assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******"); + assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******"); + assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******"); + } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 600871730..24dbaba03 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3449,8 +3449,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', - inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓入仓码物流前缀', - overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', @@ -3474,9 +3474,9 @@ BEGIN END$$ DELIMITER ; CALL price_channel_add_column('inbound_code_prefix', - "inbound_code_prefix VARCHAR(6) NULL COMMENT '普通目的仓入仓码物流前缀' AFTER aliases_json"); + "inbound_code_prefix VARCHAR(6) NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用' AFTER aliases_json"); CALL price_channel_add_column('overseas_inbound_code_prefix', - "overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀' AFTER inbound_code_prefix"); + "overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀' AFTER inbound_code_prefix"); DROP PROCEDURE IF EXISTS price_channel_add_column; CREATE TABLE IF NOT EXISTS fl_price_origin_alias ( diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 917a1bc1a..5df93c394 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -16,8 +16,10 @@ import java.sql.PreparedStatement; import java.sql.Statement; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */ @Service @@ -116,6 +118,7 @@ public class FinanceService { if (replay != null) return bill(tenantId, replay); if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细"); request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); }); + validateBillSources(tenantId, request.lines()); BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add); if (total.signum() < 0) throw bad("账单金额不能为负数"); Object credit = customerTerms(tenantId).get("credit_days"); @@ -139,6 +142,49 @@ public class FinanceService { return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId); } + /** Initial statements may only snapshot approved, included Finance sources; the client cannot choose an amount. */ + private void validateBillSources(long tenantId, List lines) { + Set seen = new HashSet<>(); + for (BillLine line : lines) { + if (!seen.add(line.sourceId() + ":" + line.sourceVersion())) throw bad("同一费用来源版本不能重复加入账单"); + List> rows = jdbc.queryForList( + "SELECT customer_id,source_fee_version,direction,currency_code,amount,status,review_status,settlement_included " + + "FROM fin_fee_source WHERE id=? FOR UPDATE", line.sourceId()); + if (rows.size() != 1) throw bad("费用来源不存在"); + Map source = rows.get(0); + if (number(source, "customer_id") != tenantId + || number(source, "source_fee_version") != line.sourceVersion() + || !"RECEIVABLE".equals(source.get("direction")) + || !"CNY".equals(source.get("currency_code")) + || !"AVAILABLE".equals(source.get("status")) + || !"APPROVED".equals(source.get("review_status")) + || !truth(source.get("settlement_included"))) { + throw bad("费用来源未审核、未参与结算或不属于当前客户"); + } + BigDecimal sourceAmount = decimal(source, "amount"); + if (sourceAmount.compareTo(line.amount()) != 0) throw bad("账单金额必须等于费用来源金额"); + Integer used = jdbc.queryForObject( + "SELECT COUNT(*) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id " + + "WHERE l.tenant_id=? AND l.source_id=? AND l.source_version=? AND b.status<>'VOIDED'", + Integer.class, tenantId, line.sourceId(), line.sourceVersion()); + if (used != null && used > 0) throw bad("费用来源已被有效账单占用"); + } + } + + private static long number(Map row, String key) { + Object value = row.get(key); + return value instanceof Number n ? n.longValue() : Long.parseLong(String.valueOf(value)); + } + + private static BigDecimal decimal(Map row, String key) { + Object value = row.get(key); + return value instanceof BigDecimal decimal ? decimal : new BigDecimal(String.valueOf(value)); + } + + private static boolean truth(Object value) { + return Boolean.TRUE.equals(value) || "1".equals(String.valueOf(value)) || "true".equalsIgnoreCase(String.valueOf(value)); + } + @Transactional public Map issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) { requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion)); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 7ad137d03..2cdebed9a 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -24,6 +24,9 @@ class FinanceContractTest { assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo")); assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求")); assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')")); + assertTrue(service.contains("validateBillSources(tenantId, request.lines())")); + assertTrue(service.contains("账单金额必须等于费用来源金额")); + assertTrue(service.contains("source_fee_version") && service.contains("settlement_included")); String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java")); assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile")); assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 971a49cf2..5c2495798 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -9,10 +9,13 @@ import com.baiyue.common.core.exception.BizException; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @@ -21,6 +24,45 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class FinanceServiceValidationTest { + @Test + void acceptsOnlyApprovedIncludedReceivableSourceAtItsRecordedAmount() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L))) + .thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50"))); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), + eq(9L), eq(81L), eq(3))).thenReturn(0); + FinanceService service = service(jdbc); + + assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "validateBillSources", 9L, + List.of(billLine(81L, 3, "125.50")))); + } + + @Test + void rejectsClientAmountThatDiffersFromFinanceSourceSnapshot() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L))) + .thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50"))); + FinanceService service = service(jdbc); + + BizException error = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service, + "validateBillSources", 9L, List.of(billLine(81L, 3, "99.00")))); + + assertEquals("账单金额必须等于费用来源金额", error.getMessage()); + verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), + eq(Integer.class), any(), any(), any()); + } + + @Test + void rejectsSourceThatIsNotApprovedAndIncludedForSettlement() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L))) + .thenReturn(List.of(source(9L, 3, "AVAILABLE", "PENDING", false, "125.50"))); + FinanceService service = service(jdbc); + + assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service, + "validateBillSources", 9L, List.of(billLine(81L, 3, "125.50")))); + } + @Test void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() { var rows = List.of( @@ -240,4 +282,16 @@ class FinanceServiceValidationTest { return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(), mock(FinanceVoucherValidator.class)); } + + private static FinanceService.BillLine billLine(long sourceId, int version, String amount) { + return new FinanceService.BillLine("FIRSTLEG_FEE", sourceId, version, null, 31L, 41L, + "FREIGHT", "运费", new BigDecimal(amount)); + } + + private static Map source(long customerId, int version, String status, + String reviewStatus, boolean included, String amount) { + return Map.of("customer_id", customerId, "source_fee_version", version, + "direction", "RECEIVABLE", "currency_code", "CNY", "amount", new BigDecimal(amount), + "status", status, "review_status", reviewStatus, "settlement_included", included); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 872bbfdf1..269c7c457 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -45,12 +45,11 @@ public class PlatformLoadingPdaController { @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String containerNo, @RequestParam(required = false) String cabinetNo, @RequestParam(required = false) Long loadingPlanId, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.task(containerNo, cabinetNo, loadingPlanId, warehouseId)); + return ApiResponse.success(service.task(cabinetNo, loadingPlanId, warehouseId)); } @GetMapping("/tasks/pending") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index f08139693..a888e52b7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -60,17 +60,10 @@ public final class LoadingPickDtos { @NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Long loadingPlanId, @NotNull @Min(1) Long containerBookingId, - @Size(max = 64) String containerNo, @NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max = 64) String inventorySnapshotToken, @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items - ) { - @Deprecated - public BatchRequest(Long warehouseId, String containerNo, Integer expectedVersion, - String inventorySnapshotToken, List items) { - this(warehouseId, null, null, containerNo, expectedVersion, inventorySnapshotToken, items); - } - } + ) {} public record BatchItemRequest( @NotNull @Min(1) Long itemId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 7f4519d6f..05753f42b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -59,6 +59,10 @@ public class OceanBookingRepository { public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public OceanBookingContainerAllocationEntity allocationByContainer(long containerBookingId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); + } public List histories(long id) { return histories.selectList(new LambdaQueryWrapper().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id).orderByAsc(OceanBookingStatusHistoryEntity::getCreatedAt)); } public void replaceParties(long id, List values) { parties.delete(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); } public void replaceRequirements(long id, List values) { requirements.delete(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 926b32f25..9eb7c19fc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.service.LoadingPickService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -31,6 +32,7 @@ public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); @Resource private OceanBookingRepository repository; @Resource private ObjectMapper objectMapper; + @Resource private LoadingPickService loadingPickService; public PageResult page(Query query) { String status = trim(query.status()); @@ -106,6 +108,8 @@ public class OceanBookingService { requireStatus(before, OceanBookingStatus.CONFIRMED); if (repository.release(id, request, operatorId, name) != 1) throw concurrent(); repository.syncReleasedTransport(id, request, operatorId, name); + repository.allocations(id).forEach(allocation -> + loadingPickService.beginIfReady(allocation.getContainerBookingId(), operatorId, name)); OceanBookingEntity after = required(id); addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index c3657cab2..ed18731ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -104,23 +104,10 @@ public class ContainerService { history.setOperatorId(operatorId); history.setOperatorName(operatorName); if (transportHistories != null) transportHistories.insert(history); ContainerBookingEntity updated = required(id); - beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName); + loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName); return updated; } - /** 预装清单先推送、后录入集装箱号时,补建对应的 PDA 拣货任务。 */ - private void beginPdaTaskAfterContainerBound(ContainerBookingEntity booking, String containerNo, - long operatorId, String operatorName) { - if (containerNo == null) return; - LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId()); - if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; - // The PDA task and its item versions are stable work identities. A later transport-number - // correction only changes the booking display value and must not resynchronize the task. - if (loadingPickRepository.taskByPlan(plan.getId()) != null) return; - loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName, - smartLoadingRepository.items(plan.getId())); - } - private ContainerBookingEntity required(long id) { ContainerBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index ed3aa7aca..199f96517 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -174,8 +174,12 @@ public class InboundMeasurementService { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); + boolean wasConfirmed = WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()); applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(), request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName); + if (wasConfirmed) { + recalculateConfirmedWeight(inbound, operatorId); + } return toWeight(weight); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index c2ab1987d..4c620349c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -33,6 +33,7 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import jakarta.annotation.Resource; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; @@ -65,6 +66,8 @@ public class LoadingPickService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource + private OceanBookingRepository oceanBookingRepository; + @Resource private LoadingListService loadingListService; @Resource private PdaPickSettlementService settlementService; @@ -73,6 +76,22 @@ public class LoadingPickService { @Value("${baiyue.security.internal-service-token:test-internal-service-token}") private String internalToken; + /** Opens the field task only after planning, transport identity and ocean release have converged. */ + @Transactional + public void beginIfReady(long containerBookingId, long operatorId, String operatorName) { + var booking = smartLoadingRepository.booking(containerBookingId); + if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return; + var plan = smartLoadingRepository.planByBooking(containerBookingId); + if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; + var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); + if (allocation == null) return; + var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); + if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) return; + if (repository.taskByPlan(plan.getId()) != null) return; + begin(plan.getId(), booking.getContainerNo(), operatorId, operatorName, + smartLoadingRepository.items(plan.getId())); + } + @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, List planItems) { @@ -111,29 +130,14 @@ public class LoadingPickService { } } - public Task task(String containerNo, long warehouseId) { - if (!StringUtils.hasText(containerNo)) { - throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空"); - } - if (warehouseId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); - } - LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId); - if (task == null) { - throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); - } - return view(task, warehouseId); - } - - public Task task(String containerNo, String cabinetNo, Long loadingPlanId, long warehouseId) { - int selectors = (StringUtils.hasText(containerNo) ? 1 : 0) + (StringUtils.hasText(cabinetNo) ? 1 : 0) + public Task task(String cabinetNo, Long loadingPlanId, long warehouseId) { + int selectors = (StringUtils.hasText(cabinetNo) ? 1 : 0) + (loadingPlanId != null ? 1 : 0); if (selectors != 1) throw new BizException(ResultCode.BAD_REQUEST, - "containerNo、cabinetNo、loadingPlanId必须且只能提供一个"); + "cabinetNo、loadingPlanId必须且只能提供一个"); LoadingPickTaskEntity task = StringUtils.hasText(cabinetNo) ? repository.taskByCabinet(cabinetNo.trim(), warehouseId) - : loadingPlanId != null ? repository.taskByPlan(loadingPlanId, warehouseId) - : repository.taskByContainer(containerNo.trim(), warehouseId); + : repository.taskByPlan(loadingPlanId, warehouseId); if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); return view(task, warehouseId); } @@ -854,6 +858,7 @@ public class LoadingPickService { } StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|') .append(request.warehouseId()).append('|').append(request.loadingPlanId()).append('|') + .append(request.containerBookingId()).append('|') .append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim()); request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId, Comparator.nullsFirst(Long::compareTo))) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index ba9a37c8c..7ec7922d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -49,7 +49,7 @@ public class MerchantPriceBookService { } public Response requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode, - String destinationType) { + String destinationType) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); Long versionId = repository.activeVersionId(); @@ -59,7 +59,7 @@ public class MerchantPriceBookService { } var prefix = repository.channelPrefix(normalized); if (prefix == null || !StringUtils.hasText(prefix.inboundCodePrefix())) { - throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); + throw new BizException(ResultCode.CONFLICT, "所选渠道未配置进仓编码"); } String selected = "OVERSEAS".equals(destinationType) && StringUtils.hasText(prefix.overseasInboundCodePrefix()) ? prefix.overseasInboundCodePrefix() : prefix.inboundCodePrefix(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 335660bea..7e0969b11 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -225,21 +225,22 @@ public class SmartLoadingService { String key = trim(idempotencyKey); if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); - if (key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId())); + if (key.equals(plan.getPushIdempotencyKey())) { + loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName); + return view(plan, requireBooking(plan.getContainerBookingId())); + } if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送"); if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List items = repository.items(planId); if (items.isEmpty()) throw validation("装柜明细不能为空"); checkPlanCapacity(booking, items); - if (StringUtils.hasText(booking.getContainerNo())) { - loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, items); - } try { if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName); LoadingPlanEntity pushed = repository.plan(planId); + loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName); return view(pushed, requireBooking(pushed.getContainerBookingId())); } diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index eb50fe04b..3fc7fa9cb 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -421,8 +421,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', - inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓入仓码物流前缀', - overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 093b8d4f2..b7054cf21 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -49,6 +49,26 @@ paths: - {name: initializationKey, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}} responses: '200': {description: 幂等补偿完成} + /internal/firstleg/v1/pricing/channels/validate: + post: + summary: 校验订单渠道并返回进仓编码前缀 + description: 仅供订单服务调用;前缀按渠道和目的仓类型选择,同一渠道的包税与不包税订单共用编码前缀。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId, originWarehouseId, channelCode, destinationType] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + channelCode: {type: string, minLength: 1, maxLength: 64} + destinationType: {type: string, enum: [FBA, OVERSEAS, PRIVATE]} + responses: + '200': {description: 成功时 data 返回 channelCode 和 inboundCodePrefix;未配置对应进仓编码时返回业务冲突} /api/platform/v1/first-leg/customer-dimension-config: get: summary: 查询客户固定四箱规尺寸配置 diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index aebdd7383..8074f91ad 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -7,7 +7,6 @@ paths: get: summary: Find the active container picking task in one platform warehouse parameters: - - {name: containerNo, in: query, required: false, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}} - {name: cabinetNo, in: query, required: false, schema: {type: string}} - {name: loadingPlanId, in: query, required: false, schema: {type: integer, format: int64}} - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} @@ -195,7 +194,6 @@ components: warehouseId: {type: integer, format: int64, minimum: 1} loadingPlanId: {type: integer, format: int64, minimum: 1} containerBookingId: {type: integer, format: int64, minimum: 1} - containerNo: {type: string, maxLength: 64, nullable: true, deprecated: true, description: Accepted during compatibility period but ignored for identity and idempotency} expectedVersion: {type: integer, minimum: 0} inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64} items: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 58af100bc..33f05f9a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -31,5 +31,18 @@ class PdaConvergenceOpenApiContractTest { .contains("changing it never resynchronizes the task or item versions") .contains("expectedTaskVersion") .contains("CompleteLoadingRequest"); + + String taskLookup = yaml.substring( + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks:"), + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/pending:")); + String batchRequest = yaml.substring( + yaml.indexOf(" BatchRequest:"), + yaml.indexOf(" BatchItemRequest:")); + assertThat(taskLookup) + .contains("name: cabinetNo", "name: loadingPlanId") + .doesNotContain("name: containerNo"); + assertThat(batchRequest) + .contains("loadingPlanId:", "containerBookingId:") + .doesNotContain("containerNo:"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index a173b51db..553b49f95 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformLoadingPdaControllerContractTest { @Test void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception { - authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, String.class, Long.class, long.class); + authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, Long.class, long.class); authority("pendingTasks", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class); authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index ca577af36..d23d7d14b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; @DisplayName("船期价格表公共契约") class PriceBookContractTest { @@ -24,11 +25,23 @@ class PriceBookContractTest { void keepsStableChannelCatalog() { assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15); assertThat(PriceBookChannelCatalog.CHANNELS) - .extracting(value -> value.channelCode() + ":" + value.inboundCodePrefix()) - .contains("MATSON_CLX_TRUCK:ZBYHK", "OA_LA_FULL_TRUCK:PCKK", "OA_CHICAGO_RAIL:CPHT"); - assertThat(PriceBookChannelCatalog.CHANNELS) - .filteredOn(value -> "OA_NEW_YORK_TRUCK".equals(value.channelCode())) - .singleElement().extracting(value -> value.overseasInboundCodePrefix()).isEqualTo("NPHWK"); + .extracting("channelCode", "inboundCodePrefix", "overseasInboundCodePrefix") + .containsExactly( + tuple("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK"), + tuple("MATSON_MAX_TRUCK", "BYHK", "BYHWK"), + tuple("EXX_TRUCK", "EXXHK", "EXXHWK"), + tuple("HEDE_TRUCK", "HDHK", "HDHWK"), + tuple("EXX_RAIL_NY", "NYEXX", null), + tuple("OA_LA_TRUCK", "PCHK", "PCHWK"), + tuple("OA_LA_FULL_TRUCK", "PCKK", null), + tuple("OA_CHICAGO_RAIL", "CPHT", null), + tuple("OA_HOUSTON_TRUCK", "HPHK", null), + tuple("OA_NEW_YORK_TRUCK", "NPHK", "NPHWK"), + tuple("OA_SAVANNAH_TRUCK", "SPHK", null), + tuple("MATSON_CLX_PARCEL", "ZBYHP", null), + tuple("MATSON_MAX_PARCEL", "BYHP", null), + tuple("HEDE_PARCEL", "HDHP", null), + tuple("OA_PARCEL", "PCHP", null)); assertThat(PriceBookChannelCatalog.CHANNELS.stream() .map(PriceBookChannelCatalog.ChannelDefinition::channelCode)) .containsExactly( @@ -102,5 +115,12 @@ class PriceBookContractTest { .contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source") .contains("确认并立即生效") .contains("SUPERSEDED"); + + String firstlegOpenapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(firstlegOpenapi) + .contains("/internal/firstleg/v1/pricing/channels/validate:") + .contains("required: [tenantId, originWarehouseId, channelCode, destinationType]") + .contains("同一渠道的包税与不包税订单共用编码前缀"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index d9cd3c9b0..121390e9d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -5,6 +5,8 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest; +import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; @@ -30,6 +32,7 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; + @Mock private LoadingPickService loadingPickService; @InjectMocks private OceanBookingService service; @BeforeEach @@ -77,6 +80,24 @@ class OceanBookingServiceTest { verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString()); } + @Test + void opensAllocatedContainerPdaTasksAfterRelease() { + OceanBookingEntity confirmed = booking("CONFIRMED", 3); + OceanBookingEntity released = booking("RELEASED", 4); + var allocation = new OceanBookingContainerAllocationEntity(); + allocation.setContainerBookingId(12L); + when(repository.find(1L)).thenReturn(confirmed, released); + when(repository.release(eq(1L), any(ReleaseRequest.class), eq(9L), eq("admin"))).thenReturn(1); + when(repository.allocations(1L)).thenReturn(List.of(allocation)); + var request = new ReleaseRequest(3, "BL-001", "VESSEL", "V001", + LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱"); + + Detail result = service.release(1L, 9L, "admin", "release-1", request); + + assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED"); + verify(loadingPickService).beginIfReady(12L, 9L, "admin"); + } + @Test void keepsCurrentVoyagePendingUntilChangeConfirmation() { OceanBookingEntity current = booking("CONFIRMED", 5); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 19066d250..46693ba9b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -143,9 +143,7 @@ class ContainerServiceTest { service.updateTransport(11L, 9L, "admin", new TransportUpdateRequest("MSCU1234567", "V002", 2)); - verify(loadingPickService).begin(org.mockito.ArgumentMatchers.eq(31L), - org.mockito.ArgumentMatchers.eq("MSCU1234567"), org.mockito.ArgumentMatchers.eq(9L), - org.mockito.ArgumentMatchers.eq("admin"), any()); + verify(loadingPickService).beginIfReady(11L, 9L, "admin"); } @Test @@ -165,8 +163,7 @@ class ContainerServiceTest { service.updateTransport(11L, 9L, "admin", new TransportUpdateRequest("MSCU7654321", "V002", 2)); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), any(), any()); - verify(smartLoadingRepository, never()).items(31L); + verify(loadingPickService).beginIfReady(11L, 9L, "admin"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 52634b9df..009fab68a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -85,6 +85,28 @@ class InboundMeasurementServiceTest { assertThat(revision.getValue().getAfterWeightValue()).isEqualByComparingTo("13.200"); } + @Test + void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setActualGrossWeightKg(new BigDecimal("12.500")); + InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4); + weight.setFinalWeightKg(new BigDecimal("12.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + service.correctWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.CorrectWeightResultRequest( + 4, new BigDecimal("13.200"), "KG", "秤面显示修正")); + + assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM"); + assertThat(weight.getFinalWeightKg()).isNull(); + assertThat(inbound.getActualGrossWeightKg()).isNull(); + verify(repository).updateInboundWeightSummary(inbound); + } + @Test void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 73251d400..35e990082 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -19,6 +19,9 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.mockito.InOrder; @@ -46,6 +49,8 @@ class LoadingPickServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean + private OceanBookingRepository oceanBookingRepository; + @MockBean private LoadingListService loadingListService; @MockBean private PdaPickSettlementService settlementService; @@ -54,6 +59,51 @@ class LoadingPickServiceTest { @Resource private LoadingPickService service; + @Test + void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(smartLoadingRepository.plan(10L)).thenReturn(plan); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + when(repository.taskByPlan(10L)).thenReturn(null); + when(smartLoadingRepository.items(10L)).thenReturn(List.of()); + doAnswer(invocation -> { + invocation.getArgument(0, LoadingPickTaskEntity.class).setId(20L); + return null; + }).when(repository).insert(any(LoadingPickTaskEntity.class)); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository).insert(any(LoadingPickTaskEntity.class)); + } + + @Test + void doesNotOpenTaskBeforeOceanRelease() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + } + @Test void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() { LoadingPickTaskEntity task = task(); @@ -66,8 +116,8 @@ class LoadingPickServiceTest { when(repository.items(20L, 101L)).thenReturn(List.of()); when(repository.cartonLabels(20L)).thenReturn(List.of()); - var byCabinet = service.task(null, "CAB-001", null, 101L); - var byPlan = service.task(null, null, 10L, 101L); + var byCabinet = service.task("CAB-001", null, 101L); + var byPlan = service.task(null, 10L, 101L); assertThat(byCabinet.id()).isEqualTo(20L); assertThat(byPlan.id()).isEqualTo(20L); @@ -76,27 +126,12 @@ class LoadingPickServiceTest { @Test void rejectsMissingOrConflictingCompatibilitySelectors() { - assertThatThrownBy(() -> service.task(null, null, null, 101L)) + assertThatThrownBy(() -> service.task(null, null, 101L)) .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); - assertThatThrownBy(() -> service.task("MSCU", "CAB", null, 101L)) + assertThatThrownBy(() -> service.task("CAB", 10L, 101L)) .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); } - @Test - void legacyContainerLookupReturnsCurrentTransportDisplayWithoutChangingIdentity() { - LoadingPickTaskEntity task = task(); task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); - var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - booking.setId(40L); booking.setContainerNo("MSCU-CURRENT"); - when(repository.taskByContainer("MSCU-CURRENT", 101L)).thenReturn(task); - when(smartLoadingRepository.booking(40L)).thenReturn(booking); - when(repository.items(20L, 101L)).thenReturn(List.of()); - when(repository.cartonLabels(20L)).thenReturn(List.of()); - var result = service.task("MSCU-CURRENT", null, null, 101L); - assertThat(result.id()).isEqualTo(20L); - assertThat(result.loadingPlanId()).isEqualTo(10L); - assertThat(result.currentContainerNo()).isEqualTo("MSCU-CURRENT"); - } - @Test void keepsPlanLoadingWhenItemsRemain() { LoadingPickTaskEntity task = task(); @@ -271,7 +306,7 @@ class LoadingPickServiceTest { ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); @@ -301,7 +336,7 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null))); @@ -325,7 +360,7 @@ class LoadingPickServiceTest { when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "0".repeat(64), List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) @@ -343,7 +378,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = task(); task.setVersion(3); when(repository.taskForUpdate(20L)).thenReturn(task); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 2, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) @@ -373,14 +408,14 @@ class LoadingPickServiceTest { 30L, "PICK", 2, null, "MANUAL", List.of(), 0, null); assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest( - 101L, 10L, 99L, "CSLU1234567", 1, snapshotToken, List.of(line)), 9L, "operator")) + 101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator")) .isInstanceOf(BizException.class) .hasMessageContaining("任务版本已变化"); assertThat(item.getVersion()).isZero(); assertThat(item.getPickedPackageCount()).isZero(); service.batch(20L, "batch-valid", new BatchRequest( - 101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of(line)), 9L, "operator"); + 101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator"); verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L); verify(repository, times(1)).touchTask(20L, 0); @@ -401,7 +436,7 @@ class LoadingPickServiceTest { service.begin(10L, "CSLU1234567", 9L, "operator", List.of(planItem)); - BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 1, + BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index ea790c269..a84dd0918 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -389,7 +389,7 @@ class SmartLoadingServiceTest { assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(error.getMessage()).contains("重量").contains("110%"); verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } @Test @@ -407,7 +407,7 @@ class SmartLoadingServiceTest { assertThat(result.status()).isEqualTo("EDITING"); verify(repository).push(1, "no-container-key", 1, 7, "u"); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + verify(loadingPickService).beginIfReady(99L, 7L, "u"); } private void stubAssignment() { diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index 5dacabec2..c2778a083 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.gateway.config.GatewayProperties; +import org.springframework.http.server.PathContainer; +import org.springframework.web.util.pattern.PathPatternParser; import static org.assertj.core.api.Assertions.assertThat; @@ -101,8 +103,11 @@ class GatewayFileRouteConfigurationTest { assertThat(route.getUri().toString()).isEqualTo("lb://audit-service"); assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { assertThat(predicate.getName()).isEqualTo("Path"); - assertThat(String.join(",", predicate.getArgs().values())) - .contains("/api/platform/v1/audit/**"); + String pattern = String.join(",", predicate.getArgs().values()); + assertThat(pattern).contains("/api/platform/v1/audit/**"); + var matcher = PathPatternParser.defaultInstance.parse("/api/platform/v1/audit/**"); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs"))).isTrue(); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs/8"))).isTrue(); }); } } diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index bcc7206ba..c59fc9cd9 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -127,7 +127,7 @@ public class InternalTenantController { @RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) { + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) { verify(token); return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status)); } 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 4140dd770..0cfcfd30b 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -62,7 +62,7 @@ public class PlatformTenantController { @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status, + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pageMerchants(page, size, keyword, status)); diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java index 7e33c6fba..300cb1377 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java @@ -31,6 +31,8 @@ public class PlatformNotificationController { public ApiResponse status(@PathVariable Long id,@Valid@RequestBody ChangeTemplateStatusRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.changeTemplateStatus(id,request,user.membershipId()));} @GetMapping("/notification-tasks") @PreAuthorize("hasAuthority('platform:notification:task:list')") public ApiResponse> tasks(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String taskNo,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="CREATED|SCHEDULED|DISPATCHING|RETRYING|SENT|DEAD|CANCELLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.tasks(page,size,taskNo,category,title,status));} + @GetMapping("/notification-tasks/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:task:list')") + public ApiResponse taskDetail(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.taskDetail(id));} @PutMapping("/notification-tasks/{id:\\d+}/cancel") @PreAuthorize("hasAuthority('platform:notification:task:cancel')") public ApiResponse cancel(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.cancel(id));} @PutMapping("/notification-tasks/{id:\\d+}/retry") @PreAuthorize("hasAuthority('platform:notification:task:retry')") public ApiResponse retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));} private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");} diff --git a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java index c25ad297c..5e138668e 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java @@ -28,10 +28,10 @@ public final class NotificationDtos { @Size(max = 128) String sourceEventId, @JsonIgnore @Size(max = 128) String sourceEventType) { } - public record NotificationSummary(Long id, String category, String title, String status, + public record NotificationSummary(Long id, String category, String title, String summary, String status, LocalDateTime sentAt, LocalDateTime readAt) { } - public record NotificationDetail(Long id, String category, String title, String body, + public record NotificationDetail(Long id, String category, String title, String summary, String body, String status, String sourceType, String sourceId, LocalDateTime sentAt, LocalDateTime readAt) { } @@ -69,4 +69,12 @@ public final class NotificationDtos { public record TaskListItem(Long id, String taskNo, String category, String title, String status, int recipientCount, LocalDateTime scheduledAt, LocalDateTime sentAt, boolean duplicated) { } + + public record TaskRecipientItem(Long id, String displayName, String appCode, String status, + String failureReason, LocalDateTime deliveredAt) { } + + public record TaskDetail(Long id, String taskNo, String category, String title, String status, + int recipientCount, int attemptCount, LocalDateTime scheduledAt, + LocalDateTime sentAt, String lastError, + List recipients) { } } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java index 0d58a2fef..427b67e18 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java @@ -6,6 +6,35 @@ import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Param; import java.time.LocalDateTime; @Mapper public interface NotificationTaskMapper extends BaseMapper { + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'SCHEDULED' + AND scheduled_at <= #{now} + AND attempt_count < #{maxAttempts} + """) + int claimScheduled(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("now") LocalDateTime now); + + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'RETRYING' + AND updated_at <= #{dueBefore} + AND attempt_count < #{maxAttempts} + """) + int claimRetrying(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("dueBefore") LocalDateTime dueBefore, + @Param("now") LocalDateTime now); + @Update(""" UPDATE iam_notification_task task SET task.status = 'DEAD', @@ -27,15 +56,36 @@ import java.time.LocalDateTime; int deadTimedOutDispatchingWithDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, @Param("now") LocalDateTime now); + @Update(""" + UPDATE iam_notification_task task + SET task.status = 'DEAD', + task.last_error = 'DISPATCH_TIMEOUT_ATTEMPT_LIMIT', + task.updated_at = #{now} + WHERE task.status = 'DISPATCHING' + AND task.updated_at <= #{staleBefore} + AND task.sent_at IS NULL + AND task.attempt_count >= #{maxAttempts} + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_recipient recipient + WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id + ) + """) + int deadTimedOutDispatchingAtAttemptLimit(@Param("staleBefore") LocalDateTime staleBefore, + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + @Update(""" UPDATE iam_notification_task task SET task.status = 'RETRYING', - task.attempt_count = task.attempt_count + 1, task.last_error = 'DISPATCH_TIMEOUT_RECOVERED', task.updated_at = #{now} WHERE task.status = 'DISPATCHING' AND task.updated_at <= #{staleBefore} AND task.sent_at IS NULL + AND task.attempt_count < #{maxAttempts} AND NOT EXISTS ( SELECT 1 FROM iam_notification_recipient recipient WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' @@ -45,5 +95,19 @@ import java.time.LocalDateTime; ) """) int retryTimedOutDispatchingWithoutDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, - @Param("now") LocalDateTime now); + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + + @Update(""" + UPDATE iam_notification_task + SET status = CASE WHEN attempt_count >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRYING' END, + last_error = #{error}, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'DISPATCHING' + """) + int failDispatch(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("error") String error, + @Param("now") LocalDateTime now); } diff --git a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java index 5f5e76809..24431b598 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java +++ b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java @@ -49,21 +49,43 @@ public class NotificationRepository { public void insertRecipient(NotificationRecipient value) { recipients.insert(value); } public List recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper().eq(NotificationRecipient::getTaskId,taskId)); } public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); } - public List dueTasks() { return tasks.selectList(new LambdaQueryWrapper() - .eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,java.time.LocalDateTime.now()).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); } - public boolean claimScheduled(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper() - .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"SCHEDULED") - .set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; } - public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now) { - int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now); - return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now); + public List dueTasks(java.time.LocalDateTime now) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,now).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); } + public boolean claimScheduled(Long taskId,int maxAttempts,java.time.LocalDateTime now) { + return tasks.claimScheduled(taskId,maxAttempts,now)==1; } - public List retryingTasks() { return tasks.selectList(new LambdaQueryWrapper() - .eq(NotificationTask::getStatus,"RETRYING").orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); } - public boolean claimRetrying(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper() - .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"RETRYING") - .set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; } + public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now,int maxAttempts) { + int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now); + dead += tasks.deadTimedOutDispatchingAtAttemptLimit(staleBefore,now,maxAttempts); + return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now,maxAttempts); + } + public List retryingTasks(java.time.LocalDateTime dueBefore,int maxAttempts) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"RETRYING").lt(NotificationTask::getAttemptCount,maxAttempts) + .le(NotificationTask::getUpdatedAt,dueBefore).orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); } + public boolean claimRetrying(Long taskId,int maxAttempts,java.time.LocalDateTime dueBefore,java.time.LocalDateTime now) { + return tasks.claimRetrying(taskId,maxAttempts,dueBefore,now)==1; + } + public boolean completeDispatch(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"DISPATCHING") + .set(NotificationTask::getStatus,"SENT").set(NotificationTask::getSentAt,now) + .set(NotificationTask::getLastError,null).set(NotificationTask::getUpdatedAt,now))==1; } + public boolean failDispatch(Long taskId,int maxAttempts,String error,java.time.LocalDateTime now) { + return tasks.failDispatch(taskId,maxAttempts,error,now)==1; + } + public boolean requeueForManualRetry(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).in(NotificationTask::getStatus,"RETRYING","DEAD") + .set(NotificationTask::getStatus,"RETRYING").set(NotificationTask::getAttemptCount,0) + .set(NotificationTask::getLastError,null).set(NotificationTask::getSentAt,null) + .set(NotificationTask::getUpdatedAt,now))==1; } public void insertInbox(NotificationInbox value) { inbox.insert(value); } + public boolean markRecipientDelivered(Long recipientId,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getId,recipientId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"DELIVERED").set(NotificationRecipient::getFailureReason,null) + .set(NotificationRecipient::getDeliveredAt,now).set(NotificationRecipient::getUpdatedAt,now))==1; } + public int markUndeliveredRecipientsFailed(Long taskId,String error,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getTaskId,taskId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"FAILED").set(NotificationRecipient::getFailureReason,error) + .set(NotificationRecipient::getUpdatedAt,now)); } public PageResult pageInbox(long tenantId,long membershipId,String app,long page,long size,String status,String category) { return PageResult.from(inbox.selectPage(new Page<>(page,size), new LambdaQueryWrapper() .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId) diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java new file mode 100644 index 000000000..3d8bc076d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java @@ -0,0 +1,74 @@ +package com.baiyue.iam.notification.service; + +import com.baiyue.iam.notification.entity.NotificationInbox; +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class NotificationDispatchTransactionService { + static final int MAX_DISPATCH_ATTEMPTS = 3; + static final long RETRY_DELAY_SECONDS = 30; + + @Resource private NotificationRepository repository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimScheduled(Long taskId) { + return repository.claimScheduled(taskId, MAX_DISPATCH_ATTEMPTS, LocalDateTime.now()); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimRetrying(Long taskId) { + LocalDateTime now = LocalDateTime.now(); + return repository.claimRetrying(taskId, MAX_DISPATCH_ATTEMPTS, + now.minusSeconds(RETRY_DELAY_SECONDS), now); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deliverClaimed(Long taskId) { + NotificationTask task = repository.task(taskId); + if (task == null || !"DISPATCHING".equals(task.getStatus())) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + LocalDateTime now = LocalDateTime.now(); + for (NotificationRecipient recipient : repository.recipients(taskId)) { + if ("DELIVERED".equals(recipient.getStatus())) continue; + NotificationInbox item = new NotificationInbox(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setRecipientId(recipient.getId()); + item.setTenantId(recipient.getTenantId()); item.setMembershipId(recipient.getMembershipId()); + item.setAppCode(recipient.getAppCode()); item.setCategory(task.getCategory()); item.setTitle(task.getTitle()); + item.setBody(task.getBody()); item.setSourceType(task.getSourceEventType()); item.setSourceId(task.getSourceEventId()); + item.setStatus("UNREAD"); item.setSentAt(now); item.setCreatedAt(now); item.setUpdatedAt(now); + repository.insertInbox(item); + if (!repository.markRecipientDelivered(recipient.getId(), now)) { + throw new IllegalStateException("NOTIFICATION_RECIPIENT_DELIVERY_STATE_LOST"); + } + } + if (!repository.completeDispatch(taskId, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordFailure(Long taskId, Throwable failure) { + LocalDateTime now = LocalDateTime.now(); + String error = boundedError(failure); + repository.markUndeliveredRecipientsFailed(taskId, error, now); + if (!repository.failDispatch(taskId, MAX_DISPATCH_ATTEMPTS, error, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + private String boundedError(Throwable failure) { + String message = failure.getMessage(); + String error = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + return error.length() <= 1000 ? error : error.substring(0, 1000); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java index 9b1e8bc36..4a7285aac 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java @@ -25,6 +25,7 @@ import java.util.Set; @Service public class NotificationService { private static final long EVENT_PROCESSING_TIMEOUT_MINUTES = 5; + private static final int INBOX_SUMMARY_MAX_CODE_POINTS = 120; public enum EventClaimStatus { ACQUIRED, PROCESSED, PROCESSING } public record EventClaim(NotificationEventInbox event, EventClaimStatus status) { } @@ -33,6 +34,7 @@ public class NotificationService { @Resource private NotificationTemplateRenderer renderer; @Resource private NotificationRecipientResolver resolver; @Resource private ObjectMapper objectMapper; + @Resource private NotificationDispatchTransactionService dispatchTransactions; @Transactional(readOnly = true) public PageResult inbox(long tenantId, long membershipId, String appCode, @@ -183,35 +185,44 @@ public class NotificationService { public int recoverTimedOutDispatching(long timeoutSeconds) { if (timeoutSeconds < 1) throw new IllegalArgumentException("Dispatch timeout must be positive"); LocalDateTime now = eventNow(); - return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now); + return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now, + NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS); } @Transactional(readOnly = true) public List dueScheduledTaskIds() { - return repository.dueTasks().stream().map(NotificationTask::getId).toList(); + return repository.dueTasks(LocalDateTime.now()).stream().map(NotificationTask::getId).toList(); } @Transactional(readOnly = true) public List retryingTaskIds() { - return repository.retryingTasks().stream().map(NotificationTask::getId).toList(); + LocalDateTime dueBefore = LocalDateTime.now() + .minusSeconds(NotificationDispatchTransactionService.RETRY_DELAY_SECONDS); + return repository.retryingTasks(dueBefore, NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS) + .stream().map(NotificationTask::getId).toList(); } - @Transactional public boolean dispatchScheduled(Long taskId) { - if (!repository.claimScheduled(taskId)) return false; - NotificationTask task = requiredTask(taskId); - task.setStatus("DISPATCHING"); - deliver(task, LocalDateTime.now()); - return true; + return dispatch(taskId, dispatchTransactions.claimScheduled(taskId)); } - @Transactional public boolean dispatchRetrying(Long taskId) { - if (!repository.claimRetrying(taskId)) return false; - NotificationTask task = requiredTask(taskId); - task.setStatus("DISPATCHING"); - deliver(task, LocalDateTime.now()); - return true; + return dispatch(taskId, dispatchTransactions.claimRetrying(taskId)); + } + + private boolean dispatch(Long taskId, boolean claimed) { + if (!claimed) return false; + try { + dispatchTransactions.deliverClaimed(taskId); + return true; + } catch (RuntimeException failure) { + try { + dispatchTransactions.recordFailure(taskId, failure); + } catch (RuntimeException persistenceFailure) { + failure.addSuppressed(persistenceFailure); + } + throw failure; + } } @Transactional(readOnly = true) @@ -280,6 +291,19 @@ public class NotificationService { source.page(), source.size(), source.total(), source.pages()); } + @Transactional(readOnly = true) + public TaskDetail taskDetail(Long id) { + NotificationTask task = requiredTask(id); + List recipients = repository.recipients(id).stream() + .map(value -> new TaskRecipientItem(value.getId(), value.getDisplayName(), value.getAppCode(), + value.getStatus(), value.getFailureReason(), value.getDeliveredAt())) + .toList(); + return new TaskDetail(task.getId(), task.getTaskNo(), task.getCategory(), task.getTitle(), task.getStatus(), + task.getRecipientCount(), task.getAttemptCount() == null ? 0 : task.getAttemptCount(), + task.getScheduledAt(), task.getSentAt(), + task.getLastError(), recipients); + } + @Transactional @BusinessAudit(event = "NOTIFICATION_TASK_CANCEL", resourceType = "NOTIFICATION_TASK", resourceId = "#id", description = "取消消息任务", captureAfter = false) @@ -298,8 +322,9 @@ public class NotificationService { NotificationTask task = requiredTask(id); if (!Set.of("RETRYING", "DEAD").contains(task.getStatus())) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); - task.setStatus("RETRYING"); task.setAttemptCount(task.getAttemptCount() + 1); repository.saveTask(task); - deliver(task, LocalDateTime.now()); + LocalDateTime now = LocalDateTime.now(); + if (!repository.requeueForManualRetry(id, now)) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + task.setStatus("RETRYING"); task.setAttemptCount(0); task.setLastError(null); task.setUpdatedAt(now); return taskItem(task, false); } @@ -319,12 +344,21 @@ public class NotificationService { return value; } private NotificationSummary summary(NotificationInbox value) { - return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), value.getStatus(), - value.getSentAt(), value.getReadAt()); + return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getStatus(), value.getSentAt(), value.getReadAt()); + } + + private String boundedSummary(String body) { + if (body == null || body.isBlank()) return ""; + String compact = body.strip().replaceAll("\\s+", " "); + int count = compact.codePointCount(0, compact.length()); + if (count <= INBOX_SUMMARY_MAX_CODE_POINTS) return compact; + return compact.substring(0, compact.offsetByCodePoints(0, INBOX_SUMMARY_MAX_CODE_POINTS)); } private NotificationDetail detail(NotificationInbox value) { - return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), value.getBody(), - value.getStatus(), value.getSourceType(), value.getSourceId(), value.getSentAt(), value.getReadAt()); + return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getBody(), value.getStatus(), value.getSourceType(), + value.getSourceId(), value.getSentAt(), value.getReadAt()); } private TemplateResponse template(NotificationTemplate value) { return new TemplateResponse(value.getId(), value.getTemplateCode(), value.getTemplateName(), diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java index dce0133f6..403039863 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java @@ -31,7 +31,7 @@ public class CustomerListExportProviderService { private static final int MAX_BATCH_SIZE = 500; private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512; private static final Set STATUSES = Set.of( - "PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED"); + "PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED", "ARCHIVED"); private static final Set PARAMETER_KEYS = Set.of("keyword", "status"); private static final List COLUMNS = List.of( column("id", "客户ID", ExportColumnType.STRING, 20), @@ -205,6 +205,7 @@ public class CustomerListExportProviderService { case "ACTIVE" -> "启用"; case "REJECTED" -> "已驳回"; case "DISABLED" -> "已停用"; + case "ARCHIVED" -> "已归档"; default -> status; }; } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index aa3bce84a..0a10d16e3 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -93,7 +93,7 @@ public class TenantService { captureBefore = false, captureAfter = false) public TenantResponse archiveMerchant(long tenantId) { IamTenant tenant = requireMerchantForUpdate(tenantId); - if ("DISABLED".equals(tenant.getStatus())) return response(tenant); + if ("ARCHIVED".equals(tenant.getStatus())) return response(tenant); if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) { throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档"); } @@ -106,7 +106,7 @@ public class TenantService { } catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); } - tenant.setStatus("DISABLED"); + tenant.setStatus("ARCHIVED"); repository.saveTenant(tenant); return response(tenant); } @@ -350,6 +350,7 @@ public class TenantService { description = "平台编辑企业客户档案") public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) { IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); String contactPhone = normalizeContactPhone(request.contactPhone()); String existingContactPhone = normalizeContactPhone(tenant.getContactPhone()); if (!contactPhone.equals(existingContactPhone)) { @@ -429,6 +430,7 @@ public class TenantService { resourceId = "#tenantId", description = "编辑企业资料") public TenantResponse update(long tenantId, UpdateProfileRequest request) { IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); @@ -443,6 +445,12 @@ public class TenantService { return tenant.getId() != null && tenant.getId().longValue() == id; } + private void ensureNotArchived(IamTenant tenant) { + if ("ARCHIVED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Archived customer is read-only / 已归档客户仅可查看"); + } + } + private String normalizeContactPhone(String contactPhone) { return contactPhone == null ? "" : contactPhone.trim(); } diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 2f0d3b823..65b0d2654 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', - status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用', + status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 00fccfde0..0ae6774de 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -45,7 +45,7 @@ paths: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} - - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} + - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}} responses: '200': description: 企业客户分页查询成功 @@ -985,7 +985,7 @@ components: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} - status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]} + status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} unifiedSocialCreditCode: {type: string} diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index ebb2711c3..99f5f992f 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -50,7 +50,7 @@ paths: tags: [Platform notification administration] summary: Create an IN_APP notification task requestBody: {$ref: '#/components/requestBodies/CreateNotification'} - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} /platform/v1/notification-templates: get: tags: [Platform notification administration] @@ -95,18 +95,26 @@ paths: - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} + /platform/v1/notification-tasks/{id}: + get: + tags: [Platform notification administration] + summary: Get notification task delivery details + description: Uses the task list permission and exposes bounded operational evidence without message body content. + parameters: [{$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/TaskDetail'}} /platform/v1/notification-tasks/{id}/cancel: put: tags: [Platform notification administration] summary: Cancel a CREATED or SCHEDULED task parameters: [{$ref: '#/components/parameters/IdPath'}] - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} /platform/v1/notification-tasks/{id}/retry: put: tags: [Platform notification administration] - summary: Retry a RETRYING or DEAD task + summary: Requeue a RETRYING or DEAD task + description: Resets the current delivery-attempt counter and returns the task in RETRYING; scheduler delivery starts after the retry delay. parameters: [{$ref: '#/components/parameters/IdPath'}] - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} components: parameters: AppPath: {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} @@ -126,6 +134,7 @@ components: TemplatePage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}} TemplateDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateDetailResponse'}}}} TaskPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}} + TaskItem: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskItemResponse'}}}} TaskDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskDetailResponse'}}}} schemas: CreateNotificationRequest: @@ -159,10 +168,12 @@ components: type: object required: [targetStatus, version] properties: {targetStatus: {type: string, enum: [ACTIVE, DISABLED], description: ACTIVE is accepted only from DRAFT; DISABLED only from ACTIVE.}, version: {type: integer, minimum: 1}} - InboxItem: {type: object, required: [id, category, title, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} + InboxItem: {type: object, required: [id, category, title, summary, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, summary: {type: string, maxLength: 120, description: Whitespace-compacted body preview limited to 120 Unicode code points; never contains the full body unless the body itself fits.}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, required: [body], properties: {body: {type: string}, sourceType: {type: string, nullable: true}, sourceId: {type: string, nullable: true}}}]} TemplateItem: {type: object, required: [id, templateCode, templateName, category, channel, titleTemplate, bodyTemplate, variableWhitelist, status, version], properties: {id: {type: integer, format: int64}, templateCode: {type: string}, templateName: {type: string}, category: {type: string}, channel: {type: string, enum: [IN_APP]}, titleTemplate: {type: string}, bodyTemplate: {type: string}, variableWhitelist: {type: array, items: {type: string}}, status: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}, version: {type: integer}}} TaskItem: {type: object, required: [id, taskNo, category, title, status, recipientCount, duplicated], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, duplicated: {type: boolean}}} + TaskRecipientItem: {type: object, required: [id, displayName, appCode, status], properties: {id: {type: integer, format: int64}, displayName: {type: string}, appCode: {type: string}, status: {type: string}, failureReason: {type: string, nullable: true}, deliveredAt: {type: string, format: date-time, nullable: true}}} + TaskDetail: {type: object, required: [id, taskNo, category, title, status, recipientCount, attemptCount, recipients], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, attemptCount: {type: integer, minimum: 0, maximum: 3, description: Number of started delivery attempts in the current automatic retry cycle.}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, lastError: {type: string, nullable: true}, recipients: {type: array, items: {$ref: '#/components/schemas/TaskRecipientItem'}}}} InboxPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} TemplatePageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} TaskPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} @@ -173,4 +184,5 @@ components: TemplatePageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplatePageData'}}} TemplateDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplateItem'}}} TaskPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskPageData'}}} - TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}} + TaskItemResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}} + TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskDetail'}}} diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 461ddbbc1..9066fd423 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest { assertNotNull(annotation(method, 0, Min.class)); assertNotNull(annotation(method, 1, Min.class)); assertEquals(200, annotation(method, 1, Max.class).value()); - assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", + assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED", annotation(method, 3, Pattern.class).regexp()); Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value()) diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java new file mode 100644 index 000000000..788fa540b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class NotificationDispatchTransactionServiceTest { + private final NotificationRepository repository = mock(NotificationRepository.class); + private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception { + for (var method : List.of( + NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class), + NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class), + NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class), + NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) { + assertThat(method.getAnnotation(Transactional.class)).isNotNull() + .extracting(Transactional::propagation) + .isEqualTo(Propagation.REQUIRES_NEW); + } + } + + @Test void claimStartsOneOfThreeAllowedAttempts() { + when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true); + + assertThat(service.claimScheduled(41L)).isTrue(); + + verify(repository).claimScheduled(eq(41L), eq(3), any()); + } + + @Test void retryClaimEnforcesDelayAndSameAttemptLimit() { + when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true); + + assertThat(service.claimRetrying(41L)).isTrue(); + + ArgumentCaptor dueBefore = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture()); + assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30)); + } + + @Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("FAILED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L) + && item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus()))); + var ordered = inOrder(repository); + ordered.verify(repository).markRecipientDelivered(eq(51L), any()); + ordered.verify(repository).completeDispatch(eq(41L), any()); + } + + @Test void alreadyDeliveredRecipientIsNotInsertedAgain() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("DELIVERED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository, never()).insertInbox(any()); + verify(repository, never()).markRecipientDelivered(anyLong(), any()); + } + + @Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() { + when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true); + + service.recordFailure(41L, new IllegalStateException("x".repeat(1200))); + + ArgumentCaptor error = ArgumentCaptor.forClass(String.class); + verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any()); + assertThat(error.getValue()).hasSize(1000); + verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any()); + } + + @Test void rejectsDeliveryWithoutCurrentClaim() { + when(repository.task(41L)).thenReturn(task("RETRYING")); + + assertThatThrownBy(() -> service.deliverClaimed(41L)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST"); + + verify(repository, never()).insertInbox(any()); + } + + private NotificationTask task(String status) { + NotificationTask task = new NotificationTask(); + task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化"); + task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed"); + return task; + } + + private NotificationRecipient recipient(String status) { + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L); + recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status); + recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now()); + return recipient; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java index 776d2bdcc..ea97693f2 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.iam.notification; import com.baiyue.iam.notification.controller.NotificationInboxController; +import com.baiyue.iam.notification.controller.PlatformNotificationController; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -18,6 +19,15 @@ class NotificationInboxPermissionContractTest { assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')"); } + @Test + void taskDetailUsesTheTaskListPermission() { + var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("taskDetail")) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:notification:task:list')"); + } + private static void assertPermission(String methodName, String expected) { var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index afab24b08..af6a9a172 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -28,6 +28,7 @@ class NotificationOpenApiContractTest { "/{app}/v1/notifications/read-all", "/platform/v1/notifications", "/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}", "/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks", + "/platform/v1/notification-tasks/{id}", "/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry"); assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create"); @@ -37,6 +38,7 @@ class NotificationOpenApiContractTest { assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put"); for (String path : List.of("/platform/v1/notification-templates/{id}", "/platform/v1/notification-templates/{id}/status", + "/platform/v1/notification-tasks/{id}", "/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry")) { Map operation = map(map(paths.get(path)).values().iterator().next()); @@ -50,6 +52,7 @@ class NotificationOpenApiContractTest { "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse", "InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse", "TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse"); + assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail"); for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest", "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem", "TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) { @@ -59,6 +62,13 @@ class NotificationOpenApiContractTest { .contains("DISABLED cannot return to ACTIVE") .contains("does not support deleting or clearing read messages") .contains("name: taskNo").contains("name: templateCode").contains("name: title"); + Map inboxItem = map(schemas.get("InboxItem")); + assertThat(list(inboxItem.get("required"))).contains("summary"); + assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120); + Map taskDetail = map(schemas.get("TaskDetail")); + assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) + .containsEntry("minimum", 0).containsEntry("maximum", 3); + assertThat(document).contains("scheduler delivery starts after the retry delay"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java index d199ba723..c629d44be 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java @@ -13,15 +13,34 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class NotificationSchedulerRecoveryTest { + @Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception { + Update scheduled = NotificationTaskMapper.class + .getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + + Update retrying = NotificationTaskMapper.class + .getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class, + java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + } + @Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception { Update retry = NotificationTaskMapper.class - .getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class) + .getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) .getAnnotation(Update.class); String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase(); assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}", "TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX") - .contains("SET TASK.STATUS = 'RETRYING'"); + .contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}") + .doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1"); Update dead = NotificationTaskMapper.class .getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class) @@ -30,6 +49,21 @@ class NotificationSchedulerRecoveryTest { assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX", "DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE"); + + Update attemptLimit = NotificationTaskMapper.class + .getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) + .getAnnotation(Update.class); + String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'", + "TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT"); + + Update failed = NotificationTaskMapper.class + .getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END", + "LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'"); } @Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() { diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java index ac6b51b98..b000b0ae0 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java @@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.notification.dto.NotificationDtos.*; import com.baiyue.iam.notification.entity.NotificationRecipient; import com.baiyue.iam.notification.entity.NotificationEventInbox; +import com.baiyue.iam.notification.entity.NotificationInbox; import com.baiyue.iam.notification.entity.NotificationTask; import com.baiyue.iam.notification.entity.NotificationTemplate; import com.baiyue.iam.notification.repository.NotificationRepository; import com.baiyue.iam.notification.service.NotificationRecipientResolver; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; import com.baiyue.iam.notification.service.NotificationService; import com.baiyue.iam.notification.service.NotificationTemplateRenderer; import com.fasterxml.jackson.databind.ObjectMapper; @@ -27,6 +29,8 @@ import static org.mockito.Mockito.*; class NotificationServiceTest { private final NotificationRepository repository = mock(NotificationRepository.class); private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class); + private final NotificationDispatchTransactionService dispatchTransactions = + mock(NotificationDispatchTransactionService.class); private final NotificationService service = new NotificationService(); @BeforeEach void setup() { @@ -34,6 +38,7 @@ class NotificationServiceTest { ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer()); ReflectionTestUtils.setField(service, "resolver", resolver); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions); } @Test void createsTaskAndImmutableRecipientSnapshot() { @@ -63,6 +68,39 @@ class NotificationServiceTest { assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED"); } + @Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() { + NotificationInbox item = new NotificationInbox(); + item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD"); + item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " "); + when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1)); + + NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0); + + assertThat(summary.summary()).startsWith("第一行 "); + assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120); + assertThat(summary.summary()).isNotEqualTo(item.getBody()); + } + + @Test void returnsTaskAndRecipientFailureEvidence() { + NotificationTask task = task(41L, "DEAD"); + task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3); + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT"); + recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + + TaskDetail detail = service.taskDetail(41L); + + assertThat(detail.attemptCount()).isEqualTo(3); + assertThat(detail.lastError()).isEqualTo("gateway timeout"); + assertThat(detail.recipients()).singleElement().satisfies(value -> { + assertThat(value.status()).isEqualTo("FAILED"); + assertThat(value.failureReason()).isEqualTo("gateway timeout"); + }); + } + @Test void returnsOriginalTaskForRepeatedIdempotencyKey() { NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5"); existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1); @@ -151,31 +189,28 @@ class NotificationServiceTest { } @Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() { - when(repository.recoverTimedOutDispatching(any(), any())).thenReturn(2); + when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2); assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2); ArgumentCaptor staleBefore = ArgumentCaptor.forClass(LocalDateTime.class); ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); - verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture()); + verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3)); assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300)); } @Test void onlyOneInstanceCanClaimRecoveredTask() { - when(repository.claimRetrying(41L)).thenReturn(true, false); - NotificationTask task = task(41L, "RETRYING"); - when(repository.task(41L)).thenReturn(task); - when(repository.recipients(41L)).thenReturn(List.of()); + when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false); assertThat(service.dispatchRetrying(41L)).isTrue(); assertThat(service.dispatchRetrying(41L)).isFalse(); - verify(repository, times(2)).claimRetrying(41L); - verify(repository, times(1)).saveTask(task); + verify(dispatchTransactions, times(2)).claimRetrying(41L); + verify(dispatchTransactions, times(1)).deliverClaimed(41L); } @Test void sentTaskCannotBeClaimedForRecoveryDelivery() { - when(repository.claimRetrying(42L)).thenReturn(false); + when(dispatchTransactions.claimRetrying(42L)).thenReturn(false); assertThat(service.dispatchRetrying(42L)).isFalse(); @@ -183,6 +218,30 @@ class NotificationServiceTest { verify(repository, never()).recipients(42L); } + @Test void persistsScheduledDeliveryFailureAfterCommittedClaim() { + IllegalStateException failure = new IllegalStateException("inbox unavailable"); + when(dispatchTransactions.claimScheduled(43L)).thenReturn(true); + doThrow(failure).when(dispatchTransactions).deliverClaimed(43L); + + assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure); + + verify(dispatchTransactions).recordFailure(43L, failure); + } + + @Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() { + NotificationTask task = task(44L, "DEAD"); + task.setAttemptCount(3); task.setLastError("timeout"); + when(repository.task(44L)).thenReturn(task); + when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true); + + TaskListItem result = service.retry(44L); + + assertThat(result.status()).isEqualTo("RETRYING"); + assertThat(task.getAttemptCount()).isZero(); + assertThat(task.getLastError()).isNull(); + verify(dispatchTransactions, never()).deliverClaimed(anyLong()); + } + private NotificationTask task(long id, String status) { NotificationTask value = new NotificationTask(); value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 37eacb10d..18d4a36c0 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -179,11 +179,11 @@ class TenantServiceTest { } @Test - void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() { - IamTenant tenant = merchant(10001L, "DISABLED"); + void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() { + IamTenant tenant = merchant(10001L, "ARCHIVED"); when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); - assertEquals("DISABLED", service.archiveMerchant(10001L).status()); + assertEquals("ARCHIVED", service.archiveMerchant(10001L).status()); verify(authClient, never()).archiveTenant(any(), any()); verify(repository, never()).saveTenant(any()); } @@ -222,6 +222,34 @@ class TenantServiceTest { assertEquals(ResultCode.CONFLICT, exception.getResultCode()); } + @Test + void archivedCustomerCannotBeReactivated() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED")); + + BizException exception = assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + + @Test + void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() { + IamTenant archived = merchant(10001L, "ARCHIVED"); + when(repository.findTenant(10001L)).thenReturn(archived); + + BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, + "基础版", null, null, 30, null, null, null, "CNY", null, null))); + BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, + new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", + "13800138000", "alice@example.com"))); + + assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode()); + assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + @Test void merchantProfileCannotReadOrUpdatePlatformTenant() { IamTenant platform = new IamTenant(); @@ -300,7 +328,7 @@ class TenantServiceTest { var archived = service.archiveMerchant(10001L); - assertEquals("DISABLED", archived.status()); + assertEquals("ARCHIVED", archived.status()); verify(authClient).archiveTenant("internal-token", 10001L); verify(repository).saveTenant(tenant); } diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index f0649f719..ae601f063 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -201,7 +201,7 @@ export async function run(context) { const pushKey = `${context.marker}-container-push`.slice(0, 128) const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey }) - const pushed = context.apiSuccess(MODULE, '推送预装并创建 PDA 任务', pushResponse, '', OPTIONS) + const pushed = context.apiSuccess(MODULE, '推送预装计划', pushResponse, '', OPTIONS) context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse, pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1, `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) @@ -232,11 +232,34 @@ export async function run(context) { containerNo: `TST-${suffix}`.slice(0, 64), voyageNo: `V-${suffix}`.slice(0, 64), version: Number(containerDetail?.version ?? 0), reason: context.marker, }) - const transported = context.apiSuccess(MODULE, '绑定本轮真实箱号并补建 PDA 任务', transportResponse, '', OPTIONS) + const transported = context.apiSuccess(MODULE, '在放舱前绑定本轮真实箱号', transportResponse, '', OPTIONS) context.assert(MODULE, '绑定后柜次保留本轮箱号', transportResponse, transported?.containerNo === `TST-${suffix}`.slice(0, 64), `containerNo=${transported?.containerNo || 'missing'}`, OPTIONS) + const confirmedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse, + failure(confirmedTaskResponse) || !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id), + `HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) + + const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString() + const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, { + expectedVersion: Number(confirmedBooking?.oceanBooking?.version), + billOfLadingNo: `BL-${suffix}`.slice(0, 128), vesselName: `VESSEL-${suffix}`.slice(0, 128), + voyageNo: `V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt, + estimatedArrivalAt: arrivalAt, reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) }) + const releasedBooking = context.apiSuccess(MODULE, '订舱放舱并开放现场 PDA 任务', releaseResponse, '', OPTIONS) + context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse, + releasedBooking?.oceanBooking?.status === 'RELEASED' + && releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId), + `status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS) + + const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) + const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS) + const taskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS) @@ -263,7 +286,7 @@ export async function run(context) { const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64) const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), - version: Number(transported?.version), reason: `${context.marker}-replace-container-no`, + version: Number(releasedContainer?.version), reason: `${context.marker}-replace-container-no`, }) const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS) context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse, diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 802b1fec9..096eb9640 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -73,6 +73,14 @@ test('existing container number replacement keeps container plan and PDA identit assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/) }) +test('PDA task opens only after preload push, real container number and ocean release converge', () => { + assert.match(containerLoadingAcceptance, /无真实柜号时计划可排柜但 PDA 任务不开放/) + assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/) + assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/) + assert.match(containerLoadingAcceptance, /订舱放舱并开放现场 PDA 任务/) + assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/) +}) + test('customer public display uses tenantName without a legalName fallback', () => { for (const source of [truckList, containerList]) { assert.match(source, /label: customer\.tenantName/) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 89870ba7d..356cd1c9b 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -150,7 +150,8 @@ test('container loading creates and confirms booking before scoped candidates', assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport")) - assert.match(value, /绑定本轮真实箱号并补建 PDA 任务/) + assert.match(value, /在放舱前绑定本轮真实箱号/) + assert.match(value, /订舱放舱并开放现场 PDA 任务/) assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/) }) @@ -188,7 +189,9 @@ test('container loading enforces channel isolation and stable PDA identity acros '创建异渠道隔离订舱', '异渠道入库单拒绝装入当前柜次', '无真实柜号时计划可排柜但 PDA 任务不开放', - '绑定本轮真实箱号并补建 PDA 任务', + '在放舱前绑定本轮真实箱号', + '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', + '订舱放舱并开放现场 PDA 任务', '换柜号前读取 PDA 稳定库存快照', 'PDA 任务创建后替换真实柜号', 'PDA 任务创建后清空真实柜号失败', diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index 0368cd4b7..cc496ce32 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -64,7 +64,7 @@ export async function run(context) { 'all warehouse role permissions must use platform scope', OPTIONS) const unauthorized = await context.request('GET', - '/api/platform/v1/first-leg/pda-loading/tasks?containerNo=UNAUTHORIZED&warehouseId=1', undefined, '') + '/api/platform/v1/first-leg/pda-loading/tasks?cabinetNo=UNAUTHORIZED&warehouseId=1', undefined, '') context.addStep(MODULE, '未登录访问仓库PDA任务被拒绝', unauthorized, [401, 403].includes(unauthorized.http) || (unauthorized.http === 200 && ['A0401', 'A0403', '401', '403'].includes(String(unauthorized.body?.code))),