This commit is contained in:
2026-08-19 09:14:30 +08:00
parent 8845a66749
commit ff1d330c2d
91 changed files with 1538 additions and 523 deletions
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照")
.contains("费用方向、费用项目、往来单位、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,6 +36,7 @@ class OceanBookingServiceTest {
@Mock private PriceBookQueryRepository priceBooks;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@@ -42,6 +44,7 @@ class OceanBookingServiceTest {
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules();
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, objectMapper);
@@ -60,6 +63,7 @@ class OceanBookingServiceTest {
new CreateRequest(0, partialDraft(null)));
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(result.oceanBooking().getBookingNo()).isEqualTo("OB-260818-0001");
assertThat(inserted.get().getCustomerId()).isNull();
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
verify(repository, never()).insertContainer(any());
@@ -22,6 +22,6 @@ class ContainerFeeDetailRepositoryTest {
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
"getSettlementIncluded", "getRemark");
"getRemark");
}
}
@@ -32,7 +32,7 @@ class FinanceFeeSourceMigrationContractTest {
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
"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",
"settlement_included", "source_version", "partner_id", "partner_name",
"source_version", "partner_id", "partner_name",
"firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整",
@@ -31,6 +31,7 @@ class SmartLoadingSqlContractTest {
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_document_sequence")
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
@@ -59,7 +59,6 @@ class ContainerFeeDetailServiceTest {
approved.setTenantId(10001L);
approved.setLoadingPlanId(41L);
approved.setSourceVersion(1);
approved.setSettlementIncluded(true);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
@@ -92,7 +91,6 @@ class ContainerFeeDetailServiceTest {
assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getPartnerId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getSettlementIncluded()).isTrue();
assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong());
}
@@ -170,7 +168,7 @@ class ContainerFeeDetailServiceTest {
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, null, null, "整柜拖车费");
null, null, "整柜拖车费");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位");
@@ -184,7 +182,7 @@ class ContainerFeeDetailServiceTest {
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, 71L, "不应保留", "整柜操作费");
71L, "不应保留", "整柜操作费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
@@ -225,7 +223,6 @@ class ContainerFeeDetailServiceTest {
value.setPartnerId(71L);
value.setPartnerName("现有往来单位");
value.setCurrency("CNY");
value.setSettlementIncluded(true);
value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11));
value.setAmount(new BigDecimal("300.00"));
@@ -239,9 +236,9 @@ class ContainerFeeDetailServiceTest {
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
settlementIncluded, 71L, " 现有往来单位 ", "整柜拖车费");
71L, " 现有往来单位 ", "整柜拖车费");
}
}
@@ -54,11 +54,14 @@ class ContainerServiceTest {
@MockBean private PriceBookQueryRepository priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
@@ -77,8 +80,8 @@ class ContainerServiceTest {
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).startsWith("CAB-");
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelName()).isEqualTo("美西海运");
@@ -220,7 +220,6 @@ class FeeManagementServiceTest {
assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198");
assertThat(saved.getReviewStatus()).isEqualTo("APPROVED");
assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED");
assertThat(saved.getSettlementIncluded()).isTrue();
}
@Test
@@ -324,16 +323,24 @@ class FeeManagementServiceTest {
@Test
@DisplayName("审核只更新待审核明细")
void approvesPendingDetail() {
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(new InboundFeeDetailEntity());
InboundFeeDetailEntity pending = new InboundFeeDetailEntity();
pending.setId(31L);
pending.setTenantId(10001L);
pending.setInboundId(21L);
pending.setSourceType("MANUAL");
pending.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
@DisplayName("审核通过的人工费用自动写入Finance事件")
void publishesApprovedSettlementFee() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
@@ -342,7 +349,6 @@ class FeeManagementServiceTest {
approved.setFeeItemId(11L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
@@ -409,7 +415,6 @@ class FeeManagementServiceTest {
current.setFeeItemId(11L);
current.setSourceType("MANUAL");
current.setSourceFeeVersion(1);
current.setSettlementIncluded(true);
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
voided.setId(31L);
voided.setTenantId(10001L);
@@ -417,7 +422,6 @@ class FeeManagementServiceTest {
voided.setFeeItemId(11L);
voided.setSourceType("MANUAL");
voided.setSourceFeeVersion(1);
voided.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
@@ -3,10 +3,10 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
@@ -19,7 +19,7 @@ import static org.mockito.Mockito.when;
class FinanceFeeSourceSnapshotServiceTest {
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
private final ContainerFeeSourceSnapshotRepository containerFees = mock(ContainerFeeSourceSnapshotRepository.class);
private final FinanceFeeSourceSnapshotService service =
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
@@ -37,8 +37,7 @@ class FinanceFeeSourceSnapshotServiceTest {
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setPublicationStatus("PUBLISHED");
detail.setSettlementIncluded(true);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 2)).thenReturn(detail);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setOrderId(81L);
@@ -50,53 +49,53 @@ class FinanceFeeSourceSnapshotServiceTest {
item.setFeeCode("STORAGE");
when(inboundFees.findItem(11L)).thenReturn(item);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L, 2);
assertThat(snapshot.customerId()).isEqualTo(10001L);
assertThat(snapshot.orderId()).isEqualTo(81L);
assertThat(snapshot.feeVersion()).isEqualTo(2);
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
detail.setPublicationStatus("VOIDED");
assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash());
assertThat(service.inbound(21L, 31L, 2).sourceHash()).isEqualTo(snapshot.sourceHash());
}
@Test
void rejectsFeeThatDoesNotBelongToInboundPath() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setInboundId(22L);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 1)).thenReturn(detail);
assertThatThrownBy(() -> service.inbound(21L, 31L))
assertThatThrownBy(() -> service.inbound(21L, 31L, 1))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
@Test
void containerSnapshotPublishesPayableCounterpartyIdentity() {
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
detail.setId(51L);
ContainerFeeSourceSnapshotEntity detail = new ContainerFeeSourceSnapshotEntity();
detail.setSourceFeeId(51L);
detail.setTenantId(10001L);
detail.setLoadingPlanId(41L);
detail.setContainerBookingId(61L);
detail.setContainerNo("CONT-41");
detail.setFeeItemId(11L);
detail.setFeeItemCodeSnapshot("TRUCKING");
detail.setFeeItemNameSnapshot("拖车费");
detail.setFeeItemCode("TRUCKING");
detail.setFeeItemName("拖车费");
detail.setDirection("PAYABLE");
detail.setPartnerId(71L);
detail.setPartnerName("现有往来单位");
detail.setSupplierId(71L);
detail.setSupplierName("现有供应商");
detail.setSupplierCategory("TRUCKING");
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
detail.setAmount(new BigDecimal("300.00"));
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setSourceStatus("ACTIVE");
detail.setSourceVersion(2);
detail.setSettlementIncluded(true);
when(containerFees.find(41L, 51L)).thenReturn(detail);
when(containerFees.find(41L, 51L, 2)).thenReturn(detail);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L, 2);
assertThat(snapshot.partnerId()).isEqualTo(71L);
assertThat(snapshot.partnerName()).isEqualTo("现有往来单位");
assertThat(snapshot.supplierId()).isEqualTo(71L);
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FirstlegDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void formatsAllOperationalDocumentPrefixes() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(1L, 1L, 1L, 1L, 1L);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001");
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
eq("OB:20260818"));
}
@Test
void rejectsUnsupportedTypeAndDailyOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class);
}
}
@@ -63,6 +63,7 @@ class SmartLoadingServiceTest {
@MockBean private LoadingPickService loadingPickService;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService service;
@TestConfiguration
@@ -73,6 +74,7 @@ class SmartLoadingServiceTest {
@BeforeEach
void setUp() {
when(documentNumbers.next("LP")).thenReturn("LP-260818-0001");
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
@@ -174,6 +176,22 @@ class SmartLoadingServiceTest {
verify(repository, never()).insert(any(LoadingPlanEntity.class));
}
@Test
void createsPlanWithShortStableNumber() {
when(repository.booking(99L)).thenReturn(booking());
when(repository.planByBooking(99L)).thenReturn(null);
doAnswer(invocation -> {
LoadingPlanEntity value = invocation.getArgument(0);
value.setId(1L);
return 1;
}).when(repository).insert(any(LoadingPlanEntity.class));
when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u");
assertThat(result.planNo()).isEqualTo("LP-260818-0001");
}
@Test
void rejectsExcessBoxes() {
LoadingPlanEntity plan = plan("EDITING", 0);
@@ -47,11 +47,13 @@ class TruckAppointmentServiceTest {
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Mock FirstlegDocumentNumberGenerator documentNumbers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
service.internalToken = "test-internal-token";
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
"Canonical Customer", "ACTIVE", null, null));
@@ -107,6 +109,7 @@ class TruckAppointmentServiceTest {
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}