feat(fleet): integrate costs quotes operations and in-transit handovers

This commit is contained in:
2026-10-03 12:38:04 +08:00
parent 274ecac474
commit cb6c54ffe5
107 changed files with 7304 additions and 42 deletions
@@ -0,0 +1,67 @@
package com.baiyue.finance.fleet;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class VehicleCostAuthorizationTest {
@Test void actualMethodSecurityDoesNotLetReadersApprovePayOrVoid() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformVehicleCostController.class);
var service = context.getBean(VehicleCostService.class);
var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L); when(user.displayName()).thenReturn("财务人员");
var request = new VehicleCostModels.Command(0L, null);
login("platform:finance:vehicle-cost:list"); controller.detail("VC-ONE");
assertThatThrownBy(() -> controller.approve("VC-ONE", user, "approve-one", request)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class);
login("platform:finance:vehicle-cost:approve"); controller.approve("VC-ONE", user, "approve-one", request);
assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class);
login("platform:finance:vehicle-cost:pay"); controller.pay("VC-ONE", user, "pay-one", null);
assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class);
login("platform:finance:vehicle-cost:void"); controller.voidCost("VC-ONE", user, "void-one", request);
login(); assertThatThrownBy(() -> controller.detail("VC-ONE")).isInstanceOf(AccessDeniedException.class);
verify(service).detail("VC-ONE"); verify(service).approve("VC-ONE", 8L, "财务人员", "approve-one", request);
verify(service).pay("VC-ONE", 8L, "财务人员", "pay-one", null);
verify(service).voidCost("VC-ONE", 8L, "财务人员", "void-one", request);
verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
private static void login(String... permissions) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused",
java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));
}
@Test void companyCostActionsHaveIndependentPermissionsAndReadsUseListPermission() throws Exception {
assertThat(PlatformVehicleCostController.class.getMethod("detail", String.class).getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:finance:vehicle-cost:list')");
for (var entry : java.util.Map.of("approve", "approve", "voidCost", "void").entrySet()) {
assertThat(PlatformVehicleCostController.class.getMethod(entry.getKey(), String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Command.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:" + entry.getValue() + "')");
}
assertThat(PlatformVehicleCostController.class.getMethod("pay", String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Payment.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:pay')");
}
@Test void internalEndpointsRejectMissingOrWrongServiceIdentityBeforeServiceCalls() {
var service = mock(VehicleCostService.class);
var controller = new InternalVehicleCostController(service, "test-internal-token");
assertThatThrownBy(() -> controller.receive(null, null)).hasMessageContaining("令牌");
assertThatThrownBy(() -> controller.sources("wrong", null)).hasMessageContaining("令牌");
assertThatThrownBy(() -> new InternalVehicleCostController(service, "").receive("", null)).hasMessageContaining("令牌");
verifyNoInteractions(service);
}
@Configuration @EnableMethodSecurity static class Config {
@Bean VehicleCostService costs() { return mock(VehicleCostService.class); }
@Bean VehicleCostVouchers vouchers() { return mock(VehicleCostVouchers.class); }
@Bean PlatformVehicleCostController controller(VehicleCostService costs, VehicleCostVouchers vouchers) {
return new PlatformVehicleCostController(costs, vouchers);
}
}
}
@@ -0,0 +1,23 @@
package com.baiyue.finance.fleet;
import com.baiyue.finance.service.FinanceEventException;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;
class VehicleCostHttpContractTest {
@Test void fleetBusinessErrorsKeepFinanceCodesAndDiagnosticIds() throws Exception {
var service = mock(VehicleCostService.class);
when(service.detail("VC-ONE")).thenThrow(new FinanceEventException("FIN_409", "公司车辆费用版本已变化"));
var mvc = MockMvcBuilders.standaloneSetup(new PlatformVehicleCostController(service, mock(VehicleCostVouchers.class)))
.setControllerAdvice(new VehicleCostExceptionHandler()).build();
mvc.perform(get("/api/platform/finance/v1/vehicle-costs/VC-ONE"))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("FIN_409"))
.andExpect(jsonPath("$.message").value("公司车辆费用版本已变化"))
.andExpect(jsonPath("$.requestId", not(emptyOrNullString())))
.andExpect(jsonPath("$.traceId", not(emptyOrNullString())));
}
}
@@ -0,0 +1,187 @@
package com.baiyue.finance.fleet;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.*;
import com.baiyue.finance.client.WarehouseSupplierClient;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.validation.Validation;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class VehicleCostPersistenceTest {
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private JdbcTemplate jdbc;
private VehicleCostService service;
private FinanceSupplierValidator suppliers;
private VehicleCostVouchers vouchers;
private ValidatorFactory validators;
private static final VehicleCostModels.Query PAGE = new VehicleCostModels.Query(1, 20);
@BeforeEach void setup() {
var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", "");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fin_subject(id BIGINT PRIMARY KEY,subject_code VARCHAR(64),status VARCHAR(20))");
jdbc.update("INSERT INTO fin_subject VALUES(1,'PLATFORM_DEFAULT','ACTIVE')");
jdbc.execute("CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64),payload CLOB,payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT,processed_at TIMESTAMP(3),UNIQUE(source_system,event_id))");
jdbc.execute("CREATE TABLE fin_vehicle_cost(id BIGINT PRIMARY KEY,subject_id BIGINT NOT NULL,cost_no VARCHAR(64) NOT NULL UNIQUE,source_type VARCHAR(32) NOT NULL,source_no VARCHAR(64) NOT NULL,source_version BIGINT NOT NULL,source_hash VARCHAR(71) NOT NULL,source_json CLOB NOT NULL,supplier_id BIGINT NOT NULL,supplier_name VARCHAR(200) NOT NULL,amount DECIMAL(17,2) NOT NULL,paid_amount DECIMAL(17,2) NOT NULL,status VARCHAR(32) NOT NULL,record_version BIGINT NOT NULL,created_at TIMESTAMP(3),updated_at TIMESTAMP(3),UNIQUE(subject_id,source_type,source_no),CHECK(paid_amount>=0 AND paid_amount<=amount))");
jdbc.execute("CREATE TABLE fin_vehicle_cost_revision(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,record_version BIGINT NOT NULL,action VARCHAR(32) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,operator_id BIGINT NOT NULL,operator_name VARCHAR(128) NOT NULL,reason VARCHAR(1000),snapshot_json CLOB NOT NULL,occurred_at TIMESTAMP(3),UNIQUE(cost_id,record_version),UNIQUE(operator_id,action,request_key))");
jdbc.execute("CREATE TABLE fin_vehicle_cost_payment(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,payment_no VARCHAR(64) NOT NULL UNIQUE,amount DECIMAL(17,2) NOT NULL,payment_json CLOB NOT NULL,created_at TIMESTAMP(3))");
suppliers = mock(FinanceSupplierValidator.class);
when(suppliers.requireActive(8L, null)).thenReturn(new WarehouseSupplierClient.SupplierSnapshot(8L, "维修供应商", null, "ACTIVE"));
vouchers = mock(VehicleCostVouchers.class);
var numbers = mock(FinanceDocumentNumberGenerator.class); var sequence = new AtomicInteger();
when(numbers.next(anyString())).thenAnswer(call -> call.getArgument(0) + "-" + sequence.incrementAndGet());
validators = Validation.buildDefaultValidatorFactory();
var target = new VehicleCostService(jdbc, json, validators.getValidator(), suppliers, numbers, vouchers);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (VehicleCostService) proxy.getProxy();
}
@AfterEach void close() { validators.close(); }
private VehicleCostModels.Source source(long version, String amount) {
return new VehicleCostModels.Source("REPAIR", "VR-ONE", version, "101", "TEST-PLATE", "TR-ONE", 8,
"车辆维修", LocalDate.of(2026, 1, 1), "USD", new BigDecimal(amount), new BigDecimal("7.000000"), "2026-01",
new BigDecimal(amount).multiply(new BigDecimal("7")).setScale(2), null);
}
private EventEnvelope event(VehicleCostModels.Source source) {
var payload = json.valueToTree(source);
return new EventEnvelope(UUID.randomUUID().toString(), VehicleCostModels.EVENT_TYPE, 1, Instant.now(), "0",
"COMPANY_VEHICLE_COST", source.sourceType() + ":" + source.sourceNo(), source.sourceVersion(), null,
UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
private VehicleCostModels.Cost receive() { service.receive(event(source(1, "10"))); return service.list(PAGE).records().get(0); }
private VehicleCostModels.Payment payment(long version, String amount) {
return new VehicleCostModels.Payment(version, new BigDecimal(amount), Instant.parse("2026-01-02T00:00:00Z"),
"BANK_TRANSFER", "PAY-REF", List.of("11"), null);
}
@Test void acceptedSourceIsNotBookedOrPaidAndReplayRequiresTheSameHash() {
var first = event(source(1, "10"));
assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED);
assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE);
var other = event(source(1, "11"));
var conflict = new EventEnvelope(first.eventId(), other.eventType(), 1, other.occurredAt(), "0", other.aggregateType(), other.aggregateId(), 1,
null, other.traceId(), other.correlationId(), null, other.payload(), other.payloadHash());
assertThatThrownBy(() -> service.receive(conflict)).hasMessageContaining("不同公司费用");
var row = service.list(PAGE).records().get(0);
assertThat(row.status()).isEqualTo("PENDING"); assertThat(row.outstandingAmount()).isNull();
assertThat(row.paidAmount()).isZero(); assertThat(row.source().amount()).isEqualByComparingTo("70");
assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isEqualTo(1);
}
@Test void missingVersionCannotApproveOrPayTheInitialRecord() throws Exception {
var row = receive();
var missing = json.readValue("{}", VehicleCostModels.Command.class);
assertThat(missing.expectedVersion()).isNull();
assertThatThrownBy(() -> service.approve(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数");
assertThatThrownBy(() -> service.voidCost(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数");
var pay = new VehicleCostModels.Payment(null, BigDecimal.ONE, Instant.parse("2026-01-02T00:00:00Z"), "CASH", null, List.of("11"), null);
assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "missing-version", pay)).hasMessageContaining("参数");
assertThat(service.detail(row.costNo()).status()).isEqualTo("PENDING");
assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1);
}
@Test void failedSourceAuditCannotAcknowledgeOrPersistHalfAnEvent() {
var event = event(source(1, "10"));
jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_source_audit CHECK(action<>'RECEIVE')");
assertThatThrownBy(() -> service.receive(event)).isInstanceOf(RuntimeException.class);
assertThat(service.list(PAGE).total()).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero();
jdbc.execute("ALTER TABLE fin_vehicle_cost_revision DROP CONSTRAINT reject_source_audit");
assertThat(service.receive(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED);
assertThat(service.list(PAGE).total()).isEqualTo(1);
}
@Test void concurrentDuplicateEventsOnlyPersistOneSourceAndOneHistory() throws Exception {
var event = event(source(1, "10")); var executor = Executors.newFixedThreadPool(2);
try {
var a = executor.submit(() -> service.receive(event)); var b = executor.submit(() -> service.receive(event));
assertThat(List.of(a.get(10, TimeUnit.SECONDS).outcome(), b.get(10, TimeUnit.SECONDS).outcome()))
.containsExactlyInAnyOrder(DeliveryAck.Outcome.APPLIED, DeliveryAck.Outcome.DUPLICATE);
} finally { executor.shutdownNow(); }
assertThat(service.list(PAGE).total()).isEqualTo(1);
}
@Test void partialAndFullPaymentConserveAmountsAndReplayNeverPaysTwice() {
var row = receive(); var no = row.costNo();
assertThatThrownBy(() -> service.pay(no, 1, "财务", "before-approve", payment(0, "1"))).hasMessageContaining("已入账");
var approved = service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
assertThat(approved.outstandingAmount()).isEqualByComparingTo("70");
var partial = service.pay(no, 1, "财务", "payment-one", payment(1, "20"));
assertThat(partial.status()).isEqualTo("PARTIALLY_PAID"); assertThat(partial.outstandingAmount()).isEqualByComparingTo("50");
assertThat(service.pay(no, 1, "财务", "payment-one", payment(1, "20"))).isEqualTo(partial);
assertThatThrownBy(() -> service.pay(no, 1, "财务", "payment-one", payment(1, "21"))).hasMessageContaining("幂等键");
assertThatThrownBy(() -> service.pay(no, 1, "财务", "too-much", payment(2, "51"))).hasMessageContaining("未付余额");
assertThatThrownBy(() -> service.pay(no, 1, "财务", "stale-version", payment(1, "1"))).hasMessageContaining("版本");
var paid = service.pay(no, 1, "财务", "payment-two", payment(2, "50"));
assertThat(paid.status()).isEqualTo("PAID"); assertThat(paid.outstandingAmount()).isZero();
assertThat(service.payments(no, PAGE).total()).isEqualTo(2);
assertThatThrownBy(() -> service.voidCost(no, 1, "财务", "void-paid", new VehicleCostModels.Command(3L, null))).hasMessageContaining("已付款");
assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("不能替换");
}
@Test void unpaidBookedCostRequiresVoidBeforeNewVersionAndRetainsSnapshots() {
var row = receive(); var no = row.costNo();
service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("先作废");
service.voidCost(no, 1, "财务", "void-one", new VehicleCostModels.Command(1L, "金额重新核对"));
service.receive(event(source(2, "20")));
var latest = service.detail(no);
assertThat(latest.status()).isEqualTo("PENDING"); assertThat(latest.source().amount()).isEqualByComparingTo("140");
assertThat(service.history(no, PAGE).records()).hasSize(4);
assertThat(service.history(no, PAGE).records().get(3).snapshot().source().amount()).isEqualByComparingTo("70");
assertThatThrownBy(() -> service.receive(event(source(1, "10")))).hasMessageContaining("过期");
}
@Test void auditFailureRollsBackPaymentAndBalances() {
var row = receive(); var no = row.costNo();
service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_payment_audit CHECK(action<>'PAY')");
assertThatThrownBy(() -> service.pay(no, 1, "财务", "failing-payment", payment(1, "20"))).isInstanceOf(RuntimeException.class);
assertThat(service.detail(no).paidAmount()).isZero(); assertThat(service.detail(no).recordVersion()).isEqualTo(1);
assertThat(service.payments(no, PAGE).records()).isEmpty();
}
@Test void voucherOrSupplierFailureDoesNotCreatePayment() {
var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
doThrow(new IllegalStateException("凭证归属不符")).when(vouchers).validate(anyLong(), anyList());
assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("归属");
assertThat(service.payments(row.costNo(), PAGE).total()).isZero();
reset(vouchers); when(suppliers.requireActive(8L, null)).thenThrow(new IllegalStateException("供应商不可用"));
assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("不可用");
assertThat(service.detail(row.costNo()).paidAmount()).isZero();
}
@Test void concurrentPaymentsCannotOverpay() throws Exception {
var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
var executor = Executors.newFixedThreadPool(2);
try {
Callable<Boolean> first = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-a", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } };
Callable<Boolean> second = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-b", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } };
var a = executor.submit(first); var b = executor.submit(second);
assertThat(List.of(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
} finally { executor.shutdownNow(); }
assertThat(service.detail(row.costNo()).paidAmount()).isEqualByComparingTo("50");
assertThat(service.detail(row.costNo()).outstandingAmount()).isEqualByComparingTo("20");
}
@Test void rejectsClientScopeBadExchangeAndBadSourceBeforeWritingAnything() {
var valid = event(source(1, "10"));
var customer = new EventEnvelope(valid.eventId(), valid.eventType(), 1, valid.occurredAt(), "12", valid.aggregateType(), valid.aggregateId(), 1,
null, valid.traceId(), valid.correlationId(), null, valid.payload(), valid.payloadHash());
assertThatThrownBy(() -> service.receive(customer)).hasMessageContaining("归属");
var tree = (com.fasterxml.jackson.databind.node.ObjectNode) valid.payload().deepCopy(); tree.put("amount", 71);
var invalid = new EventEnvelope(UUID.randomUUID().toString(), valid.eventType(), 1, valid.occurredAt(), "0", valid.aggregateType(), valid.aggregateId(), 1,
null, valid.traceId(), valid.correlationId(), null, tree, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(tree)));
assertThatThrownBy(() -> service.receive(invalid)).hasMessageContaining("不守恒");
assertThat(service.list(PAGE).total()).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero();
}
}
@@ -0,0 +1,42 @@
package com.baiyue.finance.fleet;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class VehicleCostVouchersTest {
@Test void onlyPlatformFilesBoundToThisCompanyCostCanProvePayment() {
var client = mock(VehicleCostFileClient.class);
var service = new VehicleCostVouchers(client, "test-internal-token");
when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success(
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L)));
service.validate(5, List.of("11"));
for (var wrong : List.of(
new VehicleCostFileClient.Metadata(11L, "CUSTOMER", 12L, "FINANCE_VOUCHER", "12", "payment.png", "image/png", 100L),
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "6", "payment.png", "image/png", 100L),
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FLEET_VEHICLE_RECORD_PHOTO", "5", "repair.png", "image/png", 100L))) {
when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success(wrong));
assertThatThrownBy(() -> service.validate(5, List.of("11"))).hasMessageContaining("归属");
}
}
@Test void absentConfigurationOrDuplicateVouchersFailClosed() {
var client = mock(VehicleCostFileClient.class);
assertThatThrownBy(() -> new VehicleCostVouchers(client, "").validate(5, List.of("11"))).hasMessageContaining("不可用");
assertThatThrownBy(() -> new VehicleCostVouchers(client, "test").validate(5, List.of("11", "11"))).hasMessageContaining("重复");
verifyNoInteractions(client);
}
@Test void fileServiceBusinessErrorBodyCannotBeForwardedAsPaymentEvidence() {
var client = mock(VehicleCostFileClient.class);
var service = new VehicleCostVouchers(client, "test");
when(client.metadata("test", 5, 11)).thenReturn(ApiResponse.success(
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L)));
when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
assertThatThrownBy(() -> service.download(5, 11)).hasMessageContaining("不可用");
when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok()
.contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[100]));
assertThat(service.download(5, 11).getBody()).hasSize(100);
}
}
@@ -56,4 +56,15 @@ class FinanceDocumentNumberGeneratorTest {
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
}
@Test
void vehicleCostsAndPaymentsUseIndependentSequenceKeys() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L);
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("VC")).isEqualTo("VC-260818-0001");
assertThat(generator.next("VP")).isEqualTo("VP-260818-0001");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818"));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818"));
}
}