优化
This commit is contained in:
@@ -24,6 +24,7 @@ public final class AuditLogResponses {
|
||||
String username,
|
||||
String appCode,
|
||||
String clientType,
|
||||
String clientIp,
|
||||
String requestId,
|
||||
String traceId,
|
||||
String serviceName,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+13
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
|
||||
verify(sink).publish(event.capture());
|
||||
|
||||
@@ -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("******");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<BillLine> lines) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (BillLine line : lines) {
|
||||
if (!seen.add(line.sourceId() + ":" + line.sourceVersion())) throw bad("同一费用来源版本不能重复加入账单");
|
||||
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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));
|
||||
|
||||
@@ -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)"));
|
||||
|
||||
@@ -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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -45,12 +45,11 @@ public class PlatformLoadingPdaController {
|
||||
@GetMapping("/tasks")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<Task> 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")
|
||||
|
||||
@@ -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<BatchItemRequest> items) {
|
||||
this(warehouseId, null, null, containerNo, expectedVersion, inventorySnapshotToken, items);
|
||||
}
|
||||
}
|
||||
) {}
|
||||
|
||||
public record BatchItemRequest(
|
||||
@NotNull @Min(1) Long itemId,
|
||||
|
||||
+4
@@ -59,6 +59,10 @@ public class OceanBookingRepository {
|
||||
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
|
||||
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
|
||||
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
|
||||
public OceanBookingContainerAllocationEntity allocationByContainer(long containerBookingId) {
|
||||
return allocations.selectOne(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>()
|
||||
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId));
|
||||
}
|
||||
public List<OceanBookingStatusHistoryEntity> histories(long id) { return histories.selectList(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id).orderByAsc(OceanBookingStatusHistoryEntity::getCreatedAt)); }
|
||||
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
|
||||
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
|
||||
|
||||
+4
@@ -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<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
|
||||
@Resource private OceanBookingRepository repository;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
|
||||
public PageResult<OceanBookingEntity> 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);
|
||||
|
||||
@@ -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, "货柜不存在");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LoadingPlanItemEntity> 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)))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<LoadingPlanItemEntity> 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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '最后更新时间',
|
||||
|
||||
@@ -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: 查询客户固定四箱规尺寸配置
|
||||
|
||||
@@ -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:
|
||||
|
||||
+13
@@ -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:");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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("同一渠道的包税与不包税订单共用编码前缀");
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+7
-2
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
+2
@@ -31,6 +31,8 @@ public class PlatformNotificationController {
|
||||
public ApiResponse<TemplateResponse> 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<PageResult<TaskListItem>> 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> 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<TaskListItem> 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<TaskListItem> retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));}
|
||||
private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");}
|
||||
|
||||
@@ -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<TaskRecipientItem> recipients) { }
|
||||
}
|
||||
|
||||
@@ -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<NotificationTask> {
|
||||
@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);
|
||||
}
|
||||
|
||||
+35
-13
@@ -49,21 +49,43 @@ public class NotificationRepository {
|
||||
public void insertRecipient(NotificationRecipient value) { recipients.insert(value); }
|
||||
public List<NotificationRecipient> recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper<NotificationRecipient>().eq(NotificationRecipient::getTaskId,taskId)); }
|
||||
public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); }
|
||||
public List<NotificationTask> dueTasks() { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.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<NotificationTask>()
|
||||
.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<NotificationTask> dueTasks(java.time.LocalDateTime now) { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.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<NotificationTask> retryingTasks() { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getStatus,"RETRYING").orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); }
|
||||
public boolean claimRetrying(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
|
||||
.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<NotificationTask> retryingTasks(java.time.LocalDateTime dueBefore,int maxAttempts) { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.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<NotificationTask>()
|
||||
.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<NotificationTask>()
|
||||
.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<NotificationRecipient>()
|
||||
.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<NotificationRecipient>()
|
||||
.eq(NotificationRecipient::getTaskId,taskId).ne(NotificationRecipient::getStatus,"DELIVERED")
|
||||
.set(NotificationRecipient::getStatus,"FAILED").set(NotificationRecipient::getFailureReason,error)
|
||||
.set(NotificationRecipient::getUpdatedAt,now)); }
|
||||
public PageResult<NotificationInbox> 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<NotificationInbox>()
|
||||
.eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId)
|
||||
|
||||
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationSummary> 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<Long> dueScheduledTaskIds() {
|
||||
return repository.dueTasks().stream().map(NotificationTask::getId).toList();
|
||||
return repository.dueTasks(LocalDateTime.now()).stream().map(NotificationTask::getId).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Long> 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<TaskRecipientItem> 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(),
|
||||
|
||||
@@ -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<String> STATUSES = Set.of(
|
||||
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED");
|
||||
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED", "ARCHIVED");
|
||||
private static final Set<String> PARAMETER_KEYS = Set.of("keyword", "status");
|
||||
private static final List<ExportColumn> 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 '统一社会信用代码',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}}}
|
||||
|
||||
+1
-1
@@ -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<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
|
||||
|
||||
+126
@@ -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<LocalDateTime> dueBefore = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
ArgumentCaptor<LocalDateTime> 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<String> 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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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))
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> inboxItem = map(schemas.get("InboxItem"));
|
||||
assertThat(list(inboxItem.get("required"))).contains("summary");
|
||||
assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120);
|
||||
Map<String, Object> 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<String, Object> paths, String path, String method) {
|
||||
|
||||
+36
-2
@@ -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() {
|
||||
|
||||
@@ -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<LocalDateTime> staleBefore = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
ArgumentCaptor<LocalDateTime> 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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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 任务创建后清空真实柜号失败',
|
||||
|
||||
@@ -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))),
|
||||
|
||||
Reference in New Issue
Block a user