395 lines
24 KiB
Java
395 lines
24 KiB
Java
package com.baiyue.finance;
|
|
|
|
import com.baiyue.common.core.api.ApiResponse;
|
|
import com.baiyue.common.security.AuthenticatedUser;
|
|
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
|
import com.baiyue.finance.controller.FinancePhase1OperationController;
|
|
import com.baiyue.finance.dto.FinancePhase1Dtos;
|
|
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
|
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
|
import com.baiyue.finance.service.FinanceEventException;
|
|
import com.baiyue.finance.service.FinancePhase1OperationService;
|
|
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
|
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
import org.junit.jupiter.params.ParameterizedTest;
|
|
import org.junit.jupiter.params.provider.ValueSource;
|
|
import org.mockito.Mock;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.jdbc.core.RowMapper;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.sql.ResultSet;
|
|
import java.sql.Timestamp;
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.List;
|
|
import java.util.Set;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
|
|
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.anyString;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.ArgumentMatchers.startsWith;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.lenient;
|
|
import static org.mockito.Mockito.never;
|
|
import static org.mockito.Mockito.verify;
|
|
import static org.mockito.Mockito.when;
|
|
import static org.mockito.Mockito.spy;
|
|
import static org.mockito.Mockito.doReturn;
|
|
import static org.mockito.Mockito.times;
|
|
|
|
@ExtendWith(MockitoExtension.class)
|
|
class FinancePhase1OperationBehaviorTest {
|
|
@Mock FinancePhase1OperationService operationService;
|
|
@Mock FinanceCustomerAccountService customerAccountService;
|
|
|
|
@Test
|
|
void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() {
|
|
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService);
|
|
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
|
|
var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0);
|
|
var action = new FinancePhase1Dtos.FeeSourceActionRequest("提交审核", 1);
|
|
|
|
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
|
|
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
|
|
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED",
|
|
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
|
controller.getFinanceFeeSource(88L);
|
|
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
|
|
controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
|
|
controller.voidFinanceFeeSource(user, 88L, "void-key", action);
|
|
|
|
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
|
|
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
|
|
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
|
|
eq("AVAILABLE"), eq("APPROVED"), eq(false), any(), any(), eq("IN-9"));
|
|
verify(operationService).getFinanceFeeSource(88L);
|
|
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
|
|
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
|
|
verify(operationService).voidFinanceFeeSource(88L, 71L, "finance", "void-key", action);
|
|
}
|
|
|
|
@Test
|
|
void pageEnvelopeAlwaysContainsTraceMetadata() {
|
|
PageEnvelope<String> response = PageEnvelope.success(List.of("row"), 1, 20, 1);
|
|
assertThat(response.code()).isEqualTo("200");
|
|
assertThat(response.requestId()).isNotBlank();
|
|
assertThat(response.traceId()).isNotBlank();
|
|
assertThat(response.page().total()).isEqualTo(1);
|
|
}
|
|
|
|
/** 无效客户在进入持久化查询前拒绝。 */
|
|
@Test
|
|
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
|
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
|
.isInstanceOf(FinanceEventException.class)
|
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
|
}
|
|
|
|
/** 保留客户账户汇总的固定分页与金额口径。 */
|
|
@Test
|
|
void accountListReturnsFixedPagedDto() {
|
|
AccountJdbc jdbc = new AccountJdbc();
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
|
|
|
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
|
|
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
|
|
|
|
assertThat(result.data()).singleElement().satisfies(account -> {
|
|
assertThat(account.customerId()).isEqualTo(9L);
|
|
assertThat(account.customerName()).isEqualTo("客户9");
|
|
assertThat(account.outstandingReceivable()).isEqualByComparingTo("100.00");
|
|
});
|
|
assertThat(result.page().total()).isEqualTo(1);
|
|
assertThat(result.requestId()).isNotBlank();
|
|
assertThat(result.traceId()).isNotBlank();
|
|
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
|
|
assertThat(jdbc.accountListSql)
|
|
.contains("ar.direction='RECEIVABLE'", "cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "FROM fin_settlement_line sl")
|
|
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'");
|
|
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
|
}
|
|
|
|
/** 所有派生流水查询保留目标客户范围。 */
|
|
@Test
|
|
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
|
|
AccountJdbc jdbc = new AccountJdbc();
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
|
|
|
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
|
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
|
|
|
assertThat(result.data()).isEmpty();
|
|
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L);
|
|
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
|
|
.contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "status='CONFIRMED'"));
|
|
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
|
|
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
|
}
|
|
|
|
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
|
|
@Test
|
|
void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
|
|
var reads = mock(FinanceFeeSourceReadRepository.class);
|
|
var filters = java.util.Map.<String, Object>of("query", "current");
|
|
when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
|
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
|
|
when(reads.list(filters)).thenReturn(List.of());
|
|
when(reads.count(filters)).thenReturn(3L);
|
|
var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
|
|
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
|
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
|
assertThat(result.page().total()).isEqualTo(3);
|
|
assertThat(result.page().page()).isEqualTo(2);
|
|
verify(reads).list(filters);
|
|
}
|
|
|
|
/** 流水返回业务单号和操作者快照。 */
|
|
@Test
|
|
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
|
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
when(jdbc.query(startsWith("SELECT id,bill_no"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
|
|
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
|
ResultSet rs = mock(ResultSet.class);
|
|
when(rs.getLong("id")).thenReturn(81L);
|
|
when(rs.getLong("created_by")).thenReturn(71L);
|
|
when(rs.getString("bill_no")).thenReturn("REC-81");
|
|
when(rs.getString("created_by_name")).thenReturn("platform_admin");
|
|
when(rs.getString("remark")).thenReturn("八月账单");
|
|
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("5200.00"));
|
|
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-08-17 13:00:00"));
|
|
return List.of(mapper.mapRow(rs, 0));
|
|
});
|
|
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
|
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
|
|
|
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
|
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
|
|
|
assertThat(result.data()).singleElement().satisfies(entry -> {
|
|
assertThat(entry.businessType()).isEqualTo("SETTLEMENT_ISSUED");
|
|
assertThat(entry.documentType()).isEqualTo("BILL");
|
|
assertThat(entry.documentNo()).isEqualTo("REC-81");
|
|
assertThat(entry.occurredAt()).isEqualTo(LocalDateTime.of(2026, 8, 17, 13, 0));
|
|
assertThat(entry.operatorName()).isEqualTo("platform_admin");
|
|
});
|
|
}
|
|
|
|
/** 保留费用审核状态读取。 */
|
|
@Test
|
|
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 2));
|
|
|
|
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
|
}
|
|
|
|
/** 费用详情保持原客户与来源版本。 */
|
|
@Test
|
|
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
|
mock(JdbcTemplate.class), objectMapper(), null, "", feeSources("APPROVED", true, 2));
|
|
|
|
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
|
|
|
assertThat(detail.feeSourceId()).isEqualTo(88L);
|
|
assertThat(detail.customerId()).isEqualTo(9L);
|
|
assertThat(detail.currency()).isEqualTo("CNY");
|
|
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
|
}
|
|
|
|
/** 业务来源费用必须回到所属系统调整。 */
|
|
@Test
|
|
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 1));
|
|
|
|
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
|
new BigDecimal("120.00"), "更正金额", 1);
|
|
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false))
|
|
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
|
|
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true))
|
|
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
|
|
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="),
|
|
(Object[]) any());
|
|
}
|
|
|
|
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
|
@Test
|
|
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
|
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
|
|
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
|
|
when(firstleg.voidSource(eq("internal-token"), eq("void-key"), anyString(), any()))
|
|
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
|
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
|
jdbc, objectMapper(), firstleg, "internal-token", feeSources("APPROVED", false, 2));
|
|
|
|
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
|
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
|
|
|
assertThat(result.sourceStatus()).isEqualTo("AVAILABLE");
|
|
var command = org.mockito.ArgumentCaptor.forClass(FirstlegFeeSourceClient.VoidCommand.class);
|
|
verify(firstleg).voidSource(eq("internal-token"), eq("void-key"), anyString(), command.capture());
|
|
assertThat(command.getValue()).satisfies(value -> {
|
|
assertThat(value.sourceType()).isEqualTo("INBOUND_FEE");
|
|
assertThat(value.sourceFeeId()).isEqualTo(700L);
|
|
assertThat(value.sourceVersion()).isEqualTo(3);
|
|
assertThat(value.customerId()).isEqualTo(9L);
|
|
assertThat(value.inboundId()).isEqualTo(100L);
|
|
assertThat(value.operatorName()).isEqualTo("finance");
|
|
assertThat(value.reason()).isEqualTo("测试作废");
|
|
});
|
|
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET status='VOIDED'"),
|
|
(Object[]) any());
|
|
}
|
|
|
|
/** 审核以当前操作版本更新并追加同一记录的审计。 */
|
|
@Test
|
|
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
AtomicInteger reads = new AtomicInteger();
|
|
var sources = mock(FinanceFeeSourceReadRepository.class);
|
|
when(sources.detail(88L)).thenAnswer(invocation -> reads.getAndIncrement() == 0
|
|
? feeDetail("PENDING", false, 0) : feeDetail("APPROVED", false, 1));
|
|
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
|
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
|
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
|
eq("FEE_SOURCE_REVIEW"), eq("review-key"), anyString())).thenReturn(1);
|
|
when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="),
|
|
eq("APPROVED"), eq("核对无误"), eq(88L), eq(0))).thenReturn(1);
|
|
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"),
|
|
eq(88L), eq("FEE_SOURCE_REVIEW_APPROVE"), eq("核对无误"), anyString(), anyString(),
|
|
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
|
|
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
|
|
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
|
|
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", sources);
|
|
|
|
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
|
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
|
|
|
assertThat(result.reviewStatus()).isEqualTo("APPROVED");
|
|
assertThat(result.operationVersion()).isEqualTo(1);
|
|
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
|
|
}
|
|
|
|
/** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */
|
|
@ParameterizedTest
|
|
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
|
void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception {
|
|
var jdbc = mock(JdbcTemplate.class);
|
|
var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)));
|
|
var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE")
|
|
.put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9)
|
|
.put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2)
|
|
.put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false);
|
|
var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class);
|
|
var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120)
|
|
.put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class);
|
|
doReturn(before, after).when(service).getFinanceFeeSource(88L);
|
|
lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")))
|
|
.thenReturn(1, 0);
|
|
var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2);
|
|
var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true);
|
|
assertThat(result.feeSourceId()).isEqualTo(88);
|
|
assertThat(result.sourceFeeId()).isEqualTo(700);
|
|
assertThat(result.direction()).isEqualTo(direction);
|
|
assertThat(result.amount()).isEqualByComparingTo("120");
|
|
assertThat(result.operationVersion()).isEqualTo(3);
|
|
var hash = org.mockito.ArgumentCaptor.forClass(String.class);
|
|
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
|
eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture());
|
|
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once")))
|
|
.thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L)));
|
|
assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after);
|
|
verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"));
|
|
verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L),
|
|
eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any());
|
|
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
|
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once",
|
|
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true))
|
|
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求");
|
|
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true))
|
|
.isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本");
|
|
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class))
|
|
.when(service).getFinanceFeeSource(88L);
|
|
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
|
|
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
|
|
for (String occupied : List.of("PAYABLE_BILLED", "PARTIALLY_PAID", "PAID")) {
|
|
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("sourceStatus", occupied), FeeSourceDetail.class))
|
|
.when(service).getFinanceFeeSource(88L);
|
|
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "occupied-" + occupied, request, true))
|
|
.isInstanceOf(FinanceEventException.class).hasMessageContaining("费用已被占用、结清或作废");
|
|
}
|
|
}
|
|
|
|
/** 服务行为测试由 Repository 返回明确快照;正式 SQL 与 DTO 映射由 Mapper 数据库测试覆盖。 */
|
|
private static FinanceFeeSourceReadRepository feeSources(String review, boolean generated, int version) throws Exception {
|
|
var repository = mock(FinanceFeeSourceReadRepository.class);
|
|
when(repository.detail(88L)).thenReturn(feeDetail(review, generated, version));
|
|
return repository;
|
|
}
|
|
|
|
private static FeeSourceDetail feeDetail(String review, boolean generated, int version) throws Exception {
|
|
var node = objectMapper().createObjectNode().put("feeSourceId",88).put("sourceSystem","FIRSTLEG")
|
|
.put("sourceType","INBOUND_FEE").put("sourceFeeId",700).put("sourceVersion",3).put("customerId",9)
|
|
.put("direction","RECEIVABLE").put("orderId",90).put("inboundId",100).put("containerId",200)
|
|
.put("inboundCode","NYEXX2608180003").put("sourceBusinessId","IN-100").put("feeItemName","头程运费")
|
|
.put("amount",100).put("currency","CNY").put("operationVersion",version).put("reviewStatus",review)
|
|
.put("sourceStatus",generated ? "BILLED" : "AVAILABLE").put("statementGenerated",generated)
|
|
.put("sourceHash","a".repeat(64)).put("businessDate","2026-08-11");
|
|
return objectMapper().treeToValue(node,FeeSourceDetail.class);
|
|
}
|
|
|
|
private static ObjectMapper objectMapper() {
|
|
return new ObjectMapper().findAndRegisterModules();
|
|
}
|
|
|
|
private static final class AccountJdbc extends JdbcTemplate {
|
|
private final List<Long> ledgerCustomerIds = new ArrayList<>();
|
|
private final List<String> ledgerSql = new ArrayList<>();
|
|
private String accountListSql = "";
|
|
|
|
@Override
|
|
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
|
|
if (sql.startsWith("SELECT COUNT(*)")) return requiredType.cast(1L);
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
@SuppressWarnings("unchecked")
|
|
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
|
|
if (sql.startsWith("SELECT c.customer_id")) {
|
|
accountListSql = sql;
|
|
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
|
|
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
|
|
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
|
|
BigDecimal.ZERO, BigDecimal.ZERO,
|
|
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
|
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0,
|
|
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
|
}
|
|
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|
|
|| sql.startsWith("SELECT id,cost_no") || sql.startsWith("SELECT p.id")) {
|
|
ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue());
|
|
ledgerSql.add(sql);
|
|
return List.of();
|
|
}
|
|
return List.of();
|
|
}
|
|
}
|
|
}
|