This commit is contained in:
2026-08-20 18:02:20 +08:00
parent 1e8577dc4e
commit b1655a1841
39 changed files with 626 additions and 38 deletions
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import static org.assertj.core.api.Assertions.assertThat;
class FeePaymentVoucherFileClientContractTest {
@Test
void validatesFinanceVoucherWithinExactCustomerScope() throws Exception {
var method = FeePaymentVoucherFileClient.class.getMethod("metadata",
String.class, long.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}");
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
}
}
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、供应商、币种和备注保持原快照")
.contains("费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@@ -65,6 +65,8 @@ class InternalFinanceFeeSourceControllerTest {
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
.contains("{name: sourceVersion, in: query, required: true")
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
.contains("settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true")
.contains("paymentVoucherFileIds:")
.contains("为空时仅要求供应商ACTIVE")
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
.contains("FIRSTLEG_FEE_VOIDED Outbox")
@@ -18,11 +18,15 @@ class FinanceFeeSourceMigrationContractTest {
assertThat(baseline).contains(
"supplier_id BIGINT UNSIGNED NULL",
"supplier_name VARCHAR(200) NULL",
"supplier_category VARCHAR(16) NULL");
"supplier_category VARCHAR(16) NULL",
"settlement_mode VARCHAR(16) NULL",
"payment_voucher_file_ids JSON NULL");
assertThat(migration).contains(
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整");
}
@@ -35,12 +39,16 @@ class FinanceFeeSourceMigrationContractTest {
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"source_version", "supplier_id", "supplier_name", "supplier_category",
"settlement_mode", "paid_at", "payment_method", "payment_reference_no",
"payment_voucher_file_ids",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version",
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
"uk_finance_fee_command_idem",
"request_hash CHAR(64) NOT NULL",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode'",
"柜次费用已付版本快照字段不完整",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
@@ -12,6 +12,7 @@ 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;
@@ -29,6 +30,7 @@ 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);
@@ -37,8 +39,18 @@ class ContainerFeeDetailServiceTest {
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);
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() {
@@ -199,6 +211,28 @@ class ContainerFeeDetailServiceTest {
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"));
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);
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -27,12 +28,14 @@ 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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@DisplayName("费用项目和入库费用明细")
@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class})
@@ -45,11 +48,21 @@ class FeeManagementServiceTest {
private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private WarehouseSupplierSnapshotService suppliers;
@MockBean
private FeePaymentSnapshotValidator paymentSnapshots;
@Resource
private FeeItemService feeItems;
@Resource
private InboundFeeDetailService details;
@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
@DisplayName("客户B不能删除客户A的费用明细")
void rejectsCrossCustomerFeeDetailDelete() {
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
import org.junit.jupiter.api.Test;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FeePaymentSnapshotValidatorTest {
private final FeePaymentVoucherFileClient files = mock(FeePaymentVoucherFileClient.class);
private final FeePaymentSnapshotValidator validator =
new FeePaymentSnapshotValidator(files, "file-token");
@Test
void receivableClearsSettlementAndRejectsPaidFields() {
assertThat(validator.validate(9L, "RECEIVABLE", null, null, null, null, null))
.isEqualTo(new FeePaymentSnapshotValidator.Snapshot(null, null, null, null, List.of()));
assertThatThrownBy(() -> validator.validate(9L, "RECEIVABLE", "ON_ACCOUNT",
null, null, null, null)).isInstanceOf(BizException.class)
.hasMessageContaining("应收费用不得");
}
@Test
void onAccountRejectsEveryPaidField() {
assertThat(validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
null, null, null, null).settlementMode()).isEqualTo("ON_ACCOUNT");
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
LocalDateTime.now(), "CASH", "CASH-1", List.of(1L)))
.isInstanceOf(BizException.class).hasMessageContaining("账期应付不得");
}
@Test
void directPaidRequiresTimeMethodAndReferenceOrVoucher() {
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "BANK_TRANSFER", null, null))
.isInstanceOf(BizException.class).hasMessageContaining("流水号或至少一个付款凭证");
assertThat(validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "BANK_TRANSFER", "BANK-001", null))
.extracting(FeePaymentSnapshotValidator.Snapshot::paymentReferenceNo)
.isEqualTo("BANK-001");
}
@Test
void directPaidVoucherIsValidatedAgainstCurrentCustomerAndFileType() {
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
new FeePaymentVoucherFileClient.Metadata(901L, 9L, "FINANCE_VOUCHER", "9",
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
var result = validator.validate(9L, "PAYABLE", "DIRECT_PAID",
paidAt, "ONLINE", null, List.of(901L, 901L));
assertThat(result.paymentVoucherFileIds()).containsExactly(901L);
verify(files).metadata("file-token", 9L, 9L, 901L);
}
@Test
void fileMismatchFailsClosed() {
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
new FeePaymentVoucherFileClient.Metadata(901L, 10L, "FINANCE_VOUCHER", "10",
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
LocalDateTime.now(), "CASH", null, List.of(901L)))
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前客户");
}
}
@@ -84,6 +84,7 @@ class FinanceFeeSourceSnapshotServiceTest {
detail.setSupplierId(71L);
detail.setSupplierName("现有供应商");
detail.setSupplierCategory("TRUCKING");
detail.setSettlementMode("ON_ACCOUNT");
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
detail.setAmount(new BigDecimal("300.00"));
detail.setCurrency("CNY");
@@ -96,6 +97,8 @@ class FinanceFeeSourceSnapshotServiceTest {
assertThat(snapshot.supplierId()).isEqualTo(71L);
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
assertThat(snapshot.settlementMode()).isEqualTo("ON_ACCOUNT");
assertThat(snapshot.paidAt()).isNull();
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
}
}