优化
This commit is contained in:
-1
@@ -56,7 +56,6 @@ public final class ContainerCustomsDtos {
|
||||
String remark, List<ClearanceResultView> results) {}
|
||||
|
||||
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
|
||||
@Size(max = 64) String batchNo,
|
||||
@Size(max = 64) String customsEntryNo,
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
|
||||
+8
-6
@@ -181,6 +181,7 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */
|
||||
@Transactional
|
||||
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
|
||||
ActionRequest request) {
|
||||
@@ -358,11 +359,12 @@ public class ContainerCustomsService {
|
||||
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
|
||||
}
|
||||
|
||||
/** 首次创建分配独立申报批次号,重复命令返回已创建批次。 */
|
||||
@Transactional
|
||||
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
|
||||
String key, DeclarationBatchRequest request) {
|
||||
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
|
||||
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark());
|
||||
String requestHash = hash(action, request.expectedVersion(), request.remark());
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
@@ -373,8 +375,8 @@ public class ContainerCustomsService {
|
||||
}
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
long batchId = IdWorker.getId();
|
||||
String batchNo = trim(request.batchNo());
|
||||
if (batchNo == null) batchNo = documentNumbers.next("DECL");
|
||||
// 申报批次号由服务端分配,外部报关单号独立保存在 customsEntryNo。
|
||||
String batchNo = documentNumbers.next("DECL");
|
||||
String snapshot = declarationSnapshot(container.id());
|
||||
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
|
||||
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
|
||||
@@ -383,20 +385,20 @@ public class ContainerCustomsService {
|
||||
return declarationBatchView(mapper.declarationBatch(batchId));
|
||||
}
|
||||
|
||||
/** 提交只回填外部报关单号,内部批次号保持创建时的值。 */
|
||||
@Transactional
|
||||
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
|
||||
String key, DeclarationBatchRequest request) {
|
||||
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
|
||||
String entryNo = trim(request.customsEntryNo());
|
||||
if (entryNo == null) throw bad("正式申报必须回填报关单号");
|
||||
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark());
|
||||
String requestHash = hash(action, request.expectedVersion(), entryNo, request.remark());
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
|
||||
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
||||
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
|
||||
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
||||
String batchNo = trim(request.batchNo());
|
||||
if (batchNo == null) batchNo = batch.batchNo();
|
||||
String batchNo = batch.batchNo();
|
||||
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
|
||||
trim(request.remark())) != 1) throw concurrent();
|
||||
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
||||
|
||||
@@ -27,11 +27,11 @@ CALL phase3_add_allocation_picked_count();
|
||||
DROP PROCEDURE phase3_add_allocation_picked_count;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
||||
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
||||
) ENGINE=InnoDB COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
|
||||
@@ -257,7 +257,7 @@ components:
|
||||
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
||||
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
||||
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||
ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}}
|
||||
ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}}
|
||||
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
||||
|
||||
@@ -700,7 +700,8 @@ paths:
|
||||
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
|
||||
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
|
||||
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
|
||||
仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。
|
||||
仍由服务端重新校验国内仓、货物原客户归属、占用、同柜组、箱数和容量资格。
|
||||
平台柜次支持跨客户拼柜,关联订舱的客户快照不限制柜内成员客户。
|
||||
parameters:
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
@@ -835,6 +836,7 @@ paths:
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围;
|
||||
传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。
|
||||
关联订舱的客户快照不裁剪候选,未指定客户筛选时可推荐多个客户的货物。
|
||||
一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -848,7 +850,8 @@ paths:
|
||||
summary: 向预装计划新增入库单明细
|
||||
description: >-
|
||||
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
|
||||
校验客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单国内仓及渠道与柜次不同,
|
||||
校验货物原客户归属、可用箱数、容积和限重后原子新增/累加明细;平台允许跨客户拼柜,
|
||||
不以关联订舱客户快照限定成员客户。人工加入允许订单国内仓及渠道与柜次不同,
|
||||
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
|
||||
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
|
||||
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
|
||||
@@ -870,6 +873,7 @@ paths:
|
||||
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
|
||||
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
|
||||
占用、同柜组和容量门禁不变。
|
||||
支持将不同客户的入仓码加入同一柜次,明细及费用仍保留各自原客户归属。
|
||||
柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
|
||||
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
||||
parameters:
|
||||
@@ -886,6 +890,7 @@ paths:
|
||||
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
|
||||
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
|
||||
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
|
||||
不同客户可加入同一柜次,关联订舱客户快照不作为客户准入限制。
|
||||
已被其他有效柜次占用、国内仓不一致、
|
||||
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
|
||||
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
|
||||
@@ -2376,7 +2381,7 @@ components:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||
planNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||
@@ -2543,7 +2548,7 @@ components:
|
||||
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示}
|
||||
orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示}
|
||||
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID}
|
||||
loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示}
|
||||
loadingPlanNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', description: 装柜计划业务号,UI主展示}
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID}
|
||||
cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示}
|
||||
containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示}
|
||||
@@ -2676,7 +2681,7 @@ components:
|
||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
|
||||
properties:
|
||||
id: {type: string}
|
||||
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
|
||||
planNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号}
|
||||
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||
bookingId: {type: string}
|
||||
containerNo: {type: string, nullable: true}
|
||||
|
||||
@@ -299,7 +299,7 @@ components:
|
||||
properties:
|
||||
planId: {$ref: '#/components/schemas/LongId'}
|
||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||
preloadNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用}
|
||||
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||
@@ -356,7 +356,7 @@ components:
|
||||
properties:
|
||||
planId: {$ref: '#/components/schemas/LongId'}
|
||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||
loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
||||
loadingListNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
|
||||
@@ -313,7 +313,7 @@ components:
|
||||
oceanBooking:
|
||||
type: object
|
||||
properties:
|
||||
bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||
parties: {type: array}
|
||||
containerRequirements: {type: array}
|
||||
containers: {type: array, description: Existing containers allocated after port pickup linking}
|
||||
|
||||
@@ -235,9 +235,9 @@ components:
|
||||
required: [appointmentNo, carrierId, carrierName, attachmentCount]
|
||||
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
|
||||
properties:
|
||||
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
|
||||
appointmentNo: {type: string, pattern: '^TA[0-9]{12}$', example: TA202608180001, description: 派车单号;按北京时间创建日逐日编号}
|
||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
|
||||
oceanBookingNo: {type: string, nullable: true, pattern: '^OB[0-9]{12}$', example: OB202608180001}
|
||||
warehouseId: {type: integer, format: int64, nullable: true}
|
||||
warehouseName: {type: string, nullable: true}
|
||||
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
||||
|
||||
+6
-3
@@ -32,7 +32,7 @@ class InboundMeasurementReviewContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID")
|
||||
@DisplayName("测量审核不进入待客户确认,并声明幂等和字符串长ID")
|
||||
void shouldDocumentReviewContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
@@ -46,8 +46,11 @@ class InboundMeasurementReviewContractTest {
|
||||
.contains("name: inboundId")
|
||||
.contains("schema: {type: string, pattern:")
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
|
||||
.doesNotContain("待客户确认");
|
||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:");
|
||||
// 约束仅针对测量审核;客户费用确认属于另一业务,允许独立存在。
|
||||
int start = openapi.indexOf(" /api/platform/v1/inbounds/{inboundId}/measurements/approve:");
|
||||
int end = openapi.indexOf("\n /", start + 1);
|
||||
assertThat(openapi.substring(start, end < 0 ? openapi.length() : end)).doesNotContain("待客户确认");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+59
-5
@@ -28,6 +28,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ContainerCustomsServiceTest {
|
||||
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
|
||||
@Mock ContainerCustomsMapper mapper;
|
||||
@Mock CustomsFileClient files;
|
||||
@Mock ObjectMapper objectMapper;
|
||||
@@ -35,6 +37,58 @@ class ContainerCustomsServiceTest {
|
||||
@InjectMocks ContainerCustomsService service;
|
||||
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);}
|
||||
|
||||
/** 服务端分配批次号后,命令重放必须返回原号且不再消耗流水。 */
|
||||
@Test void declarationCreationReplayKeepsAllocatedNumber() throws Exception {
|
||||
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_9";
|
||||
var command = new AtomicReference<ContainerCustomsMapper.CommandRow>();
|
||||
var batch = new AtomicReference<ContainerCustomsMapper.DeclarationBatchRow>();
|
||||
when(mapper.claimCommand(anyLong(), eq(5L), eq(action), eq("batch-key"), anyString())).thenAnswer(call -> {
|
||||
if (command.get() != null) return 0;
|
||||
command.set(new ContainerCustomsMapper.CommandRow(1L, 5L, action, "batch-key", call.getArgument(4), "PROCESSING", null, null, null));
|
||||
return 1;
|
||||
});
|
||||
when(mapper.command(5L, action, "batch-key")).thenAnswer(call -> command.get());
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
|
||||
when(mapper.taxRefundCount(9L)).thenReturn(1);
|
||||
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.of(2026, 10, 20, 9, 0));
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
||||
when(mapper.insertDeclarationBatch(anyLong(), eq(2L), eq(9L), anyString(), eq(3), eq("[]"), isNull())).thenAnswer(call -> {
|
||||
batch.set(new ContainerCustomsMapper.DeclarationBatchRow(call.getArgument(0), 2L, 9L, call.getArgument(3),
|
||||
"READY", 3, null, null, null, null, null, null, 0, null, null));
|
||||
return 1;
|
||||
});
|
||||
when(mapper.declarationBatch(anyLong())).thenAnswer(call -> batch.get());
|
||||
when(mapper.insertHistory(anyLong(), eq(2L), eq("CREATE_CUSTOMS_DECLARATION_BATCH"), anyString(), anyString(), anyString(), eq(5L), eq("admin"))).thenReturn(1);
|
||||
when(mapper.completeCommand(eq(5L), eq(action), eq("batch-key"), anyLong())).thenAnswer(call -> {
|
||||
var old = command.get();
|
||||
command.set(new ContainerCustomsMapper.CommandRow(old.id(), 5L, action, "batch-key", old.requestHash(), "SUCCESS", call.getArgument(3), null, null));
|
||||
return 1;
|
||||
});
|
||||
var request = new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(3, null, null);
|
||||
var created = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request);
|
||||
var replay = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request);
|
||||
assertThat(created.batchNo()).isEqualTo("DECL202610200001");
|
||||
assertThat(replay.id()).isEqualTo(created.id());
|
||||
assertThat(replay.batchNo()).isEqualTo(created.batchNo());
|
||||
verify(documentNumbers, times(1)).next("DECL");
|
||||
}
|
||||
|
||||
/** 外部报关单号与内部批次号独立,正式提交不能修改内部号。 */
|
||||
@Test void declarationSubmissionPreservesBatchNumber() {
|
||||
var batch = new ContainerCustomsMapper.DeclarationBatchRow(41L, 2L, 9L, "DECL202610200001",
|
||||
"READY", 3, null, null, null, null, null, null, 0, null, null);
|
||||
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_DECLARATION_BATCH_41"), eq("submit-key"), anyString())).thenReturn(1);
|
||||
when(mapper.lockDeclarationBatch(41L)).thenReturn(batch);
|
||||
when(mapper.submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null)).thenReturn(1);
|
||||
when(mapper.completeCommand(5L, "SUBMIT_CUSTOMS_DECLARATION_BATCH_41", "submit-key", 41L)).thenReturn(1);
|
||||
when(mapper.declarationBatch(41L)).thenReturn(batch);
|
||||
var result = service.submitDeclarationBatch(41L, 5L, "admin", "submit-key",
|
||||
new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(0, "CUSTOMS-EXTERNAL-1", null));
|
||||
assertThat(result.batchNo()).isEqualTo(batch.batchNo());
|
||||
verify(mapper).submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null);
|
||||
verifyNoInteractions(documentNumbers);
|
||||
}
|
||||
|
||||
@Test void merchantOwnBuyOrderIsNotFound(){
|
||||
assertMerchantOrderNotFound(7L,31L);
|
||||
}
|
||||
@@ -211,7 +265,7 @@ class ContainerCustomsServiceTest {
|
||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||
var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64),
|
||||
"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);
|
||||
var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",
|
||||
var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",
|
||||
java.time.LocalDateTime.now(),1);
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||
@@ -220,7 +274,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.documents(2L,null)).thenReturn(List.of(source));
|
||||
when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I")));
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
||||
when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1);
|
||||
when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1);
|
||||
@@ -235,9 +289,9 @@ class ContainerCustomsServiceTest {
|
||||
var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null));
|
||||
|
||||
assertThat(result.brokerSubmission()).isNotNull();
|
||||
assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR-LP9-01");
|
||||
assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR202610200001");
|
||||
assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3);
|
||||
verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"));
|
||||
verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"));
|
||||
}
|
||||
|
||||
@Test void initialBrokerSendRejectsStaleAggregateVersion(){
|
||||
@@ -535,7 +589,7 @@ class ContainerCustomsServiceTest {
|
||||
}
|
||||
|
||||
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
|
||||
private void assertMerchantOrderNotFound(long tenantId,long orderId){
|
||||
assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId))
|
||||
|
||||
+10
-1
@@ -37,8 +37,17 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(ColoadGroupService.class)
|
||||
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class})
|
||||
class ColoadGroupServiceTest {
|
||||
@org.springframework.context.annotation.Configuration
|
||||
static class Numbers {
|
||||
/** 同柜组业务测试使用真实格式,序列由隔离测试替身提供。 */
|
||||
@org.springframework.context.annotation.Bean
|
||||
com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers() {
|
||||
return com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
}
|
||||
}
|
||||
|
||||
@MockBean
|
||||
private ColoadGroupMapper mapper;
|
||||
@MockBean private PendingReviewOrderClient pendingReviewOrders;
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ class ArrivalCalendarTest {
|
||||
void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() {
|
||||
var repository = mock(PortScheduleRepository.class);
|
||||
var service = new PortScheduleService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.firstleg.service.TestDocumentNumbers.documents());
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
var from = LocalDate.of(2026, 9, 28);
|
||||
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from));
|
||||
|
||||
+2
@@ -35,6 +35,8 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PortScheduleServiceTest {
|
||||
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
|
||||
@Test
|
||||
void platformPublicQueryAllowsZeroButRejectsNegativeCustomerScope() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.DocumentSequenceMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
|
||||
class DocumentSequenceRepositoryTest {
|
||||
private static class Fixture {
|
||||
final JdbcTemplate jdbc;
|
||||
final DataSourceTransactionManager transactions;
|
||||
final DocumentSequenceMapper mapper;
|
||||
final DocumentSequenceRepository repository;
|
||||
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
|
||||
Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||
jdbc.execute("CREATE TABLE fl_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
|
||||
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
|
||||
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||
repository=(DocumentSequenceRepository)proxy.getProxy();
|
||||
}
|
||||
}
|
||||
/** 日期和前缀各自独立,重复分配持续递增。 */
|
||||
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
|
||||
}
|
||||
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
|
||||
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
|
||||
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var work=new ArrayList<Callable<Long>>();
|
||||
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
|
||||
var values=new TreeSet<Long>();
|
||||
for(var future:pool.invokeAll(work))values.add(future.get());
|
||||
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
|
||||
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
throw new IllegalStateException("业务回滚");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
}
|
||||
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
|
||||
@Test void connectionResultIsNeverCached() throws Exception {
|
||||
var f=new Fixture();
|
||||
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
@Test
|
||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||
String sql = sql();
|
||||
int expectedTables = 228;
|
||||
int expectedTables = 229;
|
||||
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
|
||||
.matcher(sql).results().count();
|
||||
assertThat(actualTables).isEqualTo(expectedTables);
|
||||
|
||||
+2
@@ -24,6 +24,8 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SailingCalendarServiceTest {
|
||||
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||
|
||||
@Mock private SailingCalendarRepository repository;
|
||||
@InjectMocks private SailingCalendarService service;
|
||||
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
|
||||
|
||||
+37
-58
@@ -1,73 +1,52 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.time.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FirstlegDocumentNumberGeneratorTest {
|
||||
private static final Clock CLOCK = Clock.fixed(
|
||||
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
@Test
|
||||
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(JdbcTemplate.class, () -> jdbc);
|
||||
context.register(FirstlegDocumentNumberGenerator.class);
|
||||
context.refresh();
|
||||
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
|
||||
|
||||
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
|
||||
@Test void springCreatesGeneratorWithRepository() {
|
||||
try(var context=new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
|
||||
context.register(FirstlegDocumentNumberGenerator.class);context.refresh();
|
||||
assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatsAllOperationalDocumentPrefixes() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
|
||||
.thenReturn(1L, 1L, 1L, 1L, 1L);
|
||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||
|
||||
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
|
||||
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
|
||||
assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D0001");
|
||||
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
||||
eq("OB:20260818"));
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
||||
eq("CABINET"));
|
||||
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
|
||||
@Test void usesShanghaiDateAndIndependentSequenceKey() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
when(sequences.next("LP:20261020")).thenReturn(1L,2L,9999L);
|
||||
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThat(numbers.next("LP")).isEqualTo("LP202610200001");
|
||||
assertThat(numbers.next("LP")).isEqualTo("LP202610200002");
|
||||
assertThat(numbers.next("LP")).isEqualTo("LP202610209999");
|
||||
verify(sequences,times(3)).next("LP:20261020");
|
||||
}
|
||||
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
|
||||
@Test void rejectsUnknownTypeAndOverflow() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
|
||||
verifyNoInteractions(sequences);
|
||||
when(sequences.next("LP:20261020")).thenReturn(10000L);
|
||||
assertThatThrownBy(()->numbers.next("LP")).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cabinetSequenceDoesNotResetOrStopAtFourDigits() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(9_999L, 10_000L);
|
||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D9999");
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D10000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedTypeAndDailyOverflow() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||
assertThatThrownBy(() -> generator.next("LP")).isInstanceOf(BizException.class);
|
||||
/** 柜次和实物标签明确不改格式,柜次允许超过四位。 */
|
||||
@Test void preservesCabinetAndPalletRules() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
when(sequences.next("CABINET")).thenReturn(9999L,10000L);
|
||||
when(sequences.next("DP:20261020")).thenReturn(1L);
|
||||
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThat(numbers.nextCabinetNo()).isEqualTo("D9999");
|
||||
assertThat(numbers.nextCabinetNo()).isEqualTo("D10000");
|
||||
assertThat(numbers.next("DP")).isEqualTo("DP-261020-0001");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
||||
import java.time.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */
|
||||
public final class TestDocumentNumbers {
|
||||
/** 测试工具不实例化。 */
|
||||
private TestDocumentNumbers() {}
|
||||
/** 每个测试生成器独立计数,类型前缀之间互不占号。 */
|
||||
public static FirstlegDocumentNumberGenerator documents() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
var counters=new ConcurrentHashMap<String,AtomicLong>();
|
||||
lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet());
|
||||
return new FirstlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user