This commit is contained in:
2026-08-17 19:13:08 +08:00
parent 5199146e97
commit cddd23e4c7
40 changed files with 1203 additions and 71 deletions
@@ -51,14 +51,13 @@ class FbaPricingContractTest {
.contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
.contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域")
.contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("仅此动作将费用置为 SENT")
.contains("写入 Finance Outbox")
.contains("不回退 WAIT_SPLIT 状态")
.contains("expectedFeeVersion")
.contains("chargeableQuantity")
.contains("unitPrice")
.doesNotContain("required: [subtotalAmount, additionalAmount, reason]")
.doesNotContain("应收")
.doesNotContain("应付")
.doesNotContain("待客户确认");
String customerRules = openapi.substring(
openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"),
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.dto.FbaPricingDtos;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Field;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class InboundFeeOpenApiContractTest {
@Test
void documentsEveryInboundFeeControllerPathAndReadOnlySourceContract() throws Exception {
String source = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = map(new Yaml().load(source));
Map<String, Object> paths = map(document.get("paths"));
assertThat(paths).containsKeys(
"/api/platform/v1/inbounds/{inboundId}/fees/calculate",
"/api/platform/v1/inbounds/{inboundId}/fees/confirm",
"/api/platform/v1/inbounds/{inboundId}/fees/send",
"/api/platform/v1/inbounds/{inboundId}/fees/latest",
"/api/platform/v1/inbounds/{inboundId}/fees/published",
"/api/platform/v1/inbounds/{inboundId}/fee-details",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void");
assertThat(source).contains("includeHistory", "FBA_PRICING", "MANUAL", "只读派生",
"Finance Outbox", "CALCULATED", "CONFIRMED", "SENT", "SUPERSEDED");
}
@Test
void responseAndRequestSchemasTrackActualDtoAndEntityFields() throws Exception {
Map<String, Object> document = map(new Yaml().load(new ClassPathResource(
"openapi/fba-pricing-v1.yaml").getContentAsString(StandardCharsets.UTF_8)));
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
assertThat(properties(schemas, "FeeSnapshotResponse").keySet())
.containsExactlyInAnyOrder(recordFields(FbaPricingDtos.FeeSnapshotResponse.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailSaveRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.SaveRequest.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailVersionRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.VersionRequest.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailRejectRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.RejectRequest.class).toArray(String[]::new));
Set<String> entityFields = new LinkedHashSet<>();
for (Class<?> type = InboundFeeDetailEntity.class; type != Object.class; type = type.getSuperclass()) {
Arrays.stream(type.getDeclaredFields()).map(Field::getName).forEach(entityFields::add);
}
assertThat(properties(schemas, "InboundFeeDetailResource").keySet())
.containsExactlyInAnyOrder(entityFields.toArray(String[]::new));
}
private static Set<String> recordFields(Class<?> type) {
Set<String> fields = new LinkedHashSet<>();
Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).forEach(fields::add);
return fields;
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schema) {
return map(map(schemas.get(schema)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) {
return (Map<String, Object>) value;
}
}
@@ -22,6 +22,16 @@ class InboundOrderStatusSqlContractTest {
.contains("process_status = 'INBOUNDING'");
}
@Test
void keepsConfirmedWeightNetSumAuthoritativeWhenMeasurementIsApproved() {
assertThat(sql("approveMeasurement"))
.contains("FROM fl_inbound_weight_result weight")
.contains("weight.recognition_status = 'CONFIRMED'")
.contains("weight.deleted = 0")
.contains("SUM(weight.net_weight_kg)")
.contains("ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)");
}
private static String sql(String methodName) {
Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
@@ -223,6 +223,37 @@ class FeeManagementServiceTest {
assertThat(saved.getSettlementIncluded()).isTrue();
}
@Test
@DisplayName("查询费用明细会幂等补齐历史初算缺失的派生行")
void materializesMissingLatestCalculatedFreightOnList() {
InboundOrderEntity inbound = inbound();
inbound.setCustomerId(101L);
inbound.setCustomerName("客户A");
when(repository.findInbound(10001L, 21L)).thenReturn(inbound);
when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null);
when(repository.listDetails(10001L, 21L, false)).thenReturn(List.of());
FeeItemEntity freightItem = item(true, false);
freightItem.setFeeCode("FBA_HEAD_FREIGHT");
when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setId(501L); fee.setInboundId(21L); fee.setFeeNo("FBAF-21-2");
fee.setFeeVersion(2); fee.setFeeStatus("CALCULATED");
fee.setActualWeightKg(new BigDecimal("100.0000"));
fee.setActualVolumeCbm(new BigDecimal("1.000000"));
fee.setChargeableQuantity(new BigDecimal("167.0000"));
fee.setChargeUnit("KG"); fee.setCurrency("USD");
fee.setTotalAmount(new BigDecimal("354.00"));
fee.setCalculatedAt(LocalDateTime.of(2026, 8, 17, 12, 0));
when(pricingRepository.findLatestFee(10001L, 21L)).thenReturn(fee);
when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee);
details.list(10001L, 21L, false);
verify(repository).insertDetail(any(InboundFeeDetailEntity.class));
verify(repository).listDetails(10001L, 21L, false);
}
@Test
@DisplayName("系统头程运费项目缺失时拒绝运行期补建")
void rejectsMissingSystemFreightItem() {
@@ -321,6 +352,53 @@ class FeeManagementServiceTest {
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("自动头程运费派生明细不能在费用明细中二次审核")
void rejectsReviewingDerivedFreight() {
InboundFeeDetailEntity derived = new InboundFeeDetailEntity();
derived.setSourceType("FBA_PRICING");
derived.setReviewStatus("PENDING");
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(derived);
assertThatThrownBy(() -> details.approve(10001L, 21L, 31L, 9L, "admin"))
.isInstanceOf(BizException.class)
.hasMessageContaining("只读派生");
verify(repository, never()).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
}
@Test
@DisplayName("已审核人工费用调整保留旧版本并创建待审核新版本")
void versionsApprovedManualAdjustment() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setReviewStatus("APPROVED");
approved.setPublicationStatus("PUBLISHED");
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.findInbound(10001L, 21L)).thenReturn(inbound());
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.supersedeApprovedManual(eq(10001L), eq(21L), eq(31L),
eq(1), eq(9L), any(LocalDateTime.class))).thenReturn(1);
doAnswer(invocation -> {
((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L);
return null;
}).when(repository).insertDetail(any());
InboundFeeDetailEntity next = details.adjust(
10001L, 21L, 31L, 1, 9L, detailRequest("RECEIVABLE"));
assertThat(next.getId()).isEqualTo(32L);
assertThat(next.getSourceFeeId()).isEqualTo(31L);
assertThat(next.getSourceFeeVersion()).isEqualTo(2);
assertThat(next.getReviewStatus()).isEqualTo("PENDING");
assertThat(next.getPublicationStatus()).isEqualTo("DRAFT");
verify(repository).insertDetail(next);
}
@Test
@DisplayName("作废已入池人工费用引用当前版本并发布VOIDED")
void voidsPublishedSettlementFee() {
@@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.annotations.Select;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
@@ -48,6 +49,14 @@ class LoadingCompletedOutboxServiceTest {
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1");
}
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
assertThat(sql).contains("p.plan_status = 'COMPLETED'")
.contains("o.process_status = 'LOADED'");
}
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
@@ -176,6 +176,10 @@ class LoadingListServiceTest {
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
eq(9L), eq("operator"), eq("PDA已人工确认装柜完成"));
verify(loadingCompletedOutboxService, never()).publish(10L);
service.publishCompletedEvent(10L);
verify(loadingCompletedOutboxService).publish(10L);
}
@@ -523,6 +523,12 @@ class LoadingPickServiceTest {
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
InOrder finalization = inOrder(loadingListService, loadingDeliveryService);
finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@Test
@@ -548,6 +554,31 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), any());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"入库单并发修改"))
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-finalize-fails-50", "checked");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-finalize-fails", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test