287 lines
13 KiB
Java
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, "整柜拖车费");
|
|
}
|
|
}
|