fix(acceptance): enforce immutable confirmed fees

This commit is contained in:
2026-09-10 21:18:52 +08:00
parent 7b35373da3
commit 800ebb875f
14 changed files with 116 additions and 53 deletions
@@ -222,6 +222,7 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -306,6 +307,16 @@ public class CarrierScheduleQueryService {
return false;
}
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt) {
if (actualAt == null) return;
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
if ("STOPPED".equals(value.getStatus()) || current != null && node.sequence() <= current.sequence()) {
throw conflict("实际船节点必须按业务顺序向后推进");
}
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
@@ -98,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试");
}
if (feeRevisionLocked(inbound, published)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
if (feeRevisionLocked(published)) {
throw conflict("客户已确认,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -490,8 +490,8 @@ public class FbaPricingService {
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用");
}
if (feeRevisionLocked(inbound, current)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
if (feeRevisionLocked(current)) {
throw conflict("客户已确认,不允许再修改费用");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
@@ -613,9 +613,8 @@ public class FbaPricingService {
.contains(processStatus);
}
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
private static boolean feeRevisionLocked(InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null;
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
@@ -474,6 +474,26 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin");
}
@Test
void manualActualFactRejectsCurrentOrEarlierBusinessNodeBeforeWritingFact() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("AT_ORIGIN_PORT");
task.setLatestNodeObservedAt(NOW.minusHours(1));
when(repository.lock(1L)).thenReturn(task);
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-same-1",
new ManualNodeRequest(3, "AT_ORIGIN_PORT", NOW, null, NOW,
"CNSZX", "深圳", "重复当前节点")))
.isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-earlier-1",
new ManualNodeRequest(3, "DEPARTED_WAREHOUSE", NOW, null, NOW,
"CNSZX", "深圳", "补录更早节点")))
.isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进");
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void holdIsPersistedAsSeparateImmutableExceptionFact() {
CarrierScheduleQueryEntity task = task(0, 1);
@@ -461,7 +461,6 @@ class FbaPricingServiceTest {
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("SENT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW.minusHours(1));
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
@@ -531,10 +530,10 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("客户已确认且装柜完成时拒绝平台再次修改")
@DisplayName("客户确认后即拒绝平台再次修改")
void rejectsAdjustmentAfterCustomerConfirmation() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("LOADED");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);