优化
This commit is contained in:
+19
-8
@@ -41,14 +41,25 @@ class FeeManagementContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("柜次费用新增和调整请求均携带往来单位快照")
|
||||
void containerFeeRequestsCarryCounterpartySnapshot() {
|
||||
for (Class<?> requestType : List.of(
|
||||
com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest.class,
|
||||
com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class)) {
|
||||
assertThat(Arrays.stream(requestType.getRecordComponents()).map(RecordComponent::getName))
|
||||
.contains("partnerId", "partnerName");
|
||||
}
|
||||
@DisplayName("柜次费用调整只能修改版本、业务日期和金额")
|
||||
void containerFeeAdjustmentCannotReplaceSourceIdentity() {
|
||||
assertThat(Arrays.stream(com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class
|
||||
.getRecordComponents()).map(RecordComponent::getName))
|
||||
.containsExactly("expectedSourceVersion", "businessDate", "amount");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("柜次费用调整OpenAPI冻结三字段请求和快照不漂移语义")
|
||||
void containerFeeAdjustmentOpenApiIsNarrowAndVersioned() throws Exception {
|
||||
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(source)
|
||||
.contains("/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:")
|
||||
.contains("ContainerFeeAdjustRequest:")
|
||||
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
||||
.contains("additionalProperties: false")
|
||||
.contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照")
|
||||
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerFeeDetailRepositoryTest {
|
||||
@Test
|
||||
void adjustmentUpdateOnlyTouchesAmountDateVersionAndReviewProjection() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java"),
|
||||
StandardCharsets.UTF_8);
|
||||
String method = source.substring(source.indexOf("public int adjust("),
|
||||
source.indexOf("public int voidApproved("));
|
||||
|
||||
assertThat(method).contains("getBusinessDate", "getAmount", "getSourceVersion",
|
||||
"getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt",
|
||||
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
|
||||
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
|
||||
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
|
||||
"getSettlementIncluded", "getRemark");
|
||||
}
|
||||
}
|
||||
+26
@@ -75,6 +75,22 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
|
||||
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
|
||||
String mapper = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8);
|
||||
assertThat(mapper)
|
||||
.contains("UPDATE fl_loading_plan_item i")
|
||||
.contains("p.version=#{touchedPlanVersion}")
|
||||
.contains("p.plan_status IN ('EDITING','LOADING')")
|
||||
.contains("i.frozen_at IS NULL")
|
||||
.doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p")
|
||||
.doesNotContain("p.version=p.version+1");
|
||||
assertWarehouseRemarkTriggerContract(migration());
|
||||
assertWarehouseRemarkTriggerContract(unifiedMigration());
|
||||
}
|
||||
|
||||
private static void assertContainerLifecycleSchema(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'")
|
||||
@@ -89,6 +105,16 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("column_name='merged_into_container_booking_id'");
|
||||
}
|
||||
|
||||
private static void assertWarehouseRemarkTriggerContract(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN")
|
||||
.contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted")
|
||||
.contains("已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段")
|
||||
.contains("ELSEIF NOT EXISTS (")
|
||||
.contains("IF OLD.frozen_at IS NOT NULL THEN")
|
||||
.contains("装柜完成后的明细永久冻结");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
|
||||
void shouldProvideSimpleCustomsDocuments() throws Exception {
|
||||
|
||||
+115
-15
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
|
||||
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
||||
import com.baiyue.firstleg.entity.FeeItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
@@ -20,6 +21,8 @@ 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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -69,24 +72,95 @@ class ContainerFeeDetailServiceTest {
|
||||
@Test
|
||||
void adjustmentIncrementsSourceVersionAndReturnsToReview() {
|
||||
ownedPlan();
|
||||
FeeItemEntity item = item();
|
||||
when(feeItems.findItem(11L)).thenReturn(item);
|
||||
ContainerFeeDetailEntity current = new ContainerFeeDetailEntity();
|
||||
current.setId(51L);
|
||||
current.setTenantId(10001L);
|
||||
current.setLoadingPlanId(41L);
|
||||
current.setSourceVersion(1);
|
||||
ContainerFeeDetailEntity current = approvedFee(1);
|
||||
ContainerFeeDetailEntity adjusted = approvedFee(2);
|
||||
adjusted.setBusinessDate(LocalDate.of(2026, 8, 12));
|
||||
adjusted.setAmount(new BigDecimal("325.00"));
|
||||
adjusted.setReviewStatus("PENDING");
|
||||
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
|
||||
when(repository.adjust(any(), eq(1))).thenReturn(1);
|
||||
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1),
|
||||
eq(LocalDate.of(2026, 8, 12)), eq(new BigDecimal("325.00")), eq(9L), any()))
|
||||
.thenReturn(1);
|
||||
when(repository.find(10001L, 41L, 51L)).thenReturn(current, adjusted);
|
||||
|
||||
service.adjust(10001L, 41L, 51L, 1, 9L, request(true));
|
||||
ContainerFeeDetailEntity result = service.adjust(10001L, 41L, 51L, 9L,
|
||||
adjustment(1, "325.00"));
|
||||
|
||||
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
|
||||
verify(repository).adjust(captor.capture(), eq(1));
|
||||
assertThat(captor.getValue().getSourceVersion()).isEqualTo(2);
|
||||
assertThat(captor.getValue().getReviewStatus()).isEqualTo("PENDING");
|
||||
assertThat(captor.getValue().getPartnerId()).isEqualTo(71L);
|
||||
assertThat(captor.getValue().getPartnerName()).isEqualTo("现有往来单位");
|
||||
assertThat(result.getSourceVersion()).isEqualTo(2);
|
||||
assertThat(result.getReviewStatus()).isEqualTo("PENDING");
|
||||
assertThat(result.getFeeItemId()).isEqualTo(11L);
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactAdjustmentReplayReturnsCurrentPendingVersionWithoutWriting() {
|
||||
ownedPlan();
|
||||
ContainerFeeDetailEntity replay = approvedFee(2);
|
||||
replay.setReviewStatus("PENDING");
|
||||
replay.setBusinessDate(LocalDate.of(2026, 8, 12));
|
||||
replay.setAmount(new BigDecimal("325.00"));
|
||||
when(repository.find(10001L, 41L, 51L)).thenReturn(replay);
|
||||
|
||||
assertThat(service.adjust(10001L, 41L, 51L, 9L, adjustment(1, "325.0")))
|
||||
.isSameAs(replay);
|
||||
|
||||
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
|
||||
anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleOrDifferentReplayIsRejectedWithoutWriting() {
|
||||
ownedPlan();
|
||||
ContainerFeeDetailEntity current = approvedFee(2);
|
||||
current.setReviewStatus("PENDING");
|
||||
current.setBusinessDate(LocalDate.of(2026, 8, 12));
|
||||
current.setAmount(new BigDecimal("325.00"));
|
||||
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
|
||||
|
||||
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
|
||||
adjustment(1, "326.00"))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("当前版本的已审核");
|
||||
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
|
||||
anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentVersionChangeReturnsConflictWithoutPublishing() {
|
||||
ownedPlan();
|
||||
when(repository.find(10001L, 41L, 51L)).thenReturn(approvedFee(1));
|
||||
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), any(), any(), eq(9L), any()))
|
||||
.thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
|
||||
adjustment(1, "325.00"))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("当前版本的已审核");
|
||||
|
||||
verify(outbox, never()).publishContainer(any(), anyLong(), anyLong(), anyLong(),
|
||||
anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidedOrUnapprovedFeeCannotBeAdjusted() {
|
||||
ownedPlan();
|
||||
ContainerFeeDetailEntity voided = approvedFee(1);
|
||||
voided.setSourceStatus("VOIDED");
|
||||
when(repository.find(10001L, 41L, 51L)).thenReturn(voided);
|
||||
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
|
||||
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
|
||||
|
||||
ContainerFeeDetailEntity pending = approvedFee(1);
|
||||
pending.setReviewStatus("PENDING");
|
||||
when(repository.find(10001L, 41L, 51L)).thenReturn(pending);
|
||||
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
|
||||
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
|
||||
|
||||
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
|
||||
anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -139,6 +213,32 @@ class ContainerFeeDetailServiceTest {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ContainerFeeDetailEntity approvedFee(int version) {
|
||||
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
|
||||
value.setId(51L);
|
||||
value.setTenantId(10001L);
|
||||
value.setLoadingPlanId(41L);
|
||||
value.setFeeItemId(11L);
|
||||
value.setFeeItemCodeSnapshot("TRUCKING");
|
||||
value.setFeeItemNameSnapshot("拖车费");
|
||||
value.setDirection("PAYABLE");
|
||||
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"));
|
||||
value.setSourceStatus("ACTIVE");
|
||||
value.setReviewStatus("APPROVED");
|
||||
value.setSourceVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static AdjustRequest adjustment(int version, String amount) {
|
||||
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
|
||||
}
|
||||
|
||||
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
|
||||
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
|
||||
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
|
||||
|
||||
@@ -29,11 +29,13 @@ import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -391,35 +393,92 @@ class SmartLoadingServiceTest {
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u")).thenReturn(1);
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(loadingPickService).lockMutable(1L);
|
||||
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u");
|
||||
var order = inOrder(loadingPickService, repository);
|
||||
order.verify(loadingPickService).lockMutable(1L);
|
||||
order.verify(repository).touch(1L, 2, 7L, "u");
|
||||
order.verify(repository).updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u");
|
||||
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK");
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesWarehouseRemarkWhilePlanIsEditing() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 1);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 1);
|
||||
when(repository.plan(1L)).thenReturn(plan, plan("EDITING", 2));
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
when(repository.touch(1L, 1, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("待核对外箱", 1, 0, "warehouse-editing"), 7L, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("EDITING");
|
||||
assertThat(result.version()).isEqualTo(2);
|
||||
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u");
|
||||
}
|
||||
|
||||
@Test
|
||||
void warehouseRemarkConflictProducesNoHistory() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 2);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 1);
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "新备注", 7L, "u")).thenReturn(0);
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u")).thenReturn(0);
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository).touch(1L, 2, 7L, "u");
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void warehouseRemarkMapsTriggerSqlStateToConflict() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 2);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 1);
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u"))
|
||||
.thenThrow(new DataIntegrityViolationException("trigger",
|
||||
new SQLException("状态冲突", "45000")));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error.getMessage()).contains("仓库备注未保存").contains("刷新后重试");
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePlanVersionRejectsWarehouseRemarkBeforeItemWrite() {
|
||||
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
|
||||
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
|
||||
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));
|
||||
|
||||
Reference in New Issue
Block a user