Files
baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java
T

287 lines
13 KiB
Java

package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDate;
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.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
class ContainerFeeDetailServiceTest {
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class);
private final FeeItemRepository feeItems = mock(FeeItemRepository.class);
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
private final FeePaymentSnapshotValidator paymentSnapshots = mock(FeePaymentSnapshotValidator.class);
private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots,
paymentSnapshots);
@BeforeEach
void paymentSnapshot() {
lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any()))
.thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot(
invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4),
invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6)));
}
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity other = new LoadingPlanItemEntity();
other.setAccountId(20002L);
when(loading.items(41L)).thenReturn(List.of(other));
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, request(true)))
.isInstanceOf(BizException.class).hasMessageContaining("不属于该客户");
verify(repository, never()).insert(any());
}
@Test
void approvedSettlementFeePublishesContainerSource() {
ownedPlan();
ContainerFeeDetailEntity approved = new ContainerFeeDetailEntity();
approved.setId(51L);
approved.setTenantId(10001L);
approved.setLoadingPlanId(41L);
approved.setFeeItemId(11L);
approved.setDirection("RECEIVABLE");
approved.setSourceVersion(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(feeItems.findItem(11L)).thenReturn(item());
when(repository.updateSupplierSnapshot(eq(10001L), eq(41L), eq(51L),
eq(null), eq(null), eq(null), eq(9L))).thenReturn(1);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
service.approve(10001L, 41L, 51L, 9L, "admin");
verify(outbox).publishContainer(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(41L), eq(51L), eq(1), any(LocalDateTime.class));
}
@Test
void adjustmentIncrementsSourceVersionAndReturnsToReview() {
ownedPlan();
ContainerFeeDetailEntity current = approvedFee(1);
ContainerFeeDetailEntity adjusted = approvedFee(2);
adjusted.setBusinessDate(LocalDate.of(2026, 8, 12));
adjusted.setAmount(new BigDecimal("325.00"));
adjusted.setReviewStatus("PENDING");
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1),
eq(LocalDate.of(2026, 8, 12)), eq(new BigDecimal("325.00")), eq(9L), any()))
.thenReturn(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(current, adjusted);
ContainerFeeDetailEntity result = service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"));
assertThat(result.getSourceVersion()).isEqualTo(2);
assertThat(result.getReviewStatus()).isEqualTo("PENDING");
assertThat(result.getFeeItemId()).isEqualTo(11L);
assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getSupplierId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong());
}
@Test
void exactAdjustmentReplayReturnsCurrentPendingVersionWithoutWriting() {
ownedPlan();
ContainerFeeDetailEntity replay = approvedFee(2);
replay.setReviewStatus("PENDING");
replay.setBusinessDate(LocalDate.of(2026, 8, 12));
replay.setAmount(new BigDecimal("325.00"));
when(repository.find(10001L, 41L, 51L)).thenReturn(replay);
assertThat(service.adjust(10001L, 41L, 51L, 9L, adjustment(1, "325.0")))
.isSameAs(replay);
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void staleOrDifferentReplayIsRejectedWithoutWriting() {
ownedPlan();
ContainerFeeDetailEntity current = approvedFee(2);
current.setReviewStatus("PENDING");
current.setBusinessDate(LocalDate.of(2026, 8, 12));
current.setAmount(new BigDecimal("325.00"));
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "326.00"))).isInstanceOf(BizException.class)
.hasMessageContaining("当前版本的已审核");
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void concurrentVersionChangeReturnsConflictWithoutPublishing() {
ownedPlan();
when(repository.find(10001L, 41L, 51L)).thenReturn(approvedFee(1));
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), any(), any(), eq(9L), any()))
.thenReturn(0);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class)
.hasMessageContaining("当前版本的已审核");
verify(outbox, never()).publishContainer(any(), anyLong(), anyLong(), anyLong(),
anyInt(), any());
}
@Test
void voidedOrUnapprovedFeeCannotBeAdjusted() {
ownedPlan();
ContainerFeeDetailEntity voided = approvedFee(1);
voided.setSourceStatus("VOIDED");
when(repository.find(10001L, 41L, 51L)).thenReturn(voided);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
ContainerFeeDetailEntity pending = approvedFee(1);
pending.setReviewStatus("PENDING");
when(repository.find(10001L, 41L, 51L)).thenReturn(pending);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void payableRequiresSupplierSnapshot() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var missingSupplier = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
null, "整柜拖车费");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingSupplier))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效供应商");
verify(repository, never()).insert(any());
}
@Test
void receivableClearsCounterpartyFields() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
71L, "整柜操作费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getSupplierId()).isNull();
assertThat(captor.getValue().getSupplierName()).isNull();
}
@Test
void directPaidFieldsAreStoredAsBusinessSnapshot() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
when(suppliers.fee(71L, null)).thenReturn(
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE", List.of()));
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L,
"DIRECT_PAID", paidAt, "BANK_TRANSFER", "BANK-001", List.of(901L), "已付拖车费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getSettlementMode()).isEqualTo("DIRECT_PAID");
assertThat(captor.getValue().getPaidAt()).isEqualTo(paidAt);
assertThat(captor.getValue().getPaymentMethod()).isEqualTo("BANK_TRANSFER");
assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L);
}
private void ownedPlan() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity item = new LoadingPlanItemEntity();
item.setAccountId(10001L);
when(loading.items(41L)).thenReturn(List.of(item));
}
private static FeeItemEntity item() {
FeeItemEntity value = new FeeItemEntity();
value.setId(11L);
value.setFeeCode("TRUCKING");
value.setFeeTypeName("拖车费");
value.setWarehouseReceivable(true);
value.setWarehousePayable(true);
value.setStatus("ACTIVE");
return value;
}
private static ContainerFeeDetailEntity approvedFee(int version) {
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setId(51L);
value.setTenantId(10001L);
value.setLoadingPlanId(41L);
value.setFeeItemId(11L);
value.setFeeItemCodeSnapshot("TRUCKING");
value.setFeeItemNameSnapshot("拖车费");
value.setDirection("PAYABLE");
value.setSupplierId(71L);
value.setSupplierName("现有供应商");
value.setCurrency("CNY");
value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11));
value.setAmount(new BigDecimal("300.00"));
value.setSourceStatus("ACTIVE");
value.setReviewStatus("APPROVED");
value.setSourceVersion(version);
return value;
}
private static AdjustRequest adjustment(int version, String amount) {
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
71L, "整柜拖车费");
}
}