This commit is contained in:
2026-08-19 09:42:07 +08:00
parent ff1d330c2d
commit b5864bfcda
56 changed files with 497 additions and 306 deletions
@@ -16,19 +16,19 @@ class InternalFinanceFeeSourceControllerTest {
@Test
void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() {
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L))
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L, 2))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L))
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L, 2))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
}
@Test
void routesAuthorizedSnapshotLookup() {
controller.inbound("finance-token", 21L, 31L);
controller.container("finance-token", 41L, 51L);
controller.inbound("finance-token", 21L, 31L, 2);
controller.container("finance-token", 41L, 51L, 3);
verify(service).inbound(21L, 31L);
verify(service).container(41L, 51L);
verify(service).inbound(21L, 31L, 2);
verify(service).container(41L, 51L, 3);
}
@Test
@@ -30,7 +30,7 @@ class TruckAppointmentControllerContractTest {
}
}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null");}
@Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
@@ -21,7 +21,7 @@ class ContainerFeeDetailRepositoryTest {
"getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt",
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
"getDirection", "getSupplierId", "getSupplierName", "getSupplierCategory", "getCurrency",
"getRemark");
}
}
@@ -9,19 +9,21 @@ import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest {
@Test
void freshSchemaAndMigrationExposeInboundFeePartnerSnapshotsUsedByAllFeeFlows() throws Exception {
void freshSchemaAndMigrationExposeInboundFeeSupplierSnapshotsUsedByAllFeeFlows() throws Exception {
String baseline = new ClassPathResource("db/phase2-fee-item-and-inbound-detail.sql")
.getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/finance-fee-source-migration.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(baseline).contains(
"partner_id BIGINT UNSIGNED NULL",
"partner_name VARCHAR(200) NULL");
"supplier_id BIGINT UNSIGNED NULL",
"supplier_name VARCHAR(200) NULL",
"supplier_category VARCHAR(16) NULL");
assertThat(migration).contains(
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name'",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整");
"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迁移失败:入库费用供应商快照字段不完整");
}
@Test
@@ -32,10 +34,12 @@ 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",
"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迁移失败:入库费用往来单位快照字段不完整",
"source_version", "supplier_id", "supplier_name", "supplier_category",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
}
}
@@ -152,8 +152,9 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name'");
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'");
assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
@@ -8,6 +8,7 @@ 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;
@@ -34,8 +35,10 @@ class ContainerFeeDetailServiceTest {
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 ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox);
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots);
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
@@ -58,8 +61,13 @@ class ContainerFeeDetailServiceTest {
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");
@@ -89,7 +97,7 @@ class ContainerFeeDetailServiceTest {
assertThat(result.getReviewStatus()).isEqualTo("PENDING");
assertThat(result.getFeeItemId()).isEqualTo(11L);
assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getPartnerId()).isEqualTo(71L);
assertThat(result.getSupplierId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong());
@@ -162,16 +170,16 @@ class ContainerFeeDetailServiceTest {
}
@Test
void payableRequiresCounterpartySnapshot() {
void payableRequiresSupplierSnapshot() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
var missingSupplier = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
null, null, "整柜拖车费");
null, "整柜拖车费");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingSupplier))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效供应商");
verify(repository, never()).insert(any());
}
@@ -182,13 +190,13 @@ class ContainerFeeDetailServiceTest {
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
71L, "不应保留", "整柜操作费");
71L, "整柜操作费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getPartnerId()).isNull();
assertThat(captor.getValue().getPartnerName()).isNull();
assertThat(captor.getValue().getSupplierId()).isNull();
assertThat(captor.getValue().getSupplierName()).isNull();
}
private void ownedPlan() {
@@ -220,8 +228,8 @@ class ContainerFeeDetailServiceTest {
value.setFeeItemCodeSnapshot("TRUCKING");
value.setFeeItemNameSnapshot("拖车费");
value.setDirection("PAYABLE");
value.setPartnerId(71L);
value.setPartnerName("现有往来单位");
value.setSupplierId(71L);
value.setSupplierName("现有供应商");
value.setCurrency("CNY");
value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11));
@@ -239,6 +247,6 @@ class ContainerFeeDetailServiceTest {
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
71L, " 现有往来单位 ", "整柜拖车费");
71L, "整柜拖车费");
}
}
@@ -104,30 +104,30 @@ class ContainerServiceTest {
}
@Test
@DisplayName("智能装柜草稿阶段允许完整编辑")
@DisplayName("智能装柜草稿阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileEditing() {
ContainerBookingEntity current = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")));
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜-NEW");
assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("待装柜阶段允许完整编辑")
@DisplayName("待装柜阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileWaitingForLoading() {
ContainerBookingEntity current = booking("WAIT_LOADING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜 NEW")));
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜 NEW");
assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@@ -137,7 +137,7 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(booking("LOADING", 2));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class)
.hasMessageContaining("只能修改集装箱号和航次");
@@ -240,7 +240,7 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(booking("EDITING", 3));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
}
@@ -394,8 +394,8 @@ class ContainerServiceTest {
return value;
}
private static SaveRequest request(String cabinetNo) {
return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮",
private static SaveRequest request() {
return new SaveRequest("MSCU1234567", 101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
@@ -43,6 +43,8 @@ class FeeManagementServiceTest {
private FbaPricingRepository pricingRepository;
@MockBean
private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private WarehouseSupplierSnapshotService suppliers;
@Resource
private FeeItemService feeItems;
@Resource
@@ -96,6 +98,8 @@ class FeeManagementServiceTest {
assertThat(created.getId()).isEqualTo(11L);
assertThat(created.getAllocationFee()).isTrue();
assertThat(created.getWarehouseReceivable()).isTrue();
assertThat(created.getWarehousePayable()).isTrue();
assertThat(created.getSupplierCategory()).isNull();
assertThat(created.getFeeCode()).startsWith("MANUAL_");
assertThat(created.getTenantId()).isZero();
assertThat(created.getCreatedByName()).isEqualTo("admin");
@@ -327,9 +331,13 @@ class FeeManagementServiceTest {
pending.setId(31L);
pending.setTenantId(10001L);
pending.setInboundId(21L);
pending.setFeeItemId(11L);
pending.setDirection("RECEIVABLE");
pending.setSourceType("MANUAL");
pending.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending);
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.updateDetail(pending)).thenReturn(1);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
@@ -347,9 +355,12 @@ class FeeManagementServiceTest {
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setFeeItemId(11L);
approved.setDirection("RECEIVABLE");
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.updateDetail(approved)).thenReturn(1);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
@@ -59,6 +59,7 @@ class TruckAppointmentServiceTest {
"Canonical Customer", "ACTIVE", null, null));
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE"));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
}
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
@@ -100,7 +101,7 @@ class TruckAppointmentServiceTest {
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
service.create(7L, "admin", request);
TruckAppointmentEntity created = service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
@@ -110,6 +111,7 @@ class TruckAppointmentServiceTest {
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
assertThat(created.getAttachmentCount()).isZero();
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}
@@ -42,6 +42,16 @@ class WarehouseSupplierSnapshotServiceTest {
verify(client).get("internal-token", 7L, true, "BOOKING");
}
@Test
void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() {
when(client.get("internal-token", 8L, true, null))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE")));
assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 8L, true, null);
}
@Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS")))