fix(firstleg): make sailing copy idempotent
This commit is contained in:
+10
@@ -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<SailingEntity>()
|
||||
.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<SailingEntity>().eq(SailingEntity::getGeneratedKey, key));
|
||||
}
|
||||
|
||||
+28
-2
@@ -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; }
|
||||
|
||||
+89
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user