diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java index 00c9b817e..e9947a9e3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java @@ -78,6 +78,16 @@ public class SailingCalendarRepository { sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getWarehouseCutoffAt()); } public SailingEntity sailing(long id) { return sailings.selectById(id); } + public SailingEntity sailingByBusinessKey(long warehouseId, String channelCode, String departurePortCode, + String destinationPortCode, LocalDateTime etdAt, String voyageNo) { + return sailings.selectOne(new LambdaQueryWrapper() + .eq(SailingEntity::getWarehouseId, warehouseId) + .eq(SailingEntity::getChannelCodeSnapshot, channelCode) + .eq(SailingEntity::getDeparturePortCode, departurePortCode) + .eq(SailingEntity::getDestinationPortCode, destinationPortCode) + .eq(SailingEntity::getEtdAt, etdAt) + .eq(SailingEntity::getVoyageNo, voyageNo)); + } public SailingEntity sailingByGeneratedKey(String key) { return sailings.selectOne(new LambdaQueryWrapper().eq(SailingEntity::getGeneratedKey, key)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java index cda06b6f8..78681acad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java @@ -299,8 +299,23 @@ public class SailingCalendarService { } @Transactional public SailingDetail copy(long id, long operatorId, String operatorName, CopyRequest request) { - SailingEntity source = requiredSailing(id); if (request.etdAt().isBefore(LocalDateTime.now(ZONE))) throw bad("复制船期不能早于当前时间"); - SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(source.getEtaAt()); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "COPY", null, OPEN, "复制自" + source.getSailingNo(), operatorId, operatorName, value.getCreatedAt()); return detail(value.getId()); + SailingEntity source = requiredSailing(id); + if (request.etdAt().isBefore(LocalDateTime.now(ZONE))) throw bad("复制船期不能早于当前时间"); + if (!request.cutoffAt().isBefore(request.etdAt())) throw bad("收货截止必须早于开船时间"); + String copyReason = "复制自" + source.getSailingNo(); + SailingEntity existing = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(), + source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo()); + if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId()) + : throwCopyConflict(request.etdAt(), source.getVoyageNo()); + + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); + try { repository.insert(value); } catch (DuplicateKeyException ex) { + SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(), + source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo()); + if (duplicate != null && isCopyOf(duplicate, copyReason)) return detail(duplicate.getId()); + throwCopyConflict(request.etdAt(), source.getVoyageNo()); + } + recordHistory(value.getId(), "COPY", null, OPEN, copyReason, operatorId, operatorName, value.getCreatedAt()); return detail(value.getId()); } public VolumeDetail volumeSummary(long id) { SailingEntity value = requiredSailing(id); @@ -459,6 +474,17 @@ public class SailingCalendarService { private SailingChannelEntity requiredChannel(long id) { SailingChannelEntity v = repository.channel(id); if (v == null) throw notFound("渠道不存在"); return v; } private SailingTemplateEntity requiredTemplate(long id) { SailingTemplateEntity v = repository.template(id); if (v == null) throw notFound("船期模板不存在"); return v; } private SailingEntity requiredSailing(long id) { SailingEntity v = repository.sailing(id); if (v == null) throw notFound("船期不存在"); return v; } + private boolean isCopyOf(SailingEntity value, String reason) { + return "COPY".equals(value.getSourceType()) && repository.histories(value.getId()).stream() + .anyMatch(history -> "COPY".equals(history.getAction()) && Objects.equals(reason, history.getReason())); + } + private static LocalDateTime copyEta(SailingEntity source, LocalDateTime etdAt) { + if (source.getEtdAt() == null || source.getEtaAt() == null) return null; + return etdAt.plus(ChronoUnit.MILLIS.between(source.getEtdAt(), source.getEtaAt()), ChronoUnit.MILLIS); + } + private static SailingDetail throwCopyConflict(LocalDateTime etdAt, String voyageNo) { + throw conflict("目标开船时间 " + etdAt + " 已存在航次 " + voyageNo + ",请勿重复复制"); + } private static LocalDateTime cutoff(SailingTemplateEntity t, LocalDateTime etd) { return etd.toLocalDate().minusDays(t.getCutoffDays()).atTime(t.getCutoffLocalTime()); } private static String voyage(SailingTemplateEntity t, LocalDate date) { return StringUtils.hasText(t.getVoyageRule()) ? t.getVoyageRule().replace("{date}", date.toString().replace("-", "")) : "V" + date.toString().replace("-", ""); } private static String key(SailingTemplateEntity t, LocalDateTime etd, String voyage) { return t.getId() + "|" + etd + "|" + voyage; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java new file mode 100644 index 000000000..1c7a8635a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.sailingcalendar.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity; +import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.math.BigDecimal; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SailingCalendarServiceTest { + @Mock private SailingCalendarRepository repository; + @InjectMocks private SailingCalendarService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void replaysAnExistingCopyInsteadOfReturningAnInternalError() { + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); + SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0)); + SailingHistoryEntity history = new SailingHistoryEntity(); + history.setAction("COPY"); history.setReason("复制自SL-SOURCE"); history.setSailingId(2L); + when(repository.sailing(1L)).thenReturn(source); + when(repository.sailing(2L)).thenReturn(existing); + when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing); + when(repository.histories(2L)).thenReturn(List.of(history)); + when(repository.receiptSummary(any(SailingEntity.class))).thenReturn(null); + when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null); + + var result = service.copy(1L, 7L, "admin", + new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0))); + + assertThat(result.sailing().id()).isEqualTo(2L); + verify(repository, never()).insert(any(SailingEntity.class)); + } + + @Test + void reportsBusinessConflictForAnUnrelatedExistingSailing() { + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); + SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0)); + when(repository.sailing(1L)).thenReturn(source); + when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing); + + assertThatThrownBy(() -> service.copy(1L, 7L, "admin", + new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已存在航次 QA-20261005"); + verify(repository, never()).insert(any(SailingEntity.class)); + } + + @Test + void rejectsCutoffAtOrAfterCopiedDeparture() { + SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0)); + when(repository.sailing(1L)).thenReturn(source); + + assertThatThrownBy(() -> service.copy(1L, 7L, "admin", + new CopyRequest(LocalDateTime.of(2026, 10, 12, 10, 0), LocalDateTime.of(2026, 10, 12, 10, 0)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("收货截止必须早于开船"); + verify(repository, never()).insert(any(SailingEntity.class)); + } + + private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) { + SailingEntity value = new SailingEntity(); + value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo); + value.setWarehouseId(8L); value.setChannelCodeSnapshot("MATSON"); value.setChannelNameSnapshot("美森正班"); + value.setDeparturePortCode("CNNGB"); value.setDestinationPortCode("USLGB"); value.setVesselName("MATSON"); + value.setEtdAt(etdAt); value.setWarehouseCutoffAt(etdAt.minusDays(3).withHour(18)); value.setStatus("OPEN"); + value.setVersion(0); value.setPlannedCapacityCbm(BigDecimal.ZERO); + return value; + } +}