fix(firstleg): make sailing copy idempotent

This commit is contained in:
2026-09-27 23:22:56 +08:00
parent 465d26ba42
commit cf76f7d6d3
3 changed files with 127 additions and 2 deletions
@@ -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));
}
@@ -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; }