fix: restore customer visit uploads and guard fee publication
This commit is contained in:
+2
-1
@@ -87,11 +87,12 @@ public class PlatformFbaPricingController {
|
||||
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
|
||||
}
|
||||
|
||||
/** 按费用发送权限发布已确认费用,审核门禁由服务端校验且不推进履约状态。 */
|
||||
@PostMapping("/send")
|
||||
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
|
||||
+ " 'firstleg:inbound:send-fee')")
|
||||
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "发送FBA费用快照并进入待托盘状态")
|
||||
description = "发送FBA费用快照")
|
||||
public ApiResponse<FeeSnapshotResponse> send(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||
|
||||
@@ -331,6 +331,7 @@ public class FbaPricingRepository {
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
/** 保存试算及计费量,保留测量、审核与仓库作业的主状态。 */
|
||||
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
|
||||
java.math.BigDecimal selectedWeightKg,
|
||||
java.math.BigDecimal selectedVolumeCbm,
|
||||
@@ -341,10 +342,6 @@ public class FbaPricingRepository {
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
|
||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
||||
.contains(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
|
||||
}
|
||||
applyChargeableSnapshot(update, selectedWeightKg, selectedVolumeCbm, chargeMode, chargeable);
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
@@ -383,12 +380,14 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||
}
|
||||
|
||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
||||
/** 仅确认已通过测量审核的费用,并保留当前履约状态。 */
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus,
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED"))
|
||||
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
@@ -436,15 +435,14 @@ public class FbaPricingRepository {
|
||||
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
|
||||
}
|
||||
|
||||
/** 发送仅更新费用状态,并原子校验测量已审核及履约状态未变化。 */
|
||||
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.eq(InboundPricingEntity::getMeasureReviewStatus, "APPROVED")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "SENT");
|
||||
if ("FEE_CONFIRMING".equals(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET");
|
||||
}
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
|
||||
@@ -196,9 +196,8 @@ public class FbaPricingService {
|
||||
}
|
||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||
complete(token, fee.getId(), now);
|
||||
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
||||
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
||||
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
||||
// 费用是独立状态,提前试算不得覆盖待测量或待审核等履约进度。
|
||||
return response(inbound, fee, rate.getUnitPrice(), inbound.getProcessStatus());
|
||||
}
|
||||
|
||||
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
||||
@@ -290,9 +289,7 @@ public class FbaPricingService {
|
||||
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
|
||||
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
||||
complete(token, fee.getId(), now);
|
||||
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
||||
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
||||
return response(inbound, fee, request.unitPrice(), nextStatus);
|
||||
return response(inbound, fee, request.unitPrice(), inbound.getProcessStatus());
|
||||
}
|
||||
|
||||
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
|
||||
@@ -577,10 +574,14 @@ public class FbaPricingService {
|
||||
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
|
||||
int recalculatedCount) { }
|
||||
|
||||
/** 测量审核通过后才允许确认费用,避免提前冻结费用阻断审核后的重新计价。 */
|
||||
@Transactional
|
||||
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
|
||||
throw conflict("测量数据尚未审核通过,请先完成测量审核后再确认费用");
|
||||
}
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||
throw conflict("当前入库单状态不允许确认费用");
|
||||
}
|
||||
@@ -599,10 +600,14 @@ public class FbaPricingService {
|
||||
return response(tenantId, current.getId(), null);
|
||||
}
|
||||
|
||||
/** 仅发布已通过测量审核的费用,发送不改变测量与仓库作业进度。 */
|
||||
@Transactional
|
||||
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) {
|
||||
throw conflict("测量数据尚未审核通过,请先完成测量审核后再发送客户");
|
||||
}
|
||||
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
||||
throw conflict("当前入库单状态不允许发送费用");
|
||||
}
|
||||
@@ -611,8 +616,7 @@ public class FbaPricingService {
|
||||
String hash = hash("SEND", inboundId, current.getId().toString());
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
|
||||
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
||||
? inbound.getProcessStatus() : "WAIT_PALLET";
|
||||
String nextProcessStatus = inbound.getProcessStatus();
|
||||
if ("COMPLETED".equals(token.getStatus())) {
|
||||
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
|
||||
operatorId, null, LocalDateTime.now(clock));
|
||||
@@ -812,11 +816,6 @@ public class FbaPricingService {
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
|
||||
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
||||
.contains(processStatus);
|
||||
}
|
||||
|
||||
private static boolean feeRevisionStatusSupported(String processStatus) {
|
||||
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
|
||||
|
||||
+9
-10
@@ -169,24 +169,23 @@ public class LoadingCompletedOutboxService {
|
||||
traceId, eventKey, null, payloadNode, hash).requireValid();
|
||||
}
|
||||
|
||||
/** 国内同类照片可追加,交接按每类最新有效照片冻结一张对照图。 */
|
||||
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> loadingEvidence(Long containerBookingId) {
|
||||
if (containerBookingId == null || containerBookingId <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜计划缺少柜次,不能冻结装柜照片");
|
||||
}
|
||||
List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> result = cabinetEvidenceMapper
|
||||
.byContainer(containerBookingId).stream()
|
||||
Map<String, FirstLegLoadingPlanCompletedV3.LoadingEvidence> latestByType = new java.util.TreeMap<>();
|
||||
// Mapper 按类型、证据 ID 升序返回;同类后到的有效照片覆盖前一张,原始证据全部保留。
|
||||
cabinetEvidenceMapper.byContainer(containerBookingId).stream()
|
||||
.filter(value -> Integer.valueOf(1).equals(value.getActiveFlag()) && "ACTIVE".equals(value.getStatus()))
|
||||
.map(value -> new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
|
||||
value.getEvidenceType(), value.getFileId(), value.getFileName()))
|
||||
.sorted(Comparator.comparing(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType))
|
||||
.toList();
|
||||
Set<String> types = result.stream().map(FirstLegLoadingPlanCompletedV3.LoadingEvidence::evidenceType)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (result.size() != 5 || !types.equals(Set.of(
|
||||
.forEach(value -> latestByType.put(value.getEvidenceType(),
|
||||
new FirstLegLoadingPlanCompletedV3.LoadingEvidence(
|
||||
value.getEvidenceType(), value.getFileId(), value.getFileName())));
|
||||
if (!latestByType.keySet().equals(Set.of(
|
||||
"PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL"))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "国内装柜五类照片不完整,不能生成尾程交接事件");
|
||||
}
|
||||
return result;
|
||||
return List.copyOf(latestByType.values());
|
||||
}
|
||||
|
||||
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
|
||||
|
||||
@@ -171,13 +171,13 @@ paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
|
||||
post:
|
||||
summary: 确认最新费用快照
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。测量审核通过后才可确认费用;未通过时拒绝且不冻结费用。确认只更新费用状态,不改变履约主状态。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/send:
|
||||
post:
|
||||
summary: 发送已确认费用快照
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。
|
||||
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee,无需额外的测量查看权限。服务端校验测量审核通过后才可发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox,不改变履约主状态。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/latest:
|
||||
|
||||
+65
-7
@@ -37,7 +37,8 @@ class FbaPricingFeeTransitionTest {
|
||||
session.getConnection();
|
||||
sql = new JdbcTemplate(ds);
|
||||
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
||||
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
|
||||
"process_status VARCHAR(32),fee_status VARCHAR(32),measure_review_status VARCHAR(32) DEFAULT 'APPROVED'," +
|
||||
"chargeable_weight_kg DECIMAL(20,6),chargeable_volume_cbm DECIMAL(20,6),deleted BOOLEAN DEFAULT FALSE)");
|
||||
repository = new FbaPricingRepository();
|
||||
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
||||
}
|
||||
@@ -50,7 +51,8 @@ class FbaPricingFeeTransitionTest {
|
||||
|
||||
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
@ValueSource(strings = {"WAIT_PRICING",
|
||||
"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
||||
insertInbound(processStatus, "CALCULATED");
|
||||
|
||||
@@ -58,7 +60,7 @@ class FbaPricingFeeTransitionTest {
|
||||
assertState(processStatus, "CONFIRMED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
||||
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
|
||||
assertState(processStatus, "SENT");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
||||
}
|
||||
|
||||
@@ -71,12 +73,10 @@ class FbaPricingFeeTransitionTest {
|
||||
assertState("WAIT_PALLETIZE", feeStatus);
|
||||
}
|
||||
|
||||
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
|
||||
/** 其他客户和已删除入库单仍不可确认。 */
|
||||
@Test
|
||||
void preservesProcessCustomerAndDeletionGuards() {
|
||||
insertInbound("WAIT_MEASURE", "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
|
||||
insertInbound("WAIT_PALLETIZE", "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
@@ -91,6 +91,64 @@ class FbaPricingFeeTransitionTest {
|
||||
assertState("WAIT_SPLIT", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 提前试算保留测量进度,但审核前不能确认和冻结费用。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_SPLIT"})
|
||||
void calculationPreservesMeasurementWorkflow(String processStatus) {
|
||||
insertInbound(processStatus, "UNPRICED");
|
||||
boolean approved = "WAIT_SPLIT".equals(processStatus);
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88",
|
||||
approved ? "APPROVED" : "PENDING");
|
||||
assertThat(repository.markInboundCalculated(10001L, 88L, processStatus, "KG",
|
||||
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
|
||||
new java.math.BigDecimal("616.13"))).isEqualTo(1);
|
||||
assertState(processStatus, "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(approved ? 1 : 0);
|
||||
assertState(processStatus, approved ? "CONFIRMED" : "CALCULATED");
|
||||
}
|
||||
|
||||
/** 即使主状态已经升级,确认也须原子校验实际测量审核结果。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsConfirmationWhenMeasurementIsNotApproved(String reviewStatus) {
|
||||
insertInbound("WAIT_SPLIT", "CALCULATED");
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_SPLIT", "CALCULATED");
|
||||
}
|
||||
|
||||
/** 提前试算被拒绝确认后,审核通过仍可按正常费用链重新试算、确认和发送。 */
|
||||
@Test
|
||||
void pendingCalculationCanBeRepricedAndPublishedAfterMeasurementApproval() {
|
||||
insertInbound("MEASURE_PENDING", "UNPRICED");
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=88");
|
||||
assertThat(repository.markInboundCalculated(10001L, 88L, "MEASURE_PENDING", "KG",
|
||||
new java.math.BigDecimal("570.5"), new java.math.BigDecimal("3.6894"),
|
||||
new java.math.BigDecimal("616.13"))).isEqualTo(1);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("MEASURE_PENDING", "CALCULATED");
|
||||
|
||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_SPLIT',measure_review_status='APPROVED' WHERE id=88");
|
||||
assertThat(repository.markInboundCalculated(10001L, 88L, "WAIT_SPLIT", "KG",
|
||||
new java.math.BigDecimal("580"), new java.math.BigDecimal("4"),
|
||||
new java.math.BigDecimal("668"))).isEqualTo(1);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_SPLIT")).isEqualTo(1);
|
||||
assertState("WAIT_SPLIT", "SENT");
|
||||
assertThat(sql.queryForObject("SELECT chargeable_weight_kg FROM fl_inbound_order WHERE id=88",
|
||||
java.math.BigDecimal.class)).isEqualByComparingTo("668");
|
||||
}
|
||||
|
||||
/** 即使确认费用已完成,测量审核未通过也不能在持久化阶段发送。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsSendingUnapprovedMeasurement(String reviewStatus) {
|
||||
insertInbound("MEASURE_PENDING", "CONFIRMED");
|
||||
sql.update("UPDATE fl_inbound_order SET measure_review_status=? WHERE id=88", reviewStatus);
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "MEASURE_PENDING")).isZero();
|
||||
assertState("MEASURE_PENDING", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 准备单个客户的入库单,不调用共享环境。 */
|
||||
private void insertInbound(String processStatus, String feeStatus) {
|
||||
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
||||
|
||||
@@ -165,6 +165,7 @@ class FbaPricingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 试算生成费用但保留原履约进度。 */
|
||||
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
|
||||
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
|
||||
readyRepository();
|
||||
@@ -186,7 +187,7 @@ class FbaPricingServiceTest {
|
||||
assertThat(response.conversionFactor()).isEqualByComparingTo("167");
|
||||
assertThat(response.roundingScale()).isEqualTo(6);
|
||||
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PRICING");
|
||||
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
|
||||
verify(repository).insertFee(fee.capture());
|
||||
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
|
||||
@@ -630,7 +631,7 @@ class FbaPricingServiceTest {
|
||||
|
||||
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||
@CsvSource({"FEE_CONFIRMING,FEE_CONFIRMING", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
@@ -687,6 +688,39 @@ class FbaPricingServiceTest {
|
||||
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
|
||||
}
|
||||
|
||||
/** 测量未审核前拒绝单独确认,不留下阻断审核后初算的已确认费用。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsConfirmingBeforeMeasurementApproval(String reviewStatus) {
|
||||
InboundPricingEntity inbound = inbound("570.500", "3.6894");
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus(reviewStatus);
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-pending"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
|
||||
verify(repository, never()).confirmFee(anyLong(), anyLong(), anyLong(), any());
|
||||
verify(repository, never()).markInboundConfirmed(anyLong(), anyLong());
|
||||
verify(repository, never()).insertIdempotency(any());
|
||||
}
|
||||
|
||||
/** 测量未审核前拒绝发布,不能产生已发送快照或财务事件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"NOT_SUBMITTED", "PENDING", "REJECTED"})
|
||||
void rejectsSendingBeforeMeasurementApproval(String reviewStatus) {
|
||||
InboundPricingEntity inbound = inbound("570.500", "3.6894");
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus(reviewStatus);
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.send(TENANT, OPERATOR, INBOUND, "send-pending"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("先完成测量审核");
|
||||
verify(repository, never()).sendFee(anyLong(), anyLong(), anyLong(), any());
|
||||
verify(repository, never()).insertIdempotency(any());
|
||||
verify(financeOutbox, never()).publishInbound(anyString(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一幂等键用于不同试算时点会冲突")
|
||||
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
|
||||
|
||||
+55
@@ -101,6 +101,61 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(first.eventVersion()).isEqualTo(3);
|
||||
}
|
||||
|
||||
/** 同类追加照片不阻断预检和发布,每类冻结最新有效照片并保持重试快照稳定。 */
|
||||
@Test
|
||||
void freezesLatestActivePhotoPerTypeDuringPreflightAndPublication() throws Exception {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setPlannedPackageCount(1);
|
||||
value.setPickedPackageCount(1);
|
||||
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_3", 7);
|
||||
voided.setStatus("VOID");
|
||||
CabinetLoadingEvidenceEntity inactive = evidence("PHOTO_STEP_3", 8);
|
||||
inactive.setActiveFlag(null);
|
||||
// 与 Mapper 的 evidence_type、id 升序一致,后到的作废或失效照片不能替代有效照片。
|
||||
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
|
||||
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
|
||||
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), voided, inactive,
|
||||
evidence("PHOTO_STEP_4", 4), evidence("SEAL", 5)));
|
||||
|
||||
service.requirePublishable(90L);
|
||||
verify(outbox, org.mockito.Mockito.never()).insert(
|
||||
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
service.publish(90L);
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox, org.mockito.Mockito.times(2)).insert(captured.capture());
|
||||
EventEnvelope first = mapper.readValue(captured.getAllValues().get(0).getPayloadJson(), EventEnvelope.class);
|
||||
EventEnvelope replay = mapper.readValue(captured.getAllValues().get(1).getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(first.payload().path("loadingEvidence")).hasSize(5);
|
||||
assertThat(first.payload().at("/loadingEvidence/2/evidenceType").asText()).isEqualTo("PHOTO_STEP_3");
|
||||
assertThat(first.payload().at("/loadingEvidence/2/fileId").asLong()).isEqualTo(6L);
|
||||
assertThat(replay.payloadHash()).isEqualTo(first.payloadHash());
|
||||
}
|
||||
|
||||
/** 多张同类照片不能替代缺失类别,作废照片仍不计入五类完整性。 */
|
||||
@Test
|
||||
void rejectsMissingPhotoTypeEvenWhenMoreThanFivePhotosRemain() {
|
||||
Row value = row(11, 100, 101);
|
||||
value.setPlannedPackageCount(1);
|
||||
value.setPickedPackageCount(1);
|
||||
when(snapshots.previewRows(90L)).thenReturn(List.of(value));
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
CabinetLoadingEvidenceEntity voided = evidence("PHOTO_STEP_4", 4);
|
||||
voided.setStatus("VOID");
|
||||
when(cabinetEvidence.byContainer(91L)).thenReturn(List.of(
|
||||
evidence("PHOTO_STEP_1", 1), evidence("PHOTO_STEP_2", 2),
|
||||
evidence("PHOTO_STEP_3", 3), evidence("PHOTO_STEP_3", 6), evidence("PHOTO_STEP_3", 7),
|
||||
voided, evidence("SEAL", 5)));
|
||||
|
||||
assertThatThrownBy(() -> service.requirePublishable(90L)).hasMessageContaining("五类照片不完整");
|
||||
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("五类照片不完整");
|
||||
verify(outbox, org.mockito.Mockito.never()).insert(
|
||||
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
}
|
||||
|
||||
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
|
||||
|
||||
Reference in New Issue
Block a user