This commit is contained in:
2026-08-14 16:06:27 +08:00
parent 87eb1aa72f
commit 87e919f3c4
54 changed files with 984 additions and 175 deletions
@@ -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());
}
@@ -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);
}
}
@@ -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,
@@ -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); }
@@ -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:
@@ -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:");
}
}
@@ -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("同一渠道的包税与不包税订单共用编码前缀");
}
}
@@ -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() {
@@ -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));
@@ -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);
}
@@ -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)
@@ -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();
}
+1 -1
View File
@@ -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 '统一社会信用代码',
+2 -2
View File
@@ -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'}}}
@@ -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())
@@ -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;
}
}
@@ -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) {
@@ -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))),