баг-фикс 30/34
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Использует X-Forwarded-For только когда запрос действительно пришёл от доверенного proxy.
|
||||
*/
|
||||
@Component
|
||||
public class ClientIpResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClientIpResolver.class);
|
||||
private static final int MAX_FORWARDED_HEADER_LENGTH = 2_048;
|
||||
private static final int MAX_PROXY_HOPS = 20;
|
||||
private static final String UNKNOWN_ADDRESS = "неизвестен";
|
||||
|
||||
private final List<CidrRange> trustedProxyRanges;
|
||||
|
||||
public ClientIpResolver(@Value("${app.http.trusted-proxy-cidrs:}") String trustedProxyCidrs) {
|
||||
this.trustedProxyRanges = parseTrustedRanges(trustedProxyCidrs);
|
||||
}
|
||||
|
||||
public String resolve(HttpServletRequest request) {
|
||||
Optional<InetAddress> remoteAddress = parseAddress(request.getRemoteAddr());
|
||||
String fallback = remoteAddress.map(InetAddress::getHostAddress).orElse(UNKNOWN_ADDRESS);
|
||||
if (remoteAddress.isEmpty() || !isTrusted(remoteAddress.get())) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded == null || forwarded.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
if (forwarded.length() > MAX_FORWARDED_HEADER_LENGTH) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: превышена допустимая длина");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String[] hops = forwarded.split(",", -1);
|
||||
if (hops.length > MAX_PROXY_HOPS) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: слишком длинная цепочка proxy");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
InetAddress leftmost = null;
|
||||
for (int index = hops.length - 1; index >= 0; index--) {
|
||||
Optional<InetAddress> hop = parseAddress(hops[index].trim());
|
||||
if (hop.isEmpty()) {
|
||||
log.debug("Заголовок X-Forwarded-For отклонён: обнаружен некорректный IP-адрес");
|
||||
return fallback;
|
||||
}
|
||||
leftmost = hop.get();
|
||||
if (!isTrusted(hop.get())) {
|
||||
return hop.get().getHostAddress();
|
||||
}
|
||||
}
|
||||
return leftmost == null ? fallback : leftmost.getHostAddress();
|
||||
}
|
||||
|
||||
private boolean isTrusted(InetAddress address) {
|
||||
return trustedProxyRanges.stream().anyMatch(range -> range.contains(address));
|
||||
}
|
||||
|
||||
private List<CidrRange> parseTrustedRanges(String rawRanges) {
|
||||
if (rawRanges == null || rawRanges.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CidrRange> ranges = new ArrayList<>();
|
||||
for (String rawRange : rawRanges.split(",")) {
|
||||
String value = rawRange.trim();
|
||||
if (!value.isEmpty()) {
|
||||
ranges.add(CidrRange.parse(value));
|
||||
}
|
||||
}
|
||||
return List.copyOf(ranges);
|
||||
}
|
||||
|
||||
private static Optional<InetAddress> parseAddress(String rawAddress) {
|
||||
if (rawAddress == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = rawAddress.trim().toLowerCase(Locale.ROOT);
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
if (value.isEmpty() || !value.matches("[0-9a-f:.]+")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(InetAddress.getByName(value));
|
||||
} catch (UnknownHostException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private record CidrRange(byte[] network, int prefixLength) {
|
||||
|
||||
static CidrRange parse(String value) {
|
||||
String[] parts = value.split("/", -1);
|
||||
Optional<InetAddress> address = ClientIpResolver.parseAddress(parts[0]);
|
||||
if (address.isEmpty()) {
|
||||
throw new IllegalStateException("Некорректный адрес доверенного proxy: " + value);
|
||||
}
|
||||
int addressBits = address.get().getAddress().length * 8;
|
||||
int prefix;
|
||||
try {
|
||||
prefix = parts.length == 1 ? addressBits : Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value, exception);
|
||||
}
|
||||
if (parts.length > 2 || prefix < 0 || prefix > addressBits) {
|
||||
throw new IllegalStateException("Некорректная маска доверенного proxy: " + value);
|
||||
}
|
||||
return new CidrRange(mask(address.get().getAddress(), prefix), prefix);
|
||||
}
|
||||
|
||||
boolean contains(InetAddress candidate) {
|
||||
byte[] candidateBytes = candidate.getAddress();
|
||||
return candidateBytes.length == network.length
|
||||
&& Arrays.equals(mask(candidateBytes, prefixLength), network);
|
||||
}
|
||||
|
||||
private static byte[] mask(byte[] address, int prefix) {
|
||||
byte[] masked = Arrays.copyOf(address, address.length);
|
||||
int fullBytes = prefix / 8;
|
||||
int remainingBits = prefix % 8;
|
||||
if (fullBytes < masked.length && remainingBits > 0) {
|
||||
masked[fullBytes] &= (byte) (0xFF << (8 - remainingBits));
|
||||
fullBytes++;
|
||||
}
|
||||
Arrays.fill(masked, fullBytes, masked.length, (byte) 0);
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Очищает старый аудит входа и неактивные счётчики отдельно в каждой tenant-БД. */
|
||||
@Component
|
||||
public class LoginAttemptAuditCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginAttemptAuditCleanupJob.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final AuthLoginAttemptAuditRepository auditRepository;
|
||||
private final AuthLoginRateLimitRepository rateLimitRepository;
|
||||
private final boolean enabled;
|
||||
private final Duration retention;
|
||||
private final int batchSize;
|
||||
private final int maxBatchesPerTenant;
|
||||
private final Clock clock;
|
||||
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public LoginAttemptAuditCleanupJob(
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
AuthLoginRateLimitRepository rateLimitRepository,
|
||||
@Value("${app.login-security.audit-cleanup.enabled:true}") boolean enabled,
|
||||
@Value("${app.login-security.audit-cleanup.retention:90d}") Duration retention,
|
||||
@Value("${app.login-security.audit-cleanup.batch-size:500}") int batchSize,
|
||||
@Value("${app.login-security.audit-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
|
||||
this(routingDataSource, auditRepository, rateLimitRepository, enabled, retention,
|
||||
batchSize, maxBatchesPerTenant, Clock.systemUTC());
|
||||
}
|
||||
|
||||
LoginAttemptAuditCleanupJob(TenantRoutingDataSource routingDataSource,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
AuthLoginRateLimitRepository rateLimitRepository,
|
||||
boolean enabled,
|
||||
Duration retention,
|
||||
int batchSize,
|
||||
int maxBatchesPerTenant,
|
||||
Clock clock) {
|
||||
this.routingDataSource = Objects.requireNonNull(routingDataSource);
|
||||
this.auditRepository = Objects.requireNonNull(auditRepository);
|
||||
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
throw new IllegalArgumentException("Срок хранения аудита входа должен быть положительным");
|
||||
}
|
||||
if (batchSize < 1 || batchSize > 10_000) {
|
||||
throw new IllegalArgumentException("Размер пачки очистки аудита должен быть от 1 до 10000");
|
||||
}
|
||||
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
|
||||
throw new IllegalArgumentException("Число пачек очистки аудита должно быть от 1 до 1000");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.retention = retention;
|
||||
this.batchSize = batchSize;
|
||||
this.maxBatchesPerTenant = maxBatchesPerTenant;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.login-security.audit-cleanup.interval-ms:86400000}",
|
||||
initialDelayString = "${app.login-security.audit-cleanup.initial-delay-ms:120000}"
|
||||
)
|
||||
public void cleanupScheduled() {
|
||||
if (enabled) {
|
||||
cleanupNow();
|
||||
}
|
||||
}
|
||||
|
||||
public CleanupSummary cleanupNow() {
|
||||
if (!cleanupInProgress.compareAndSet(false, true)) {
|
||||
return new CleanupSummary(0, 0, 0, 0, true);
|
||||
}
|
||||
String previousTenant = TenantContext.getCurrentTenant();
|
||||
try {
|
||||
TenantContext.clear();
|
||||
Map<String, ?> tenants = routingDataSource.snapshotTenantConfigs();
|
||||
Instant now = clock.instant();
|
||||
Instant cutoff = now.minus(retention);
|
||||
int deletedAudits = 0;
|
||||
int deletedLimits = 0;
|
||||
int failedTenants = 0;
|
||||
for (String tenant : tenants.keySet()) {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
try {
|
||||
deletedAudits += cleanupAudit(tenant, cutoff);
|
||||
deletedLimits += cleanupLimits(tenant, cutoff, now);
|
||||
} catch (RuntimeException failure) {
|
||||
failedTenants++;
|
||||
log.warn("Очистка аудита входа tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
tenant, failure.getClass().getSimpleName());
|
||||
log.debug("Технические детали очистки аудита входа", failure);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
if (deletedAudits + deletedLimits > 0) {
|
||||
log.info("Очистка аудита входа завершена: tenantCount={}, auditDeleted={}, rateLimitDeleted={}, failedCount={}",
|
||||
tenants.size(), deletedAudits, deletedLimits, failedTenants);
|
||||
}
|
||||
return new CleanupSummary(
|
||||
tenants.size(), deletedAudits, deletedLimits, failedTenants, false
|
||||
);
|
||||
} finally {
|
||||
restoreTenant(previousTenant);
|
||||
cleanupInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private int cleanupAudit(String tenant, Instant cutoff) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = auditRepository.deleteCleanupBatch(cutoff, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка аудита входа tenant-БД '{}' достигла лимита пачек", tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private int cleanupLimits(String tenant, Instant cutoff, Instant now) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = rateLimitRepository.deleteStaleBatch(cutoff, now, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка счётчиков входа tenant-БД '{}' достигла лимита пачек", tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private void restoreTenant(String tenant) {
|
||||
if (tenant == null || tenant.isBlank()) {
|
||||
TenantContext.clear();
|
||||
} else {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public record CleanupSummary(int tenantCount,
|
||||
int auditDeletedCount,
|
||||
int rateLimitDeletedCount,
|
||||
int failedTenantCount,
|
||||
boolean skippedBecauseAlreadyRunning) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.model.AuthLoginAttemptAudit;
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
|
||||
import com.magistr.app.repository.AuthLoginRateLimitRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.text.Normalizer;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class LoginRateLimitService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class);
|
||||
private static final String OUTCOME_FAILURE = "FAILURE";
|
||||
private static final String OUTCOME_BLOCKED = "BLOCKED";
|
||||
|
||||
private final AuthLoginRateLimitRepository rateLimitRepository;
|
||||
private final AuthLoginAttemptAuditRepository auditRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final LoginSecurityProperties properties;
|
||||
private final Clock clock;
|
||||
private final String dummyPasswordHash;
|
||||
|
||||
@Autowired
|
||||
public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
LoginSecurityProperties properties) {
|
||||
this(rateLimitRepository, auditRepository, userRepository, passwordEncoder,
|
||||
properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
|
||||
AuthLoginAttemptAuditRepository auditRepository,
|
||||
UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
LoginSecurityProperties properties,
|
||||
Clock clock) {
|
||||
this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
|
||||
this.auditRepository = Objects.requireNonNull(auditRepository);
|
||||
this.userRepository = Objects.requireNonNull(userRepository);
|
||||
this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only");
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка строки удерживается до завершения проверки пароля и записи результата.
|
||||
* Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток.
|
||||
*/
|
||||
@Transactional
|
||||
public AuthenticationDecision authenticate(String tenant,
|
||||
String username,
|
||||
String rawPassword,
|
||||
String clientIp) {
|
||||
String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant");
|
||||
String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя");
|
||||
String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен");
|
||||
Instant now = clock.instant();
|
||||
|
||||
rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now);
|
||||
AuthLoginRateLimit state = rateLimitRepository
|
||||
.findForUpdate(safeTenant, normalizedUsername, safeClientIp)
|
||||
.orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа"));
|
||||
|
||||
if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) {
|
||||
long retryAfter = retryAfterSeconds(now, state.getBlockedUntil());
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
|
||||
return AuthenticationDecision.blocked(retryAfter);
|
||||
}
|
||||
|
||||
if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) {
|
||||
state.setFailureCount(0);
|
||||
state.setWindowStartedAt(now);
|
||||
state.setBlockedUntil(null);
|
||||
}
|
||||
|
||||
Optional<User> user = username == null ? Optional.empty() : userRepository.findByUsername(username);
|
||||
String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash);
|
||||
boolean passwordMatches = passwordEncoder.matches(
|
||||
rawPassword == null ? "" : rawPassword,
|
||||
storedHash
|
||||
);
|
||||
boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord();
|
||||
|
||||
if (authenticated) {
|
||||
rateLimitRepository.delete(state);
|
||||
return AuthenticationDecision.authenticated(user.get());
|
||||
}
|
||||
|
||||
int failureCount = state.getFailureCount() + 1;
|
||||
state.setFailureCount(failureCount);
|
||||
state.setLastFailureAt(now);
|
||||
state.setUpdatedAt(now);
|
||||
|
||||
if (failureCount >= properties.getMaxFailures()) {
|
||||
Duration blockDuration = progressiveBlockDuration(failureCount);
|
||||
Instant blockedUntil = now.plus(blockDuration);
|
||||
state.setBlockedUntil(blockedUntil);
|
||||
long retryAfter = retryAfterSeconds(now, blockedUntil);
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
|
||||
return AuthenticationDecision.blocked(retryAfter);
|
||||
}
|
||||
|
||||
audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null);
|
||||
logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null);
|
||||
return AuthenticationDecision.rejected();
|
||||
}
|
||||
|
||||
private Duration progressiveBlockDuration(int failureCount) {
|
||||
int exponent = Math.min(failureCount - properties.getMaxFailures(), 30);
|
||||
long multiplier = 1L << exponent;
|
||||
Duration candidate;
|
||||
try {
|
||||
candidate = properties.getBaseBlockDuration().multipliedBy(multiplier);
|
||||
} catch (ArithmeticException overflow) {
|
||||
return properties.getMaxBlockDuration();
|
||||
}
|
||||
return candidate.compareTo(properties.getMaxBlockDuration()) > 0
|
||||
? properties.getMaxBlockDuration()
|
||||
: candidate;
|
||||
}
|
||||
|
||||
private void audit(String tenant,
|
||||
String username,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Instant occurredAt,
|
||||
Long retryAfterSeconds) {
|
||||
Integer retryAfter = retryAfterSeconds == null
|
||||
? null
|
||||
: Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE));
|
||||
auditRepository.save(new AuthLoginAttemptAudit(
|
||||
tenant, username, clientIp, outcome, occurredAt, retryAfter
|
||||
));
|
||||
}
|
||||
|
||||
private void logDenied(String tenant,
|
||||
String username,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Long retryAfterSeconds) {
|
||||
log.warn(
|
||||
"Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}",
|
||||
tenant,
|
||||
fingerprint(username),
|
||||
clientIp,
|
||||
outcome,
|
||||
retryAfterSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private String normalizeAndLimit(String value, int maxLength, String fallback) {
|
||||
String bounded = value == null
|
||||
? fallback
|
||||
: value.substring(0, Math.min(value.length(), maxLength * 4));
|
||||
String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC)
|
||||
.replaceAll("\\p{C}", "")
|
||||
.strip()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if (normalized.isBlank()) {
|
||||
normalized = fallback;
|
||||
}
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private long retryAfterSeconds(Instant now, Instant blockedUntil) {
|
||||
Duration remaining = Duration.between(now, blockedUntil);
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0);
|
||||
return Math.max(1, seconds);
|
||||
}
|
||||
|
||||
private String fingerprint(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest, 0, 8);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) {
|
||||
|
||||
static AuthenticationDecision authenticated(User user) {
|
||||
return new AuthenticationDecision(Status.AUTHENTICATED, user, 0);
|
||||
}
|
||||
|
||||
static AuthenticationDecision rejected() {
|
||||
return new AuthenticationDecision(Status.REJECTED, null, 0);
|
||||
}
|
||||
|
||||
static AuthenticationDecision blocked(long retryAfterSeconds) {
|
||||
return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds);
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
AUTHENTICATED,
|
||||
REJECTED,
|
||||
BLOCKED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.login-security")
|
||||
public class LoginSecurityProperties {
|
||||
|
||||
private int maxFailures = 5;
|
||||
private Duration attemptWindow = Duration.ofMinutes(15);
|
||||
private Duration baseBlockDuration = Duration.ofMinutes(1);
|
||||
private Duration maxBlockDuration = Duration.ofMinutes(15);
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
if (maxFailures < 2 || maxFailures > 100) {
|
||||
throw new IllegalStateException("LOGIN_RATE_MAX_FAILURES должен быть от 2 до 100");
|
||||
}
|
||||
requirePositive(attemptWindow, "Окно учёта попыток входа");
|
||||
requirePositive(baseBlockDuration, "Начальный срок блокировки входа");
|
||||
requirePositive(maxBlockDuration, "Максимальный срок блокировки входа");
|
||||
if (maxBlockDuration.compareTo(baseBlockDuration) < 0) {
|
||||
throw new IllegalStateException(
|
||||
"Максимальный срок блокировки входа не может быть меньше начального"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositive(Duration duration, String fieldName) {
|
||||
if (duration == null || duration.isZero() || duration.isNegative()) {
|
||||
throw new IllegalStateException(fieldName + " должен быть положительным");
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxFailures() {
|
||||
return maxFailures;
|
||||
}
|
||||
|
||||
public void setMaxFailures(int maxFailures) {
|
||||
this.maxFailures = maxFailures;
|
||||
}
|
||||
|
||||
public Duration getAttemptWindow() {
|
||||
return attemptWindow;
|
||||
}
|
||||
|
||||
public void setAttemptWindow(Duration attemptWindow) {
|
||||
this.attemptWindow = attemptWindow;
|
||||
}
|
||||
|
||||
public Duration getBaseBlockDuration() {
|
||||
return baseBlockDuration;
|
||||
}
|
||||
|
||||
public void setBaseBlockDuration(Duration baseBlockDuration) {
|
||||
this.baseBlockDuration = baseBlockDuration;
|
||||
}
|
||||
|
||||
public Duration getMaxBlockDuration() {
|
||||
return maxBlockDuration;
|
||||
}
|
||||
|
||||
public void setMaxBlockDuration(Duration maxBlockDuration) {
|
||||
this.maxBlockDuration = maxBlockDuration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.magistr.app.config.auth;
|
||||
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Периодически очищает старые refresh-сессии отдельно в каждой активной tenant-БД.
|
||||
*/
|
||||
@Component
|
||||
public class RefreshTokenCleanupJob {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RefreshTokenCleanupJob.class);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final AuthRefreshTokenRepository repository;
|
||||
private final boolean enabled;
|
||||
private final Duration retention;
|
||||
private final int batchSize;
|
||||
private final int maxBatchesPerTenant;
|
||||
private final Clock clock;
|
||||
private final AtomicBoolean cleanupInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public RefreshTokenCleanupJob(
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
AuthRefreshTokenRepository repository,
|
||||
@Value("${app.jwt.refresh-cleanup.enabled:true}") boolean enabled,
|
||||
@Value("${app.jwt.refresh-cleanup.retention:30d}") Duration retention,
|
||||
@Value("${app.jwt.refresh-cleanup.batch-size:500}") int batchSize,
|
||||
@Value("${app.jwt.refresh-cleanup.max-batches-per-tenant:20}") int maxBatchesPerTenant) {
|
||||
this(
|
||||
routingDataSource,
|
||||
repository,
|
||||
enabled,
|
||||
retention,
|
||||
batchSize,
|
||||
maxBatchesPerTenant,
|
||||
Clock.systemUTC()
|
||||
);
|
||||
}
|
||||
|
||||
RefreshTokenCleanupJob(TenantRoutingDataSource routingDataSource,
|
||||
AuthRefreshTokenRepository repository,
|
||||
boolean enabled,
|
||||
Duration retention,
|
||||
int batchSize,
|
||||
int maxBatchesPerTenant,
|
||||
Clock clock) {
|
||||
this.routingDataSource = Objects.requireNonNull(
|
||||
routingDataSource,
|
||||
"Маршрутизатор tenant-БД обязателен"
|
||||
);
|
||||
this.repository = Objects.requireNonNull(repository, "Репозиторий refresh-токенов обязателен");
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
throw new IllegalArgumentException("Срок хранения refresh-сессий должен быть положительным");
|
||||
}
|
||||
if (batchSize < 1 || batchSize > 10_000) {
|
||||
throw new IllegalArgumentException("Размер пачки очистки должен быть от 1 до 10000");
|
||||
}
|
||||
if (maxBatchesPerTenant < 1 || maxBatchesPerTenant > 1_000) {
|
||||
throw new IllegalArgumentException("Число пачек очистки должно быть от 1 до 1000");
|
||||
}
|
||||
this.enabled = enabled;
|
||||
this.retention = retention;
|
||||
this.batchSize = batchSize;
|
||||
this.maxBatchesPerTenant = maxBatchesPerTenant;
|
||||
this.clock = Objects.requireNonNull(clock, "Часы очистки обязательны");
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
fixedDelayString = "${app.jwt.refresh-cleanup.interval-ms:3600000}",
|
||||
initialDelayString = "${app.jwt.refresh-cleanup.initial-delay-ms:60000}"
|
||||
)
|
||||
public void cleanupScheduled() {
|
||||
if (enabled) {
|
||||
cleanupNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет один ограниченный проход. Локальный guard не допускает наложения запусков
|
||||
* внутри pod, а SQL SKIP LOCKED обеспечивает безопасную совместную работу нескольких pod.
|
||||
*/
|
||||
public CleanupSummary cleanupNow() {
|
||||
if (!cleanupInProgress.compareAndSet(false, true)) {
|
||||
return new CleanupSummary(0, 0, 0, true);
|
||||
}
|
||||
|
||||
String previousTenant = TenantContext.getCurrentTenant();
|
||||
try {
|
||||
TenantContext.clear();
|
||||
Map<String, ?> tenantSnapshot = routingDataSource.snapshotTenantConfigs();
|
||||
Instant cutoff = clock.instant().minus(retention);
|
||||
int deletedTotal = 0;
|
||||
int failedTenants = 0;
|
||||
|
||||
for (String tenant : tenantSnapshot.keySet()) {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
try {
|
||||
deletedTotal += cleanupTenant(tenant, cutoff);
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
failedTenants++;
|
||||
log.warn("Очистка refresh-сессий tenant-БД '{}' завершилась ошибкой: errorType={}",
|
||||
tenant, cleanupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали очистки refresh-сессий", cleanupFailure);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedTotal > 0) {
|
||||
log.info("Очистка refresh-сессий завершена: tenantCount={}, deletedCount={}, failedCount={}",
|
||||
tenantSnapshot.size(), deletedTotal, failedTenants);
|
||||
}
|
||||
return new CleanupSummary(tenantSnapshot.size(), deletedTotal, failedTenants, false);
|
||||
} finally {
|
||||
restoreTenant(previousTenant);
|
||||
cleanupInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private int cleanupTenant(String tenant, Instant cutoff) {
|
||||
int deletedTotal = 0;
|
||||
for (int batch = 0; batch < maxBatchesPerTenant; batch++) {
|
||||
int deleted = repository.deleteCleanupBatch(cutoff, batchSize);
|
||||
deletedTotal += deleted;
|
||||
if (deleted < batchSize) {
|
||||
return deletedTotal;
|
||||
}
|
||||
}
|
||||
log.warn("Очистка refresh-сессий tenant-БД '{}' достигла лимита пачек за один проход",
|
||||
tenant);
|
||||
return deletedTotal;
|
||||
}
|
||||
|
||||
private void restoreTenant(String previousTenant) {
|
||||
if (previousTenant == null || previousTenant.isBlank()) {
|
||||
TenantContext.clear();
|
||||
} else {
|
||||
TenantContext.setCurrentTenant(previousTenant);
|
||||
}
|
||||
}
|
||||
|
||||
public record CleanupSummary(int tenantCount,
|
||||
int deletedCount,
|
||||
int failedTenantCount,
|
||||
boolean skippedBecauseAlreadyRunning) {
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import com.magistr.app.model.AuthRefreshToken;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.AuthRefreshTokenRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
@@ -25,17 +27,24 @@ public class RefreshTokenService {
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final AuthRefreshTokenRepository repository;
|
||||
private final JwtProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
|
||||
this(repository, properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String createSession(User user, String tenant, String userAgent, String ipAddress) {
|
||||
String refreshToken = generateRawToken();
|
||||
AuthRefreshToken token = new AuthRefreshToken();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Instant now = clock.instant();
|
||||
token.setUser(user);
|
||||
token.setTenant(tenant);
|
||||
token.setTokenHash(hashToken(refreshToken));
|
||||
@@ -53,7 +62,7 @@ public class RefreshTokenService {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Instant now = clock.instant();
|
||||
String currentHash = hashToken(rawToken);
|
||||
Optional<AuthRefreshToken> storedOpt = repository.findByTokenHashForUpdate(currentHash);
|
||||
if (storedOpt.isEmpty()) {
|
||||
@@ -101,7 +110,7 @@ public class RefreshTokenService {
|
||||
.filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
|
||||
.filter(token -> token.getRevokedAt() == null)
|
||||
.ifPresent(token -> {
|
||||
token.setRevokedAt(LocalDateTime.now());
|
||||
token.setRevokedAt(clock.instant());
|
||||
repository.save(token);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class TenantDataSourceConfig {
|
||||
"Default", "default", defaultDbUrl, defaultDbUsername, defaultDbPassword
|
||||
);
|
||||
tenants.add(defaultTenant);
|
||||
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
|
||||
log.info("Конфигурация тенантов отсутствует, используется источник данных по умолчанию");
|
||||
}
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
@@ -79,10 +79,10 @@ public class TenantDataSourceConfig {
|
||||
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
||||
}
|
||||
|
||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||
// Если всё ещё нет ни одного тенанта — H2-заглушка в памяти
|
||||
if (routingDataSource.getTenantConfigs().isEmpty()) {
|
||||
log.warn("=== НЕТ НАСТРОЕННЫХ ТЕНАНТОВ ===");
|
||||
log.warn("Создаём H2 in-memory заглушку для запуска приложения.");
|
||||
log.warn("Создаём H2-заглушку в памяти для запуска приложения");
|
||||
log.warn("Добавьте тенант через POST /api/database/tenants");
|
||||
|
||||
TenantConfig h2Fallback = new TenantConfig(
|
||||
@@ -155,7 +155,7 @@ public class TenantDataSourceConfig {
|
||||
log.info("Загружено конфигураций тенантов: {}", list.size());
|
||||
return new TenantConfigLoadResult(List.copyOf(list), false);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
|
||||
log.error("Не удалось прочитать конфигурацию тенантов: типОшибки={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали чтения конфигурации тенантов", e);
|
||||
return new TenantConfigLoadResult(List.of(), true);
|
||||
@@ -189,15 +189,15 @@ public class TenantDataSourceConfig {
|
||||
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
||||
}
|
||||
closeDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
|
||||
log.info("БД тенанта '{}' проверена и активирована", tenant.getDomain());
|
||||
return true;
|
||||
} catch (Exception startupFailure) {
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationFailed(tenant);
|
||||
}
|
||||
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
|
||||
log.error("Не удалось безопасно активировать БД тенанта '{}': типОшибки={}",
|
||||
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
|
||||
log.debug("Технические детали жизненного цикла запуска БД тенанта", startupFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (!activated) {
|
||||
@@ -211,9 +211,9 @@ public class TenantDataSourceConfig {
|
||||
try {
|
||||
hikariDataSource.close();
|
||||
} catch (RuntimeException closeFailure) {
|
||||
log.warn("Не удалось штатно закрыть startup Hikari pool: errorType={}",
|
||||
log.warn("Не удалось штатно закрыть стартовый пул Hikari: типОшибки={}",
|
||||
closeFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали закрытия startup Hikari pool", closeFailure);
|
||||
log.debug("Технические детали закрытия стартового пула Hikari", closeFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,13 +52,13 @@ public class TenantInterceptor implements HandlerInterceptor {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
MDC.put("tenant.id", tenant);
|
||||
Span.current().setAttribute("tenant.id", tenant);
|
||||
log.debug("Database API request, tenant '{}' (no strict check)", tenant);
|
||||
log.debug("Запрос API управления БД: тенант='{}', строгая проверка отключена", tenant);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверяем, существует ли тенант
|
||||
if (routingDataSource != null && !routingDataSource.hasTenant(tenant)) {
|
||||
log.warn("Unknown tenant '{}' from Host '{}' — returning 404", tenant, host);
|
||||
log.warn("Неизвестный тенант '{}' для заголовка Host '{}': возвращается 404", tenant, host);
|
||||
response.setStatus(404);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
|
||||
@@ -72,7 +72,7 @@ public class TenantInterceptor implements HandlerInterceptor {
|
||||
TenantContext.setCurrentTenant(tenant);
|
||||
MDC.put("tenant.id", tenant);
|
||||
Span.current().setAttribute("tenant.id", tenant);
|
||||
log.debug("Resolved tenant '{}' from Host '{}'", tenant, host);
|
||||
log.debug("Тенант '{}' определён по заголовку Host '{}'", tenant, host);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
try (Connection connection = tenantDataSource.get().getConnection()) {
|
||||
return connection.isValid(5);
|
||||
} catch (SQLException e) {
|
||||
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: errorType={}",
|
||||
log.warn("Проверка подключения тенанта '{}' завершилась ошибкой: типОшибки={}",
|
||||
normalizeDomain(domain), e.getClass().getSimpleName());
|
||||
log.debug("Технические детали проверки подключения тенанта", e);
|
||||
return false;
|
||||
@@ -165,7 +165,7 @@ public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
}
|
||||
return "Подключение не валидно";
|
||||
} catch (Exception e) {
|
||||
log.warn("Проверка внешнего подключения завершилась ошибкой: errorType={}",
|
||||
log.warn("Проверка внешнего подключения завершилась ошибкой: типОшибки={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали проверки внешнего подключения", e);
|
||||
return "Не удалось подключиться к базе данных";
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -41,6 +42,7 @@ public class TenantDatabaseHealthMonitor {
|
||||
private final ExecutorService executor;
|
||||
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
|
||||
|
||||
@Autowired
|
||||
public TenantDatabaseHealthMonitor(
|
||||
TenantReadinessRegistry registry,
|
||||
TenantRoutingDataSource routingDataSource,
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicPeriodService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -22,17 +23,20 @@ public class AcademicCalendarAdminController {
|
||||
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final AcademicPeriodService academicPeriodService;
|
||||
|
||||
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
AcademicCalendarActivityTypeRepository activityTypeRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicPeriodService academicPeriodService) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.activityTypeRepository = activityTypeRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.academicPeriodService = academicPeriodService;
|
||||
}
|
||||
|
||||
@GetMapping("/years")
|
||||
@@ -45,43 +49,17 @@ public class AcademicCalendarAdminController {
|
||||
|
||||
@PostMapping("/years")
|
||||
public ResponseEntity<?> createYear(@RequestBody AcademicYearDto request) {
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (academicYearRepository.findByTitle(request.title()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует"));
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, request);
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.createYear(request)));
|
||||
}
|
||||
|
||||
@PutMapping("/years/{id}")
|
||||
public ResponseEntity<?> updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(id).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validateYear(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applyYear(year, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year)));
|
||||
return ResponseEntity.ok(toAcademicYearDto(academicPeriodService.updateYear(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/years/{id}")
|
||||
public ResponseEntity<?> deleteYear(@PathVariable Long id) {
|
||||
if (!academicYearRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
academicYearRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
academicPeriodService.deleteYear(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Учебный год удалён"));
|
||||
}
|
||||
|
||||
@@ -94,39 +72,12 @@ public class AcademicCalendarAdminController {
|
||||
|
||||
@PostMapping("/years/{academicYearId}/semesters")
|
||||
public ResponseEntity<?> createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) {
|
||||
AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null);
|
||||
if (year == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году"));
|
||||
}
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
return ResponseEntity.ok(toSemesterDto(academicPeriodService.createSemester(academicYearId, request)));
|
||||
}
|
||||
|
||||
@PutMapping("/semesters/{id}")
|
||||
public ResponseEntity<?> updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) {
|
||||
Semester semester = semesterRepository.findById(id).orElse(null);
|
||||
if (semester == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String validationError = validateSemester(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
|
||||
applySemester(semester, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester)));
|
||||
return ResponseEntity.ok(toSemesterDto(academicPeriodService.updateSemester(id, request)));
|
||||
}
|
||||
|
||||
@GetMapping("/activity-types")
|
||||
@@ -185,32 +136,6 @@ public class AcademicCalendarAdminController {
|
||||
return ResponseEntity.ok(Map.of("message", "Код активности удалён"));
|
||||
}
|
||||
|
||||
private String validateYear(AcademicYearDto request) {
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
return "Название учебного года обязательно";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания учебного года обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateSemester(SemesterDto request) {
|
||||
if (request.semesterType() == null) {
|
||||
return "Тип семестра обязателен";
|
||||
}
|
||||
if (request.startDate() == null || request.endDate() == null) {
|
||||
return "Даты начала и окончания семестра обязательны";
|
||||
}
|
||||
if (request.endDate().isBefore(request.startDate())) {
|
||||
return "Дата окончания семестра не может быть раньше даты начала";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateActivityType(AcademicCalendarActivityTypeDto request) {
|
||||
if (request == null) {
|
||||
return "Передайте код активности";
|
||||
@@ -224,18 +149,6 @@ public class AcademicCalendarAdminController {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyYear(AcademicYear year, AcademicYearDto request) {
|
||||
year.setTitle(request.title().trim());
|
||||
year.setStartDate(request.startDate());
|
||||
year.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
semester.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) {
|
||||
activityType.setCode(request.code().trim());
|
||||
activityType.setName(request.name().trim());
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.magistr.app.dto.*;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicCalendarGridService;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -32,6 +33,7 @@ public class AcademicCalendarController {
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicCalendarGridService calendarGridService;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final AcademicStructureService academicStructureService;
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
@@ -42,7 +44,8 @@ public class AcademicCalendarController {
|
||||
SubjectRepository subjectRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicCalendarGridService calendarGridService,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
@@ -53,6 +56,7 @@ public class AcademicCalendarController {
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.calendarGridService = calendarGridService;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.academicStructureService = academicStructureService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -85,17 +89,7 @@ public class AcademicCalendarController {
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) {
|
||||
AcademicCalendar calendar = calendarRepository.findById(id).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
applyCalendar(calendar, request);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
return ResponseEntity.ok(toCalendarDto(academicStructureService.updateCalendar(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -103,14 +97,9 @@ public class AcademicCalendarController {
|
||||
if (!calendarRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
calendarRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить график, который назначен учебным группам"));
|
||||
}
|
||||
calendarRepository.deleteById(id);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Календарный график удалён"));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/grid")
|
||||
|
||||
@@ -3,17 +3,18 @@ package com.magistr.app.controller;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.JwtProperties;
|
||||
import com.magistr.app.config.auth.JwtTokenService;
|
||||
import com.magistr.app.config.auth.ClientIpResolver;
|
||||
import com.magistr.app.config.auth.LoginRateLimitService;
|
||||
import com.magistr.app.config.auth.RefreshTokenRotation;
|
||||
import com.magistr.app.config.auth.RefreshTokenService;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.dto.LoginRequest;
|
||||
import com.magistr.app.dto.LoginResponse;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -27,8 +28,8 @@ import java.util.Optional;
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final LoginRateLimitService loginRateLimitService;
|
||||
private final ClientIpResolver clientIpResolver;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final JwtProperties jwtProperties;
|
||||
@@ -42,13 +43,13 @@ public class AuthController {
|
||||
"STUDENT", "/student/"
|
||||
);
|
||||
|
||||
public AuthController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
public AuthController(LoginRateLimitService loginRateLimitService,
|
||||
ClientIpResolver clientIpResolver,
|
||||
JwtTokenService jwtTokenService,
|
||||
RefreshTokenService refreshTokenService,
|
||||
JwtProperties jwtProperties) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.loginRateLimitService = loginRateLimitService;
|
||||
this.clientIpResolver = clientIpResolver;
|
||||
this.jwtTokenService = jwtTokenService;
|
||||
this.refreshTokenService = refreshTokenService;
|
||||
this.jwtProperties = jwtProperties;
|
||||
@@ -56,28 +57,46 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
|
||||
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
|
||||
|
||||
if (userOpt.isEmpty() ||
|
||||
!passwordEncoder.matches(request.getPassword(), userOpt.get().getPassword())) {
|
||||
LoginRateLimitService.AuthenticationDecision decision = loginRateLimitService.authenticate(
|
||||
TenantContext.getCurrentTenant(),
|
||||
request.getUsername(),
|
||||
request.getPassword(),
|
||||
clientIpResolver.resolve(servletRequest)
|
||||
);
|
||||
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.BLOCKED) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header(HttpHeaders.RETRY_AFTER, Long.toString(decision.retryAfterSeconds()))
|
||||
.body(new LoginResponse(
|
||||
false,
|
||||
"Слишком много попыток входа. Повторите позже",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
if (decision.status() == LoginRateLimitService.AuthenticationDecision.Status.REJECTED) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Неверное имя пользователя или пароль", null, null, null, null));
|
||||
.body(new LoginResponse(
|
||||
false,
|
||||
"Неверное имя пользователя или пароль",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (user.isArchivedRecord()) {
|
||||
return ResponseEntity
|
||||
.status(401)
|
||||
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
|
||||
}
|
||||
User user = decision.user();
|
||||
String tenant = TenantContext.getCurrentTenant();
|
||||
String accessToken = jwtTokenService.createAccessToken(user, tenant);
|
||||
String refreshToken = refreshTokenService.createSession(
|
||||
user,
|
||||
tenant,
|
||||
servletRequest.getHeader("User-Agent"),
|
||||
clientIp(servletRequest)
|
||||
clientIpResolver.resolve(servletRequest)
|
||||
);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
@@ -96,7 +115,7 @@ public class AuthController {
|
||||
refreshToken.get(),
|
||||
TenantContext.getCurrentTenant(),
|
||||
request.getHeader("User-Agent"),
|
||||
clientIp(request)
|
||||
clientIpResolver.resolve(request)
|
||||
);
|
||||
if (rotation.isEmpty()) {
|
||||
return unauthorizedWithClearedCookie();
|
||||
@@ -192,11 +211,4 @@ public class AuthController {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import org.hibernate.exception.ConstraintViolationException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Преобразует технические нарушения целостности БД в безопасные русские HTTP-ошибки.
|
||||
*/
|
||||
final class DatabaseConstraintViolationMapper {
|
||||
|
||||
private static final Pattern CONSTRAINT_PATTERN = Pattern.compile(
|
||||
"(?i)(?:constraint|ограничени[ея])\\s+[\"']?([a-z0-9_.$-]+)[\"']?"
|
||||
);
|
||||
|
||||
private static final Map<String, ViolationResponse> KNOWN_CONSTRAINTS = Map.ofEntries(
|
||||
badRequest("chk_teacher_creation_request_status", "Недопустимый статус заявки на преподавателя"),
|
||||
badRequest("chk_teacher_department_dates", "Дата окончания назначения кафедры раньше даты начала"),
|
||||
badRequest("chk_time_slot_scopes_mode", "Недопустимый режим сетки времени"),
|
||||
badRequest("chk_time_slot_scopes_day", "День недели сетки времени должен быть от 1 до 7"),
|
||||
badRequest("chk_time_slot_scopes_weekday", "День недели обязателен только для недельной сетки"),
|
||||
badRequest("chk_time_slots_order_positive", "Номер пары должен быть положительным"),
|
||||
badRequest("chk_time_slots_time_range", "Время начала пары должно быть раньше времени окончания"),
|
||||
badRequest("chk_time_slots_duration_positive", "Продолжительность пары должна быть положительной"),
|
||||
badRequest("chk_time_slots_duration_matches_range", "Продолжительность пары не соответствует её времени"),
|
||||
badRequest("chk_academic_years_dates", "Дата окончания учебного года раньше даты начала"),
|
||||
badRequest("chk_semesters_type", "Недопустимый тип семестра"),
|
||||
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
|
||||
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
|
||||
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"),
|
||||
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
|
||||
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
|
||||
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
|
||||
badRequest("chk_schedule_rules_type_hours_non_negative", "Количество академических часов не может быть отрицательным"),
|
||||
badRequest("chk_schedule_rules_has_type_hours", "В правиле должен быть хотя бы один тип занятия с часами"),
|
||||
badRequest("chk_schedule_rules_start_weeks_positive", "Неделя начала занятия должна быть положительной"),
|
||||
badRequest("chk_schedule_rules_dates", "Период действия правила задан неверно"),
|
||||
badRequest("chk_schedule_rules_academic_hours_even", "Количество академических часов должно быть чётным"),
|
||||
badRequest("chk_schedule_rule_slots_day", "День недели слота должен быть от 1 до 7"),
|
||||
badRequest("chk_schedule_rule_slots_parity", "Недопустимая чётность недели"),
|
||||
badRequest("chk_schedule_rule_slots_format", "Формат занятия должен быть «Очно» или «Онлайн»"),
|
||||
badRequest("chk_schedule_overrides_action", "Недопустимый тип точечного изменения расписания"),
|
||||
badRequest("chk_schedule_overrides_cancel_payload", "Для отмены переданы лишние параметры"),
|
||||
badRequest("chk_schedule_overrides_move_payload", "Для переноса переданы неверные параметры"),
|
||||
badRequest("chk_schedule_overrides_replace_payload", "Для замены преподавателя переданы неверные параметры"),
|
||||
badRequest("chk_schedule_overrides_format", "Недопустимый формат занятия в точечном изменении"),
|
||||
conflict("uq_specialty_profiles_name", "Профиль с таким названием уже существует"),
|
||||
conflict("uq_subjects_name_ci", "Дисциплина с таким названием уже существует"),
|
||||
conflict("uq_time_slot_scopes_default", "Базовая сетка времени уже существует"),
|
||||
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
|
||||
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
|
||||
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
|
||||
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
|
||||
conflict("uq_calendar_days", "День календарного графика уже существует"),
|
||||
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
|
||||
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
|
||||
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),
|
||||
conflict("uq_schedule_overrides_slot_date", "Для этой пары и даты уже существует точечное изменение"),
|
||||
conflict("ux_subgroups_active_group_name", "Активная подгруппа с таким названием уже существует"),
|
||||
conflict("ex_time_slots_scope_no_overlap", "Интервал пары пересекается с другой парой выбранной сетки"),
|
||||
conflict("ex_academic_years_no_overlap", "Период учебного года пересекается с существующим"),
|
||||
conflict("ex_semesters_year_no_overlap", "Период семестра пересекается с существующим"),
|
||||
conflict("ex_teacher_primary_department_no_overlap", "Период основной кафедры пересекается с другим назначением")
|
||||
);
|
||||
|
||||
ViolationResponse map(DataIntegrityViolationException exception) {
|
||||
String constraintName = constraintName(exception).orElse(null);
|
||||
String sqlState = sqlState(exception).orElse(null);
|
||||
if (constraintName != null) {
|
||||
ViolationResponse known = KNOWN_CONSTRAINTS.get(constraintName);
|
||||
if (known != null) {
|
||||
return known.withDetails(constraintName, sqlState);
|
||||
}
|
||||
if (constraintName.endsWith("_fkey")) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция невозможна из-за связанных данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("23502".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Не заполнено обязательное поле",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23514".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Данные нарушают правила предметной области",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23505".equals(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Такая запись уже существует",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
if ("23503".equals(sqlState) || "23P01".equalsIgnoreCase(sqlState)) {
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция невозможна из-за конфликта или связанных данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
return new ViolationResponse(
|
||||
HttpStatus.CONFLICT,
|
||||
"Операция нарушает ограничения целостности данных",
|
||||
constraintName,
|
||||
sqlState
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<String> constraintName(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = nextCause(current)) {
|
||||
if (current instanceof ConstraintViolationException constraintViolation
|
||||
&& constraintViolation.getConstraintName() != null) {
|
||||
return normalize(constraintViolation.getConstraintName());
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message != null) {
|
||||
Matcher matcher = CONSTRAINT_PATTERN.matcher(message);
|
||||
if (matcher.find()) {
|
||||
return normalize(matcher.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<String> sqlState(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = nextCause(current)) {
|
||||
if (current instanceof SQLException sqlException && sqlException.getSQLState() != null) {
|
||||
return Optional.of(sqlException.getSQLState());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Throwable nextCause(Throwable current) {
|
||||
Throwable cause = current.getCause();
|
||||
return cause == current ? null : cause;
|
||||
}
|
||||
|
||||
private Optional<String> normalize(String constraintName) {
|
||||
if (constraintName == null || constraintName.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalized = constraintName.trim()
|
||||
.replace("\"", "")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
int dot = normalized.lastIndexOf('.');
|
||||
return Optional.of(dot >= 0 ? normalized.substring(dot + 1) : normalized);
|
||||
}
|
||||
|
||||
private static Map.Entry<String, ViolationResponse> badRequest(String name, String message) {
|
||||
return Map.entry(name, new ViolationResponse(HttpStatus.BAD_REQUEST, message, name, "23514"));
|
||||
}
|
||||
|
||||
private static Map.Entry<String, ViolationResponse> conflict(String name, String message) {
|
||||
return Map.entry(name, new ViolationResponse(HttpStatus.CONFLICT, message, name, null));
|
||||
}
|
||||
|
||||
record ViolationResponse(HttpStatus status,
|
||||
String message,
|
||||
String constraintName,
|
||||
String sqlState) {
|
||||
|
||||
private ViolationResponse withDetails(String actualConstraintName, String actualSqlState) {
|
||||
return new ViolationResponse(status, message, actualConstraintName, actualSqlState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -93,9 +92,8 @@ public class DepartmentController {
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании кафедры: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании кафедры " + e.getMessage()));
|
||||
logger.error("Ошибка при создании кафедры", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +146,8 @@ public class DepartmentController {
|
||||
department.getDepartmentCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении кафедры с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении кафедры " + e.getMessage()));
|
||||
logger.error("Ошибка при обновлении кафедры с ID - {}", id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import com.magistr.app.service.SubjectImportService;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -40,6 +44,9 @@ public class DepartmentWorkspaceController {
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final TeacherCreationRequestRepository teacherCreationRequestRepository;
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final SubjectImportService subjectImportService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
@@ -47,7 +54,26 @@ public class DepartmentWorkspaceController {
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
ScheduleQueryService scheduleQueryService) {
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
SubjectImportService subjectImportService) {
|
||||
this(subjectRepository, subjectCommentRepository, userRepository, departmentRepository,
|
||||
teacherDepartmentAssignmentRepository, teacherCreationRequestRepository,
|
||||
scheduleQueryService, teacherDepartmentService, subjectImportService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public DepartmentWorkspaceController(SubjectRepository subjectRepository,
|
||||
SubjectCommentRepository subjectCommentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
SubjectImportService subjectImportService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.subjectCommentRepository = subjectCommentRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -55,6 +81,9 @@ public class DepartmentWorkspaceController {
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.subjectImportService = subjectImportService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping("/subjects")
|
||||
@@ -77,19 +106,7 @@ public class DepartmentWorkspaceController {
|
||||
if (departmentId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
List<Subject> saved = requests.stream()
|
||||
.filter(request -> request.getName() != null && !request.getName().isBlank())
|
||||
.map(request -> {
|
||||
Subject subject = subjectRepository.findByName(request.getName().trim()).orElseGet(Subject::new);
|
||||
subject.setName(request.getName().trim());
|
||||
subject.setCode(request.getCode() == null || request.getCode().isBlank() ? null : request.getCode().trim());
|
||||
subject.setDepartmentId(departmentId);
|
||||
if (subject.isArchivedRecord()) {
|
||||
subject.restore();
|
||||
}
|
||||
return subjectRepository.save(subject);
|
||||
})
|
||||
.toList();
|
||||
List<Subject> saved = subjectImportService.importSubjects(requests, departmentId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Дисциплины загружены",
|
||||
"count", saved.size()
|
||||
@@ -118,6 +135,7 @@ public class DepartmentWorkspaceController {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Комментарий обязателен"));
|
||||
}
|
||||
SubjectComment comment = new SubjectComment();
|
||||
comment.setCreatedAt(businessTime.now());
|
||||
comment.setSubject(subject);
|
||||
comment.setComment(commentText.trim());
|
||||
Long currentUserId = AuthContext.currentUserId();
|
||||
@@ -132,21 +150,19 @@ public class DepartmentWorkspaceController {
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
if (effectiveDepartmentId == null) {
|
||||
return userRepository.findByRoleAndStatusNot(Role.TEACHER, LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.map(this::toUserResponse)
|
||||
.map(user -> toUserResponse(
|
||||
user,
|
||||
teacherDepartmentService.findPrimaryDepartmentIdAtDate(
|
||||
user.getId(),
|
||||
businessTime.today()
|
||||
).orElse(user.getDepartmentId())
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(effectiveDepartmentId, LocalDate.now())
|
||||
.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, effectiveDepartmentId, LifecycleEntity.STATUS_ARCHIVED)
|
||||
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
return teachersById.values().stream()
|
||||
.map(this::toUserResponse)
|
||||
return teacherDepartmentService
|
||||
.findTeachersForDepartmentAtDate(effectiveDepartmentId, businessTime.today()).stream()
|
||||
.map(user -> toUserResponse(user, effectiveDepartmentId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -167,7 +183,7 @@ public class DepartmentWorkspaceController {
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER || teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активный преподаватель не найден"));
|
||||
}
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate today = businessTime.today();
|
||||
if (teacherDepartmentAssignmentRepository.existsActiveAssignment(teacherId, departmentId, today)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель уже привязан к этой кафедре"));
|
||||
}
|
||||
@@ -225,6 +241,8 @@ public class DepartmentWorkspaceController {
|
||||
}
|
||||
|
||||
TeacherCreationRequest teacherRequest = new TeacherCreationRequest();
|
||||
teacherRequest.setCreatedAt(businessTime.now());
|
||||
teacherRequest.setUpdatedAt(businessTime.now());
|
||||
teacherRequest.setDepartment(department);
|
||||
teacherRequest.setUsername(username);
|
||||
teacherRequest.setFullName(fullName);
|
||||
@@ -257,14 +275,14 @@ public class DepartmentWorkspaceController {
|
||||
);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
private UserResponse toUserResponse(User user, Long departmentId) {
|
||||
UserResponse response = new UserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getRole().name(),
|
||||
user.getFullName(),
|
||||
user.getJobTitle(),
|
||||
user.getDepartmentId()
|
||||
departmentId
|
||||
);
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/education-forms")
|
||||
@RequireRoles({Role.ADMIN})
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class EducationFormController {
|
||||
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.magistr.app.service.ScheduleConflictException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
@@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
@@ -20,6 +21,8 @@ import java.util.NoSuchElementException;
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
private final DatabaseConstraintViolationMapper constraintViolationMapper =
|
||||
new DatabaseConstraintViolationMapper();
|
||||
|
||||
@ExceptionHandler(ScheduleConflictException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleScheduleConflict(ScheduleConflictException exception,
|
||||
@@ -58,6 +61,23 @@ public class GlobalExceptionHandler {
|
||||
return error(HttpStatus.BAD_REQUEST, "Некорректные параметры запроса", request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleDataIntegrityViolation(
|
||||
DataIntegrityViolationException exception,
|
||||
HttpServletRequest request) {
|
||||
DatabaseConstraintViolationMapper.ViolationResponse violation =
|
||||
constraintViolationMapper.map(exception);
|
||||
log.warn(
|
||||
"Нарушение целостности данных {} {}: constraint={}, sqlState={}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
violation.constraintName() == null ? "не определено" : violation.constraintName(),
|
||||
violation.sqlState() == null ? "не определено" : violation.sqlState()
|
||||
);
|
||||
log.debug("Технические детали нарушения целостности данных", exception);
|
||||
return error(violation.status(), violation.message(), request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception exception,
|
||||
HttpServletRequest request) {
|
||||
@@ -67,7 +87,7 @@ public class GlobalExceptionHandler {
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, HttpServletRequest request) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("timestamp", LocalDateTime.now().toString());
|
||||
body.put("timestamp", Instant.now().toString());
|
||||
body.put("status", status.value());
|
||||
body.put("error", reason(status));
|
||||
body.put("message", message);
|
||||
|
||||
@@ -5,9 +5,7 @@ import com.magistr.app.dto.AcademicCalendarSubjectDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarSubject;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
@@ -17,12 +15,15 @@ import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.AcademicStructureService;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.service.StudentGroupLifecycleService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -45,33 +46,49 @@ public class GroupController {
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository specialtyProfileRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final AcademicCalendarRepository academicCalendarRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
private final AcademicStructureService academicStructureService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository specialtyProfileRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
AcademicCalendarRepository academicCalendarRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this(groupRepository, educationFormRepository, specialtiesRepository,
|
||||
specialtyProfileRepository, calendarSubjectRepository, assignmentRepository,
|
||||
scheduleGeneratorService, groupLifecycleService, academicStructureService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository specialtyProfileRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicStructureService academicStructureService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.specialtyProfileRepository = specialtyProfileRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.academicCalendarRepository = academicCalendarRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
this.academicStructureService = academicStructureService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -82,7 +99,7 @@ public class GroupController {
|
||||
List<StudentGroup> groups = includeArchived
|
||||
? groupRepository.findAll()
|
||||
: groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.toList();
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
@@ -101,7 +118,7 @@ public class GroupController {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
try {
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
|
||||
.toList();
|
||||
|
||||
if(groups.isEmpty()) {
|
||||
@@ -163,10 +180,9 @@ public class GroupController {
|
||||
logger.info("Группа успешно создана с ID - {}", group.getId());
|
||||
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e ) {
|
||||
logger.error("Ошибка при создании группы: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании группы: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании группы", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,50 +190,9 @@ public class GroupController {
|
||||
@RequireRoles({Role.ADMIN})
|
||||
public ResponseEntity<?> updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) {
|
||||
logger.info("Получен запрос на обновление группы с ID - {}", id);
|
||||
try {
|
||||
Optional<StudentGroup> groupOpt = groupRepository.findById(id);
|
||||
if (groupOpt.isEmpty()) {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ResponseEntity<?> validationError = validateGroupRequest(request);
|
||||
if (validationError != null) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||
if (efOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||
}
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElse(null);
|
||||
if (speciality == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена"));
|
||||
}
|
||||
SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId())
|
||||
.orElse(null);
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности"));
|
||||
}
|
||||
|
||||
StudentGroup group = groupOpt.get();
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(efOpt.get());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(speciality);
|
||||
group.setSpecialtyProfile(profile);
|
||||
groupRepository.save(group);
|
||||
|
||||
logger.info("Группа с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage()));
|
||||
}
|
||||
StudentGroup group = academicStructureService.updateGroup(id, request);
|
||||
logger.info("Группа с ID - {} успешно обновлена", id);
|
||||
return ResponseEntity.ok(mapToResponse(group));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -229,7 +204,7 @@ public class GroupController {
|
||||
logger.info("Группа с ID - {} не найдена", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
group.archive("Группа архивирована");
|
||||
group.archive("Группа архивирована", businessTime.today(), businessTime.now());
|
||||
groupRepository.save(group);
|
||||
logger.info("Группа с ID - {} успешно архивирована", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Группа архивирована"));
|
||||
@@ -278,11 +253,11 @@ public class GroupController {
|
||||
}
|
||||
|
||||
private GroupResponse mapToResponse(StudentGroup group) {
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy());
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
||||
LocalDate today = businessTime.today();
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), today);
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy(), today);
|
||||
Speciality speciality = group.getSpeciality();
|
||||
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||
LocalDate today = LocalDate.now();
|
||||
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
||||
return new GroupResponse(
|
||||
group.getId(),
|
||||
@@ -323,42 +298,9 @@ public class GroupController {
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> saveCalendarAssignment(@PathVariable Long id,
|
||||
@RequestBody GroupCalendarAssignmentDto request) {
|
||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны"));
|
||||
}
|
||||
StudentGroup group = groupRepository.findById(id).orElse(null);
|
||||
if (group == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null);
|
||||
if (academicYear == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден"));
|
||||
}
|
||||
AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null);
|
||||
if (calendar == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден"));
|
||||
}
|
||||
if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году"));
|
||||
}
|
||||
if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId())
|
||||
|| !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы"));
|
||||
}
|
||||
if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы"));
|
||||
}
|
||||
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository
|
||||
.findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId())
|
||||
.orElseGet(StudentGroupCalendarAssignment::new);
|
||||
assignment.setStudentGroup(group);
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
scheduleGeneratorService.clearCache();
|
||||
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
|
||||
StudentGroupCalendarAssignment saved = academicStructureService.saveAssignment(id, request);
|
||||
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
|
||||
.findByCalendarIdWithSubject(calendar.getId())
|
||||
.findByCalendarIdWithSubject(saved.getAcademicCalendar().getId())
|
||||
.stream()
|
||||
.map(this::toCalendarSubjectDto)
|
||||
.toList();
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -36,6 +35,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public List<Speciality> getAllSpecialties(@RequestParam(defaultValue = "false") boolean includeArchived) {
|
||||
logger.info("Получен запрос на получение списка специальностей");
|
||||
try {
|
||||
@@ -102,9 +102,8 @@ public class SpecialityController {
|
||||
)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании специальности: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании специальности " + e.getMessage()));
|
||||
logger.error("Ошибка при создании специальности", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +157,8 @@ public class SpecialityController {
|
||||
speciality.getSpecialityCode()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при обновлении специальности с ID - {}: {}", id, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при обновлении специальности " + e.getMessage()));
|
||||
logger.error("Ошибка при обновлении специальности с ID - {}", id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +192,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping("/profiles")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public List<SpecialtyProfileDto> getAllProfiles() {
|
||||
logger.info("Получен запрос на получение всех профилей обучения");
|
||||
return specialtyProfileRepository.findAll().stream()
|
||||
@@ -204,6 +203,7 @@ public class SpecialityController {
|
||||
}
|
||||
|
||||
@GetMapping("/{specialtyId}/profiles")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public ResponseEntity<?> getProfiles(@PathVariable Long specialtyId) {
|
||||
if (!specialtiesRepository.existsById(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -255,14 +255,8 @@ public class SpecialityController {
|
||||
if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
specialtyProfileRepository.delete(profile);
|
||||
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками"));
|
||||
}
|
||||
specialtyProfileRepository.delete(profile);
|
||||
return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён"));
|
||||
}
|
||||
|
||||
private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -125,14 +124,10 @@ public class SubgroupController {
|
||||
if (deleteValidationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError));
|
||||
}
|
||||
try {
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
|
||||
}
|
||||
subgroup.archive("Подгруппа архивирована");
|
||||
subgroupRepository.save(subgroup);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return ResponseEntity.ok(Map.of("message", "Подгруппа архивирована"));
|
||||
}
|
||||
|
||||
private String validate(SubgroupDto request) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.magistr.app.repository.SubjectRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -87,7 +86,7 @@ public class SubjectController {
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
}
|
||||
if (subjectRepository.findByName(request.getName().trim()).isPresent()) {
|
||||
if (subjectRepository.findByNameIgnoreCase(request.getName().trim()).isPresent()) {
|
||||
String errorMessage = "Дисциплина с таким названием уже существует";
|
||||
logger.error("Ошибка валидации: {}", errorMessage);
|
||||
return ResponseEntity.badRequest().body(Map.of("message", errorMessage));
|
||||
@@ -119,10 +118,9 @@ public class SubjectController {
|
||||
subject.getDepartmentId()
|
||||
)
|
||||
);
|
||||
} catch (Exception e){
|
||||
logger.error("Ошибка при создании дисциплины: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message", "Произошла ошибка при создании дисциплины " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("Ошибка при создании дисциплины", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,12 @@ import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherCreationRequestRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -30,17 +29,20 @@ public class TeacherCreationRequestController {
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherCreationRequestController(TeacherCreationRequestRepository teacherCreationRequestRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
BCryptPasswordEncoder passwordEncoder) {
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
BusinessTimeService businessTime) {
|
||||
this.teacherCreationRequestRepository = teacherCreationRequestRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -98,7 +100,8 @@ public class TeacherCreationRequestController {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setValidFrom(businessTime.today());
|
||||
assignment.setCreatedAt(businessTime.now());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Создан по заявке кафедры #" + request.getId());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
@@ -110,7 +113,7 @@ public class TeacherCreationRequestController {
|
||||
request.setDepartment(department);
|
||||
request.setStatus(TeacherCreationRequestStatus.APPROVED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewedAt(businessTime.now());
|
||||
request.setReviewComment(trimToNull(review.getReviewComment()));
|
||||
request.setCreatedTeacherId(teacher.getId());
|
||||
teacherCreationRequestRepository.save(request);
|
||||
@@ -132,7 +135,7 @@ public class TeacherCreationRequestController {
|
||||
}
|
||||
request.setStatus(TeacherCreationRequestStatus.REJECTED);
|
||||
request.setReviewedBy(AuthContext.currentUserId());
|
||||
request.setReviewedAt(LocalDateTime.now());
|
||||
request.setReviewedAt(businessTime.now());
|
||||
request.setReviewComment(trimToNull(review == null ? null : review.getReviewComment()));
|
||||
return ResponseEntity.ok(toResponse(teacherCreationRequestRepository.save(request)));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -25,13 +28,28 @@ public class TeacherSubjectController {
|
||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
||||
UserRepository userRepository,
|
||||
SubjectRepository subjectRepository) {
|
||||
SubjectRepository subjectRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this(teacherSubjectRepository, userRepository, subjectRepository,
|
||||
teacherDepartmentService, BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
|
||||
UserRepository userRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.teacherSubjectRepository = teacherSubjectRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -111,7 +129,11 @@ public class TeacherSubjectController {
|
||||
return departmentId != null
|
||||
&& teacher != null
|
||||
&& subject != null
|
||||
&& departmentId.equals(teacher.getDepartmentId())
|
||||
&& teacherDepartmentService.hasAssignmentAtDate(
|
||||
teacher.getId(),
|
||||
departmentId,
|
||||
businessTime.today()
|
||||
)
|
||||
&& departmentId.equals(subject.getDepartmentId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import com.magistr.app.service.TimeSlotService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -35,13 +34,16 @@ public class TimeSlotAdminController {
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
private final TimeSlotService timeSlotService;
|
||||
|
||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository,
|
||||
TimeSlotService timeSlotService) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
this.timeSlotService = timeSlotService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -151,83 +153,18 @@ public class TimeSlotAdminController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, null)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
return ResponseEntity.ok(toDto(timeSlotService.create(request)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable Long id, @RequestBody TimeSlotDto request) {
|
||||
TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null);
|
||||
if (timeSlot == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String validationError = validate(request);
|
||||
if (validationError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", validationError));
|
||||
}
|
||||
if (hasDuplicate(request, id)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
|
||||
}
|
||||
|
||||
apply(timeSlot, request);
|
||||
return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot)));
|
||||
return ResponseEntity.ok(toDto(timeSlotService.update(id, request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
if (!timeSlotRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
timeSlotRepository.deleteById(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(TimeSlotDto request) {
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
return "Номер пары должен быть больше нуля";
|
||||
}
|
||||
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
|
||||
return "Выберите сетку времени";
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
return "Время начала и окончания обязательно";
|
||||
}
|
||||
if (!request.startTime().isBefore(request.endTime())) {
|
||||
return "Время начала должно быть раньше времени окончания";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
|
||||
? request.durationMinutes()
|
||||
: (int) Duration.between(request.startTime(), request.endTime()).toMinutes();
|
||||
timeSlot.setDurationMinutes(duration);
|
||||
}
|
||||
|
||||
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
|
||||
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
|
||||
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
|
||||
.isPresent();
|
||||
timeSlotService.delete(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
}
|
||||
|
||||
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
||||
|
||||
@@ -14,20 +14,20 @@ import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import com.magistr.app.service.BusinessTimeService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@@ -38,16 +38,33 @@ public class UserController {
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository) {
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this(userRepository, passwordEncoder, departmentRepository,
|
||||
teacherDepartmentAssignmentRepository, teacherDepartmentService,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public UserController(UserRepository userRepository,
|
||||
BCryptPasswordEncoder passwordEncoder,
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentAssignmentRepository teacherDepartmentAssignmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService,
|
||||
BusinessTimeService businessTime) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentAssignmentRepository = teacherDepartmentAssignmentRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -82,7 +99,10 @@ public class UserController {
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
logger.info("Получен запрос на получение преподавателей для кафедры с ID - {}", departmentId);
|
||||
List<User> users = teachersForDepartment(departmentId, LocalDate.now());
|
||||
List<User> users = teacherDepartmentService.findTeachersForDepartmentAtDate(
|
||||
departmentId,
|
||||
businessTime.today()
|
||||
);
|
||||
|
||||
if (users.isEmpty()) {
|
||||
logger.info("Преподаватели для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -92,7 +112,7 @@ public class UserController {
|
||||
|
||||
logger.info("Найдено {} преподавателей для кафедры с ID - {}", users.size(), departmentId);
|
||||
return ResponseEntity.ok(users.stream()
|
||||
.map(this::toUserResponse)
|
||||
.map(user -> toUserResponse(user, departmentId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -161,7 +181,7 @@ public class UserController {
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(user);
|
||||
assignment.setDepartment(requestedDepartment);
|
||||
assignment.setValidFrom(LocalDate.now());
|
||||
assignment.setValidFrom(businessTime.today());
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment("Начальная кафедра при создании пользователя");
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
@@ -180,7 +200,7 @@ public class UserController {
|
||||
logger.info("Пользователь с ID - {} не найден", id);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
user.archive("Пользователь архивирован");
|
||||
user.archive("Пользователь архивирован", businessTime.today(), businessTime.now());
|
||||
userRepository.save(user);
|
||||
logger.info("Пользователь с ID - {} успешно архивирован", id);
|
||||
return ResponseEntity.ok(Map.of("message", "Пользователь архивирован"));
|
||||
@@ -206,7 +226,11 @@ public class UserController {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser != null
|
||||
&& currentUser.role() == Role.DEPARTMENT
|
||||
&& !teacherDepartmentAssignmentRepository.existsActiveAssignment(id, currentUser.departmentId(), LocalDate.now())) {
|
||||
&& !teacherDepartmentService.hasAssignmentAtDate(
|
||||
id,
|
||||
currentUser.departmentId(),
|
||||
businessTime.today()
|
||||
)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра истории этого преподавателя"));
|
||||
}
|
||||
@@ -216,46 +240,13 @@ public class UserController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/department-transfer")
|
||||
@Transactional
|
||||
public ResponseEntity<?> transferTeacher(@PathVariable Long id, @RequestBody DepartmentTransferRequest request) {
|
||||
User teacher = userRepository.findById(id).orElse(null);
|
||||
if (teacher == null || teacher.getRole() != Role.TEACHER) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
|
||||
}
|
||||
if (teacher.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Архивного преподавателя нельзя перевести"));
|
||||
}
|
||||
if (request.departmentId() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Кафедра обязательна"));
|
||||
}
|
||||
Department department = departmentRepository.findById(request.departmentId()).orElse(null);
|
||||
if (department == null || department.isArchivedRecord()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "Активная кафедра не найдена"));
|
||||
}
|
||||
LocalDate validFrom = request.validFrom() == null ? LocalDate.now() : request.validFrom();
|
||||
|
||||
teacherDepartmentAssignmentRepository.findOpenPrimaryByTeacherId(id)
|
||||
.ifPresent(current -> {
|
||||
if (current.getValidFrom().isBefore(validFrom)) {
|
||||
current.setValidTo(validFrom.minusDays(1));
|
||||
teacherDepartmentAssignmentRepository.save(current);
|
||||
} else {
|
||||
teacherDepartmentAssignmentRepository.delete(current);
|
||||
}
|
||||
});
|
||||
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(validFrom);
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment(request.comment());
|
||||
assignment.setCreatedBy(AuthContext.currentUserId());
|
||||
teacherDepartmentAssignmentRepository.save(assignment);
|
||||
|
||||
teacher.setDepartmentId(department.getId());
|
||||
userRepository.save(teacher);
|
||||
|
||||
TeacherDepartmentAssignment assignment = teacherDepartmentService.transferTeacher(
|
||||
id,
|
||||
request,
|
||||
businessTime.today(),
|
||||
AuthContext.currentUserId()
|
||||
);
|
||||
return ResponseEntity.ok(toAssignmentDto(assignment));
|
||||
}
|
||||
|
||||
@@ -268,16 +259,25 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", "Недостаточно прав для просмотра преподавателей этой кафедры"));
|
||||
}
|
||||
LocalDate effectiveDate = date == null ? LocalDate.now() : date;
|
||||
return ResponseEntity.ok(teachersForDepartment(departmentId, effectiveDate).stream()
|
||||
.map(this::toUserResponse)
|
||||
LocalDate effectiveDate = date == null ? businessTime.today() : date;
|
||||
return ResponseEntity.ok(teacherDepartmentService
|
||||
.findTeachersForDepartmentAtDate(departmentId, effectiveDate).stream()
|
||||
.map(user -> toUserResponse(user, departmentId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user) {
|
||||
String departmentName = user.getDepartmentId() == null
|
||||
Long departmentId = user.getRole() == Role.TEACHER
|
||||
? teacherDepartmentService.findPrimaryDepartmentIdAtDate(user.getId(), businessTime.today())
|
||||
.orElse(user.getDepartmentId())
|
||||
: user.getDepartmentId();
|
||||
return toUserResponse(user, departmentId);
|
||||
}
|
||||
|
||||
private UserResponse toUserResponse(User user, Long departmentId) {
|
||||
String departmentName = departmentId == null
|
||||
? null
|
||||
: departmentRepository.findById(user.getDepartmentId())
|
||||
: departmentRepository.findById(departmentId)
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Неизвестно");
|
||||
UserResponse response = new UserResponse(
|
||||
@@ -288,7 +288,7 @@ public class UserController {
|
||||
user.getJobTitle(),
|
||||
departmentName
|
||||
);
|
||||
response.setDepartmentId(user.getDepartmentId());
|
||||
response.setDepartmentId(departmentId);
|
||||
response.setStatus(user.getStatus());
|
||||
return response;
|
||||
}
|
||||
@@ -307,23 +307,6 @@ public class UserController {
|
||||
);
|
||||
}
|
||||
|
||||
private List<User> teachersForDepartment(Long departmentId, LocalDate date) {
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
List<TeacherDepartmentAssignment> assignments = teacherDepartmentAssignmentRepository.findDepartmentTeachersAtDate(departmentId, date);
|
||||
if (assignments != null) {
|
||||
assignments.stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(User::isActiveRecord)
|
||||
.forEach(teacher -> teachersById.put(teacher.getId(), teacher));
|
||||
}
|
||||
List<User> directTeachers = userRepository.findByRoleAndDepartmentIdAndStatusNot(Role.TEACHER, departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
if (directTeachers != null) {
|
||||
directTeachers.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
}
|
||||
return List.copyOf(teachersById.values());
|
||||
}
|
||||
|
||||
private boolean canAccessDepartment(Long departmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
return currentUser == null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ClassroomResponse;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
@@ -7,11 +8,13 @@ import com.magistr.app.dto.WorkloadSummaryDto;
|
||||
import com.magistr.app.model.*;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import com.magistr.app.service.TeacherDepartmentService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
@@ -27,17 +30,17 @@ public class WorkloadController {
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final TeacherDepartmentService teacherDepartmentService;
|
||||
|
||||
public WorkloadController(ScheduleQueryService scheduleQueryService,
|
||||
ClassroomRepository classroomRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository) {
|
||||
DepartmentRepository departmentRepository,
|
||||
TeacherDepartmentService teacherDepartmentService) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.teacherDepartmentService = teacherDepartmentService;
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@@ -46,19 +49,11 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(User::getId, Function.identity()));
|
||||
return summarize(lessons, RenderedLessonDto::teacherId, RenderedLessonDto::teacherName,
|
||||
teacherId -> {
|
||||
User teacher = teachersById.get(teacherId);
|
||||
return teacher == null ? null : teacher.getDepartmentId();
|
||||
});
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarizeTeacherWorkload(lessons, startDate, endDate);
|
||||
}
|
||||
|
||||
@GetMapping("/classrooms")
|
||||
@@ -67,8 +62,10 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarize(lessons, RenderedLessonDto::classroomId, RenderedLessonDto::classroomName, ignored -> null);
|
||||
}
|
||||
|
||||
@@ -78,22 +75,20 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
Map<Long, String> departments = departmentRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(Department::getId, Department::getDepartmentName));
|
||||
Map<Long, User> teachersById = userRepository.findAllById(lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(User::getId, Function.identity()));
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
|
||||
Map<Long, List<RenderedLessonDto>> byDepartment = lessons.stream()
|
||||
.collect(Collectors.groupingBy(lesson -> {
|
||||
User teacher = teachersById.get(lesson.teacherId());
|
||||
return teacher == null ? 0L : teacher.getDepartmentId();
|
||||
}));
|
||||
.collect(Collectors.groupingBy(lesson -> departmentIdAtLesson(
|
||||
lesson,
|
||||
assignmentsByTeacher
|
||||
).orElse(0L)));
|
||||
|
||||
return byDepartment.entrySet().stream()
|
||||
.map(entry -> summary(entry.getKey(), departments.getOrDefault(entry.getKey(), "Кафедра не указана"), entry.getKey(), entry.getValue()))
|
||||
@@ -101,14 +96,76 @@ public class WorkloadController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
|
||||
assignmentsByTeacher(lessons, startDate, endDate);
|
||||
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
|
||||
.filter(lesson -> lesson.teacherId() != null)
|
||||
.collect(Collectors.groupingBy(
|
||||
lesson -> new TeacherDepartmentKey(
|
||||
lesson.teacherId(),
|
||||
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
|
||||
),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
));
|
||||
return grouped.entrySet().stream()
|
||||
.map(entry -> summary(
|
||||
entry.getKey().teacherId(),
|
||||
entry.getValue().get(0).teacherName(),
|
||||
entry.getKey().departmentId(),
|
||||
entry.getValue()
|
||||
))
|
||||
.sorted(Comparator
|
||||
.comparing(WorkloadSummaryDto::name, Comparator.nullsLast(String::compareTo))
|
||||
.thenComparing(
|
||||
WorkloadSummaryDto::departmentId,
|
||||
Comparator.nullsLast(Long::compareTo)
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
|
||||
List<RenderedLessonDto> lessons,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
Set<Long> teacherIds = lessons.stream()
|
||||
.map(RenderedLessonDto::teacherId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
return teacherDepartmentService.findAssignmentsForTeachersBetween(
|
||||
teacherIds,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<Long> departmentIdAtLesson(
|
||||
RenderedLessonDto lesson,
|
||||
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher
|
||||
) {
|
||||
if (lesson.teacherId() == null || lesson.date() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return teacherDepartmentService.resolveDepartmentIdAtDate(
|
||||
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
|
||||
lesson.date()
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/time-slots")
|
||||
public List<WorkloadSummaryDto> timeSlotWorkload(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false) Long departmentId
|
||||
) {
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.search(null, null, null, departmentId,
|
||||
null, null, null, null, startDate, endDate);
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
|
||||
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, null, startDate, endDate
|
||||
);
|
||||
return summarize(lessons, RenderedLessonDto::timeSlotId,
|
||||
lesson -> lesson.timeSlotOrder() == null
|
||||
? "Пара"
|
||||
@@ -121,8 +178,10 @@ public class WorkloadController {
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam Long timeSlotId
|
||||
) {
|
||||
Set<Long> busyClassroomIds = scheduleQueryService.search(null, null, null, null,
|
||||
null, null, timeSlotId, null, date, date)
|
||||
Long effectiveDepartmentId = effectiveDepartmentId(null);
|
||||
Set<Long> busyClassroomIds = scheduleQueryService.searchForAggregation(
|
||||
effectiveDepartmentId, timeSlotId, date, date
|
||||
)
|
||||
.stream()
|
||||
.map(RenderedLessonDto::classroomId)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -166,6 +225,9 @@ public class WorkloadController {
|
||||
if (departmentId == null) {
|
||||
return null;
|
||||
}
|
||||
if (departmentId == 0L) {
|
||||
return "Кафедра не указана";
|
||||
}
|
||||
return departmentRepository.findById(departmentId)
|
||||
.map(Department::getDepartmentName)
|
||||
.orElse("Кафедра не найдена");
|
||||
@@ -185,4 +247,35 @@ public class WorkloadController {
|
||||
new ArrayList<>(classroom.getEquipments())
|
||||
);
|
||||
}
|
||||
|
||||
private Long effectiveDepartmentId(Long requestedDepartmentId) {
|
||||
var currentUser = AuthContext.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
"Не удалось определить текущего пользователя"
|
||||
);
|
||||
}
|
||||
if (currentUser.role() != Role.DEPARTMENT) {
|
||||
return requestedDepartmentId;
|
||||
}
|
||||
|
||||
Long ownDepartmentId = currentUser.departmentId();
|
||||
if (ownDepartmentId == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Для пользователя кафедры не указана кафедра"
|
||||
);
|
||||
}
|
||||
if (requestedDepartmentId != null && !Objects.equals(requestedDepartmentId, ownDepartmentId)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Нельзя просматривать нагрузку другой кафедры"
|
||||
);
|
||||
}
|
||||
return ownDepartmentId;
|
||||
}
|
||||
|
||||
private record TeacherDepartmentKey(Long teacherId, Long departmentId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.Equipment;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public class ClassroomResponse {
|
||||
@@ -12,7 +12,7 @@ public class ClassroomResponse {
|
||||
private Integer floor;
|
||||
private Boolean isAvailable;
|
||||
private String status;
|
||||
private LocalDateTime archivedAt;
|
||||
private Instant archivedAt;
|
||||
private String archiveReason;
|
||||
private List<Equipment> equipments;
|
||||
|
||||
@@ -25,7 +25,7 @@ public class ClassroomResponse {
|
||||
}
|
||||
|
||||
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
|
||||
Boolean isAvailable, String status, LocalDateTime archivedAt,
|
||||
Boolean isAvailable, String status, Instant archivedAt,
|
||||
String archiveReason, List<Equipment> equipments) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
@@ -95,11 +95,11 @@ public class ClassroomResponse {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
public Instant getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
public void setArchivedAt(Instant archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record ScheduleOverrideDto(
|
||||
Long id,
|
||||
@@ -14,6 +14,6 @@ public record ScheduleOverrideDto(
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
LocalDateTime createdAt
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record SubjectCommentDto(
|
||||
Long id,
|
||||
@@ -8,6 +8,6 @@ public record SubjectCommentDto(
|
||||
Long authorId,
|
||||
String authorName,
|
||||
String comment,
|
||||
LocalDateTime createdAt
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
public record TeacherCreationRequestResponse(
|
||||
Long id,
|
||||
@@ -12,9 +12,9 @@ public record TeacherCreationRequestResponse(
|
||||
String comment,
|
||||
String status,
|
||||
Long requestedBy,
|
||||
LocalDateTime createdAt,
|
||||
Instant createdAt,
|
||||
Long reviewedBy,
|
||||
LocalDateTime reviewedAt,
|
||||
Instant reviewedAt,
|
||||
String reviewComment,
|
||||
Long createdTeacherId
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_login_attempt_audit")
|
||||
public class AuthLoginAttemptAudit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String tenant;
|
||||
|
||||
@Column(name = "username_normalized", nullable = false, length = 100)
|
||||
private String usernameNormalized;
|
||||
|
||||
@Column(name = "client_ip", nullable = false, length = 64)
|
||||
private String clientIp;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "occurred_at", nullable = false)
|
||||
private Instant occurredAt;
|
||||
|
||||
@Column(name = "retry_after_seconds")
|
||||
private Integer retryAfterSeconds;
|
||||
|
||||
public AuthLoginAttemptAudit() {
|
||||
}
|
||||
|
||||
public AuthLoginAttemptAudit(String tenant,
|
||||
String usernameNormalized,
|
||||
String clientIp,
|
||||
String outcome,
|
||||
Instant occurredAt,
|
||||
Integer retryAfterSeconds) {
|
||||
this.tenant = tenant;
|
||||
this.usernameNormalized = usernameNormalized;
|
||||
this.clientIp = clientIp;
|
||||
this.outcome = outcome;
|
||||
this.occurredAt = occurredAt;
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public String getTenant() {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public String getUsernameNormalized() {
|
||||
return usernameNormalized;
|
||||
}
|
||||
|
||||
public String getClientIp() {
|
||||
return clientIp;
|
||||
}
|
||||
|
||||
public String getOutcome() {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public Instant getOccurredAt() {
|
||||
return occurredAt;
|
||||
}
|
||||
|
||||
public Integer getRetryAfterSeconds() {
|
||||
return retryAfterSeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_login_rate_limits")
|
||||
public class AuthLoginRateLimit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String tenant;
|
||||
|
||||
@Column(name = "username_normalized", nullable = false, length = 100)
|
||||
private String usernameNormalized;
|
||||
|
||||
@Column(name = "client_ip", nullable = false, length = 64)
|
||||
private String clientIp;
|
||||
|
||||
@Column(name = "failure_count", nullable = false)
|
||||
private int failureCount;
|
||||
|
||||
@Column(name = "window_started_at", nullable = false)
|
||||
private Instant windowStartedAt;
|
||||
|
||||
@Column(name = "last_failure_at")
|
||||
private Instant lastFailureAt;
|
||||
|
||||
@Column(name = "blocked_until")
|
||||
private Instant blockedUntil;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public int getFailureCount() {
|
||||
return failureCount;
|
||||
}
|
||||
|
||||
public void setFailureCount(int failureCount) {
|
||||
this.failureCount = failureCount;
|
||||
}
|
||||
|
||||
public Instant getWindowStartedAt() {
|
||||
return windowStartedAt;
|
||||
}
|
||||
|
||||
public void setWindowStartedAt(Instant windowStartedAt) {
|
||||
this.windowStartedAt = windowStartedAt;
|
||||
}
|
||||
|
||||
public Instant getLastFailureAt() {
|
||||
return lastFailureAt;
|
||||
}
|
||||
|
||||
public void setLastFailureAt(Instant lastFailureAt) {
|
||||
this.lastFailureAt = lastFailureAt;
|
||||
}
|
||||
|
||||
public Instant getBlockedUntil() {
|
||||
return blockedUntil;
|
||||
}
|
||||
|
||||
public void setBlockedUntil(Instant blockedUntil) {
|
||||
this.blockedUntil = blockedUntil;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_refresh_tokens")
|
||||
@@ -31,13 +31,13 @@ public class AuthRefreshToken {
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
private LocalDateTime issuedAt;
|
||||
private Instant issuedAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private LocalDateTime revokedAt;
|
||||
private Instant revokedAt;
|
||||
|
||||
@Column(name = "rotated_to_token_hash", length = 64)
|
||||
private String rotatedToTokenHash;
|
||||
@@ -80,27 +80,27 @@ public class AuthRefreshToken {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public LocalDateTime getIssuedAt() {
|
||||
public Instant getIssuedAt() {
|
||||
return issuedAt;
|
||||
}
|
||||
|
||||
public void setIssuedAt(LocalDateTime issuedAt) {
|
||||
public void setIssuedAt(Instant issuedAt) {
|
||||
this.issuedAt = issuedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getRevokedAt() {
|
||||
public Instant getRevokedAt() {
|
||||
return revokedAt;
|
||||
}
|
||||
|
||||
public void setRevokedAt(LocalDateTime revokedAt) {
|
||||
public void setRevokedAt(Instant revokedAt) {
|
||||
this.revokedAt = revokedAt;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ public class AuthRefreshToken {
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
public boolean isActive(LocalDateTime now) {
|
||||
public boolean isActive(Instant now) {
|
||||
return revokedAt == null && expiresAt.isAfter(now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@MappedSuperclass
|
||||
public abstract class LifecycleEntity {
|
||||
@@ -23,7 +24,7 @@ public abstract class LifecycleEntity {
|
||||
private LocalDate activeTo;
|
||||
|
||||
@Column(name = "archived_at")
|
||||
private LocalDateTime archivedAt;
|
||||
private Instant archivedAt;
|
||||
|
||||
@Column(name = "archive_reason")
|
||||
private String archiveReason;
|
||||
@@ -52,11 +53,11 @@ public abstract class LifecycleEntity {
|
||||
this.activeTo = activeTo;
|
||||
}
|
||||
|
||||
public LocalDateTime getArchivedAt() {
|
||||
public Instant getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(LocalDateTime archivedAt) {
|
||||
public void setArchivedAt(Instant archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
|
||||
@@ -92,9 +93,13 @@ public abstract class LifecycleEntity {
|
||||
}
|
||||
|
||||
public void archive(String reason) {
|
||||
archive(reason, LocalDate.now(ZoneId.of("Europe/Moscow")), Instant.now());
|
||||
}
|
||||
|
||||
public void archive(String reason, LocalDate businessDate, Instant archiveMoment) {
|
||||
status = STATUS_ARCHIVED;
|
||||
archivedAt = LocalDateTime.now();
|
||||
activeTo = LocalDate.now();
|
||||
archivedAt = archiveMoment;
|
||||
activeTo = businessDate;
|
||||
archiveReason = reason == null || reason.isBlank() ? "Архивировано пользователем" : reason.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_overrides")
|
||||
@@ -45,7 +45,7 @@ public class ScheduleOverride {
|
||||
private Long createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -123,11 +123,11 @@ public class ScheduleOverride {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class Subject extends LifecycleEntity {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 200)
|
||||
@Column(nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(name = "code")
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "subject_comments")
|
||||
@@ -24,7 +24,7 @@ public class SubjectComment {
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -54,11 +54,11 @@ public class SubjectComment {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_creation_requests")
|
||||
@@ -36,16 +36,16 @@ public class TeacherCreationRequest {
|
||||
private Long requestedBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt = LocalDateTime.now();
|
||||
private Instant updatedAt = Instant.now();
|
||||
|
||||
@Column(name = "reviewed_by")
|
||||
private Long reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_at")
|
||||
private LocalDateTime reviewedAt;
|
||||
private Instant reviewedAt;
|
||||
|
||||
@Column(name = "review_comment", columnDefinition = "TEXT")
|
||||
private String reviewComment;
|
||||
@@ -55,7 +55,7 @@ public class TeacherCreationRequest {
|
||||
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -118,19 +118,19 @@ public class TeacherCreationRequest {
|
||||
this.requestedBy = requestedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
@@ -142,11 +142,11 @@ public class TeacherCreationRequest {
|
||||
this.reviewedBy = reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedAt() {
|
||||
public Instant getReviewedAt() {
|
||||
return reviewedAt;
|
||||
}
|
||||
|
||||
public void setReviewedAt(LocalDateTime reviewedAt) {
|
||||
public void setReviewedAt(Instant reviewedAt) {
|
||||
this.reviewedAt = reviewedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.magistr.app.model;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_department_assignments")
|
||||
@@ -34,7 +34,7 @@ public class TeacherDepartmentAssignment {
|
||||
private String comment;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "created_by")
|
||||
private Long createdBy;
|
||||
@@ -91,11 +91,11 @@ public class TeacherDepartmentAssignment {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -35,8 +36,35 @@ public interface AcademicCalendarDayRepository extends JpaRepository<AcademicCal
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select day
|
||||
from AcademicCalendarDay day
|
||||
join fetch day.academicCalendar
|
||||
join fetch day.activityType
|
||||
where day.academicCalendar.id in :calendarIds
|
||||
and day.date between :startDate and :endDate
|
||||
order by day.academicCalendar.id, day.courseNumber, day.date
|
||||
""")
|
||||
List<AcademicCalendarDay> findForScheduleBatch(
|
||||
@Param("calendarIds") Collection<Long> calendarIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
|
||||
boolean existsByActivityTypeId(Long activityTypeId);
|
||||
|
||||
boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber);
|
||||
|
||||
@Query("""
|
||||
select case when count(day) > 0 then true else false end
|
||||
from AcademicCalendarDay day
|
||||
where day.academicCalendar.id = :calendarId
|
||||
and (day.date < :startDate or day.date > :endDate)
|
||||
""")
|
||||
boolean existsOutsideDateRange(@Param("calendarId") Long calendarId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Modifying
|
||||
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
|
||||
void deleteByCalendarId(@Param("calendarId") Long calendarId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
@@ -40,4 +42,16 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetails(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select calendar
|
||||
from AcademicCalendar calendar
|
||||
join fetch calendar.academicYear
|
||||
join fetch calendar.speciality
|
||||
join fetch calendar.specialtyProfile
|
||||
join fetch calendar.studyForm
|
||||
where calendar.id = :id
|
||||
""")
|
||||
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -31,4 +31,6 @@ public interface AcademicCalendarSubjectRepository extends JpaRepository<Academi
|
||||
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
|
||||
|
||||
void deleteByAcademicCalendar_Id(Long calendarId);
|
||||
|
||||
boolean existsByAcademicCalendarIdAndSemesterNumberGreaterThan(Long calendarId, Integer semesterNumber);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AcademicYearRepository extends JpaRepository<AcademicYear, Long> {
|
||||
|
||||
Optional<AcademicYear> findByTitle(String title);
|
||||
|
||||
boolean existsByTitle(String title);
|
||||
|
||||
boolean existsByTitleAndIdNot(String title, Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select year from AcademicYear year where year.id = :id")
|
||||
Optional<AcademicYear> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select case when count(year) > 0 then true else false end
|
||||
from AcademicYear year
|
||||
where year.startDate <= :endDate
|
||||
and year.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlapping(@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select case when count(year) > 0 then true else false end
|
||||
from AcademicYear year
|
||||
where year.id <> :excludedId
|
||||
and year.startDate <= :endDate
|
||||
and year.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlappingExcluding(@Param("excludedId") Long excludedId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AuthLoginAttemptAudit;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface AuthLoginAttemptAuditRepository extends JpaRepository<AuthLoginAttemptAudit, Long> {
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_login_attempt_audit
|
||||
WHERE occurred_at < :cutoff
|
||||
ORDER BY occurred_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_login_attempt_audit audit
|
||||
USING cleanup_candidates candidate
|
||||
WHERE audit.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.AuthLoginRateLimit;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthLoginRateLimitRepository extends JpaRepository<AuthLoginRateLimit, Long> {
|
||||
|
||||
@Modifying(flushAutomatically = true)
|
||||
@Query(value = """
|
||||
INSERT INTO auth_login_rate_limits (
|
||||
tenant, username_normalized, client_ip, failure_count,
|
||||
window_started_at, updated_at
|
||||
) VALUES (:tenant, :username, :clientIp, 0, :now, :now)
|
||||
ON CONFLICT (tenant, username_normalized, client_ip) DO NOTHING
|
||||
""", nativeQuery = true)
|
||||
int ensureExists(@Param("tenant") String tenant,
|
||||
@Param("username") String username,
|
||||
@Param("clientIp") String clientIp,
|
||||
@Param("now") Instant now);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
SELECT limit
|
||||
FROM AuthLoginRateLimit limit
|
||||
WHERE limit.tenant = :tenant
|
||||
AND limit.usernameNormalized = :username
|
||||
AND limit.clientIp = :clientIp
|
||||
""")
|
||||
Optional<AuthLoginRateLimit> findForUpdate(@Param("tenant") String tenant,
|
||||
@Param("username") String username,
|
||||
@Param("clientIp") String clientIp);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_login_rate_limits
|
||||
WHERE updated_at < :cutoff
|
||||
AND (blocked_until IS NULL OR blocked_until <= :now)
|
||||
ORDER BY updated_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_login_rate_limits rate_limit
|
||||
USING cleanup_candidates candidate
|
||||
WHERE rate_limit.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteStaleBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("now") Instant now,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import com.magistr.app.model.AuthRefreshToken;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshToken, Long> {
|
||||
@@ -21,4 +24,27 @@ public interface AuthRefreshTokenRepository extends JpaRepository<AuthRefreshTok
|
||||
WHERE token.tokenHash = :tokenHash
|
||||
""")
|
||||
Optional<AuthRefreshToken> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
|
||||
|
||||
/**
|
||||
* Удаляет ограниченную пачку старых audit-записей. SKIP LOCKED позволяет нескольким
|
||||
* backend-pod безопасно разбирать непересекающиеся пачки одной tenant-БД.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(value = """
|
||||
WITH cleanup_candidates AS (
|
||||
SELECT id
|
||||
FROM auth_refresh_tokens
|
||||
WHERE expires_at < :cutoff
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < :cutoff)
|
||||
ORDER BY LEAST(expires_at, COALESCE(revoked_at, expires_at)), id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :batchSize
|
||||
)
|
||||
DELETE FROM auth_refresh_tokens token
|
||||
USING cleanup_candidates candidate
|
||||
WHERE token.id = candidate.id
|
||||
""", nativeQuery = true)
|
||||
int deleteCleanupBatch(@Param("cutoff") Instant cutoff,
|
||||
@Param("batchSize") int batchSize);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
|
||||
@@ -14,4 +19,15 @@ public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||
List<StudentGroup> findByStatusNot(String status);
|
||||
|
||||
List<StudentGroup> findByDepartmentIdAndStatusNot(Long departmentId, String status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select studentGroup
|
||||
from StudentGroup studentGroup
|
||||
join fetch studentGroup.educationForm
|
||||
join fetch studentGroup.speciality
|
||||
join fetch studentGroup.specialtyProfile
|
||||
where studentGroup.id = :id
|
||||
""")
|
||||
Optional<StudentGroup> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Collection;
|
||||
|
||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||
|
||||
@@ -60,6 +61,54 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
@Param("semesterId") Long semesterId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups matchingGroup
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where matchingGroup.id in :groupIds
|
||||
and sem.id in :semesterIds
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdsAndSemesterIds(
|
||||
@Param("groupIds") Collection<Long> groupIds,
|
||||
@Param("semesterIds") Collection<Long> semesterIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id in :semesterIds
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterIds(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("semesterIds") Collection<Long> semesterIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
|
||||
@@ -9,4 +9,6 @@ public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSl
|
||||
|
||||
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
|
||||
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
||||
|
||||
boolean existsByTimeSlotId(Long timeSlotId);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,51 @@ public interface SemesterRepository extends JpaRepository<Semester, Long> {
|
||||
|
||||
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select semester
|
||||
from Semester semester
|
||||
join fetch semester.academicYear
|
||||
where semester.startDate <= :endDate
|
||||
and semester.endDate >= :startDate
|
||||
order by semester.startDate, semester.id
|
||||
""")
|
||||
List<Semester> findOverlappingForSchedule(@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
|
||||
|
||||
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
|
||||
|
||||
boolean existsByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
|
||||
|
||||
boolean existsByAcademicYearIdAndSemesterTypeAndIdNot(Long academicYearId,
|
||||
SemesterType semesterType,
|
||||
Long id);
|
||||
|
||||
@Query("""
|
||||
select case when count(semester) > 0 then true else false end
|
||||
from Semester semester
|
||||
where semester.academicYear.id = :academicYearId
|
||||
and semester.startDate <= :endDate
|
||||
and semester.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlapping(@Param("academicYearId") Long academicYearId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select case when count(semester) > 0 then true else false end
|
||||
from Semester semester
|
||||
where semester.academicYear.id = :academicYearId
|
||||
and semester.id <> :excludedId
|
||||
and semester.startDate <= :endDate
|
||||
and semester.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlappingExcluding(@Param("academicYearId") Long academicYearId,
|
||||
@Param("excludedId") Long excludedId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select semester from Semester semester where semester.id = :id")
|
||||
Optional<Semester> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Collection;
|
||||
|
||||
public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<StudentGroupCalendarAssignment, Long> {
|
||||
|
||||
@@ -51,4 +54,52 @@ public interface StudentGroupCalendarAssignmentRepository extends JpaRepository<
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("academicYearId") Long academicYearId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select assignment
|
||||
from StudentGroupCalendarAssignment assignment
|
||||
join fetch assignment.studentGroup
|
||||
join fetch assignment.academicYear
|
||||
join fetch assignment.academicCalendar
|
||||
where assignment.studentGroup.id in :groupIds
|
||||
and assignment.academicYear.id in :academicYearIds
|
||||
""")
|
||||
List<StudentGroupCalendarAssignment> findForScheduleBatch(
|
||||
@Param("groupIds") Collection<Long> groupIds,
|
||||
@Param("academicYearIds") Collection<Long> academicYearIds
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select assignment
|
||||
from StudentGroupCalendarAssignment assignment
|
||||
join fetch assignment.studentGroup studentGroup
|
||||
join fetch studentGroup.educationForm
|
||||
join fetch studentGroup.speciality
|
||||
join fetch studentGroup.specialtyProfile
|
||||
join fetch assignment.academicYear
|
||||
join fetch assignment.academicCalendar calendar
|
||||
join fetch calendar.speciality
|
||||
join fetch calendar.specialtyProfile
|
||||
join fetch calendar.studyForm
|
||||
where calendar.id = :calendarId
|
||||
order by assignment.id
|
||||
""")
|
||||
List<StudentGroupCalendarAssignment> findByCalendarIdWithDetails(@Param("calendarId") Long calendarId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select assignment
|
||||
from StudentGroupCalendarAssignment assignment
|
||||
join fetch assignment.academicYear
|
||||
join fetch assignment.academicCalendar calendar
|
||||
join fetch calendar.speciality
|
||||
join fetch calendar.specialtyProfile
|
||||
join fetch calendar.studyForm
|
||||
where assignment.studentGroup.id = :groupId
|
||||
and assignment.academicYear.id = :academicYearId
|
||||
""")
|
||||
Optional<StudentGroupCalendarAssignment> findByGroupIdAndAcademicYearIdForUpdate(
|
||||
@Param("groupId") Long groupId,
|
||||
@Param("academicYearId") Long academicYearId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SubjectRepository extends JpaRepository<Subject, Long> {
|
||||
Optional<Subject> findByName(String name);
|
||||
Optional<Subject> findByNameIgnoreCase(String name);
|
||||
|
||||
List<Subject> findByDepartmentId(Long departmentId);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public interface TeacherDepartmentAssignmentRepository extends JpaRepository<TeacherDepartmentAssignment, Long> {
|
||||
|
||||
@@ -60,4 +63,46 @@ public interface TeacherDepartmentAssignmentRepository extends JpaRepository<Tea
|
||||
@Param("departmentId") Long departmentId,
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select a
|
||||
from TeacherDepartmentAssignment a
|
||||
join fetch a.department
|
||||
where a.teacher.id = :teacherId
|
||||
and a.primaryAssignment = true
|
||||
and a.validFrom <= :date
|
||||
and (a.validTo is null or a.validTo >= :date)
|
||||
order by a.validFrom desc, a.id desc
|
||||
""")
|
||||
Optional<TeacherDepartmentAssignment> findPrimaryAtDate(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("date") LocalDate date
|
||||
);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select a
|
||||
from TeacherDepartmentAssignment a
|
||||
join fetch a.department
|
||||
where a.teacher.id = :teacherId
|
||||
and a.primaryAssignment = true
|
||||
order by a.validFrom, a.id
|
||||
""")
|
||||
List<TeacherDepartmentAssignment> findPrimaryHistoryForUpdate(@Param("teacherId") Long teacherId);
|
||||
|
||||
@Query("""
|
||||
select a
|
||||
from TeacherDepartmentAssignment a
|
||||
join fetch a.teacher
|
||||
join fetch a.department
|
||||
where a.teacher.id in :teacherIds
|
||||
and a.validFrom <= :endDate
|
||||
and (a.validTo is null or a.validTo >= :startDate)
|
||||
order by a.teacher.id, a.validFrom, a.id
|
||||
""")
|
||||
List<TeacherDepartmentAssignment> findForTeachersBetween(
|
||||
@Param("teacherIds") Set<Long> teacherIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
@@ -15,5 +17,15 @@ public interface TimeSlotDateAssignmentRepository extends JpaRepository<TimeSlot
|
||||
|
||||
List<TimeSlotDateAssignment> findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
@Query("""
|
||||
select assignment
|
||||
from TimeSlotDateAssignment assignment
|
||||
join fetch assignment.timeSlotScope
|
||||
where assignment.date between :startDate and :endDate
|
||||
order by assignment.date
|
||||
""")
|
||||
List<TimeSlotDateAssignment> findForScheduleRange(@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
boolean existsByTimeSlotScopeId(Long scopeId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalTime;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -14,5 +20,45 @@ public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
|
||||
|
||||
Optional<TimeSlot> findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
|
||||
|
||||
@Query("""
|
||||
select slot
|
||||
from TimeSlot slot
|
||||
join fetch slot.timeSlotScope
|
||||
order by slot.timeSlotScope.displayOrder, slot.orderNumber, slot.startTime
|
||||
""")
|
||||
List<TimeSlot> findAllForSchedule();
|
||||
|
||||
boolean existsByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
|
||||
|
||||
boolean existsByTimeSlotScopeIdAndOrderNumberAndIdNot(Long scopeId, Integer orderNumber, Long id);
|
||||
|
||||
boolean existsByTimeSlotScopeId(Long scopeId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select slot from TimeSlot slot where slot.id = :id")
|
||||
Optional<TimeSlot> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select case when count(slot) > 0 then true else false end
|
||||
from TimeSlot slot
|
||||
where slot.timeSlotScope.id = :scopeId
|
||||
and slot.startTime < :endTime
|
||||
and slot.endTime > :startTime
|
||||
""")
|
||||
boolean existsOverlapping(@Param("scopeId") Long scopeId,
|
||||
@Param("startTime") LocalTime startTime,
|
||||
@Param("endTime") LocalTime endTime);
|
||||
|
||||
@Query("""
|
||||
select case when count(slot) > 0 then true else false end
|
||||
from TimeSlot slot
|
||||
where slot.timeSlotScope.id = :scopeId
|
||||
and slot.id <> :excludedId
|
||||
and slot.startTime < :endTime
|
||||
and slot.endTime > :startTime
|
||||
""")
|
||||
boolean existsOverlappingExcluding(@Param("scopeId") Long scopeId,
|
||||
@Param("excludedId") Long excludedId,
|
||||
@Param("startTime") LocalTime startTime,
|
||||
@Param("endTime") LocalTime endTime);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TimeSlotScope;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -15,4 +19,10 @@ public interface TimeSlotScopeRepository extends JpaRepository<TimeSlotScope, Lo
|
||||
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
|
||||
|
||||
Optional<TimeSlotScope> findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek);
|
||||
|
||||
List<TimeSlotScope> findByApplyModeOrderByDisplayOrderAsc(String applyMode);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select scope from TimeSlotScope scope where scope.id = :id")
|
||||
Optional<TimeSlotScope> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.User;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -20,4 +24,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
List<User> findByRoleAndStatusNot(Role role, String status);
|
||||
|
||||
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select user from User user where user.id = :id")
|
||||
Optional<User> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class AcademicDateService {
|
||||
@@ -33,6 +35,64 @@ public class AcademicDateService {
|
||||
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date);
|
||||
}
|
||||
|
||||
public List<Semester> findSemesters(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
|
||||
return List.of();
|
||||
}
|
||||
return semesterRepository.findOverlappingForSchedule(startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает календарные данные одним набором batch-запросов и возвращает снимок,
|
||||
* который не обращается к репозиториям во внутренних циклах генератора.
|
||||
*/
|
||||
public ScheduleSnapshot createScheduleSnapshot(Collection<StudentGroup> groups,
|
||||
Collection<Semester> semesters,
|
||||
LocalDate endDate) {
|
||||
List<Semester> semesterSnapshot = Optional.ofNullable(semesters)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.sorted(Comparator.comparing(Semester::getStartDate).thenComparing(Semester::getId))
|
||||
.toList();
|
||||
LocalDate snapshotStart = semesterSnapshot.stream()
|
||||
.map(Semester::getStartDate)
|
||||
.min(LocalDate::compareTo)
|
||||
.orElse(endDate);
|
||||
|
||||
Set<Long> groupIds = Optional.ofNullable(groups)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(StudentGroup::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> academicYearIds = semesterSnapshot.stream()
|
||||
.map(Semester::getAcademicYear)
|
||||
.filter(Objects::nonNull)
|
||||
.map(AcademicYear::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
|
||||
List<StudentGroupCalendarAssignment> assignments = groupIds.isEmpty() || academicYearIds.isEmpty()
|
||||
? List.of()
|
||||
: assignmentRepository.findForScheduleBatch(groupIds, academicYearIds);
|
||||
Set<Long> calendarIds = assignments.stream()
|
||||
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
|
||||
.filter(Objects::nonNull)
|
||||
.map(AcademicCalendar::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<AcademicCalendarDay> calendarDays = calendarIds.isEmpty()
|
||||
|| snapshotStart == null
|
||||
|| endDate == null
|
||||
|| endDate.isBefore(snapshotStart)
|
||||
? List.of()
|
||||
: calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate);
|
||||
|
||||
return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate);
|
||||
}
|
||||
|
||||
public int getWeekNumber(Semester semester, LocalDate date) {
|
||||
long days = ChronoUnit.DAYS.between(semester.getStartDate(), date);
|
||||
if (days < 0) {
|
||||
@@ -86,4 +146,128 @@ public class AcademicDateService {
|
||||
}
|
||||
return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent();
|
||||
}
|
||||
|
||||
public static final class ScheduleSnapshot {
|
||||
|
||||
private final Map<LocalDate, Semester> semesterByDate;
|
||||
private final Map<GroupAcademicYearKey, StudentGroupCalendarAssignment> assignmentByGroupAndYear;
|
||||
private final Map<CalendarDayKey, AcademicCalendarActivityType> activityByCalendarCourseAndDate;
|
||||
private final LocalDate startDate;
|
||||
private final LocalDate endDate;
|
||||
|
||||
private ScheduleSnapshot(Collection<Semester> semesters,
|
||||
Collection<StudentGroupCalendarAssignment> assignments,
|
||||
Collection<AcademicCalendarDay> calendarDays,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
this.semesterByDate = indexSemesters(semesters, startDate, endDate);
|
||||
this.assignmentByGroupAndYear = assignments.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
assignment -> new GroupAcademicYearKey(
|
||||
assignment.getStudentGroup().getId(),
|
||||
assignment.getAcademicYear().getId()
|
||||
),
|
||||
Function.identity()
|
||||
));
|
||||
this.activityByCalendarCourseAndDate = calendarDays.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
day -> new CalendarDayKey(
|
||||
day.getAcademicCalendar().getId(),
|
||||
day.getCourseNumber(),
|
||||
day.getDate()
|
||||
),
|
||||
AcademicCalendarDay::getActivityType
|
||||
));
|
||||
}
|
||||
|
||||
public Optional<Semester> findSemester(LocalDate date) {
|
||||
return Optional.ofNullable(semesterByDate.get(date));
|
||||
}
|
||||
|
||||
public boolean hasCalendarAssignment(StudentGroup group, Semester semester) {
|
||||
return findAssignment(group, semester).isPresent();
|
||||
}
|
||||
|
||||
public Optional<AcademicCalendarActivityType> getActivityType(StudentGroup group,
|
||||
Semester semester,
|
||||
LocalDate date) {
|
||||
int courseNumber = courseNumber(group, date);
|
||||
if (courseNumber < 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return findAssignment(group, semester)
|
||||
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
|
||||
.map(AcademicCalendar::getId)
|
||||
.map(calendarId -> activityByCalendarCourseAndDate.get(
|
||||
new CalendarDayKey(calendarId, courseNumber, date)
|
||||
));
|
||||
}
|
||||
|
||||
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
|
||||
return getActivityType(group, semester, date)
|
||||
.map(AcademicCalendarActivityType::getAllowSchedule)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public LocalDate startDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public LocalDate endDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
private Optional<StudentGroupCalendarAssignment> findAssignment(StudentGroup group, Semester semester) {
|
||||
if (group == null
|
||||
|| group.getId() == null
|
||||
|| semester == null
|
||||
|| semester.getAcademicYear() == null
|
||||
|| semester.getAcademicYear().getId() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(assignmentByGroupAndYear.get(
|
||||
new GroupAcademicYearKey(group.getId(), semester.getAcademicYear().getId())
|
||||
));
|
||||
}
|
||||
|
||||
private static Map<LocalDate, Semester> indexSemesters(Collection<Semester> semesters,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<LocalDate, Semester> result = new HashMap<>();
|
||||
for (Semester semester : semesters) {
|
||||
LocalDate effectiveStart = semester.getStartDate().isAfter(startDate)
|
||||
? semester.getStartDate()
|
||||
: startDate;
|
||||
LocalDate effectiveEnd = semester.getEndDate().isBefore(endDate)
|
||||
? semester.getEndDate()
|
||||
: endDate;
|
||||
for (LocalDate date = effectiveStart; !date.isAfter(effectiveEnd); date = date.plusDays(1)) {
|
||||
Semester previous = result.putIfAbsent(date, semester);
|
||||
if (previous != null && !Objects.equals(previous.getId(), semester.getId())) {
|
||||
throw new IllegalStateException("На одну дату назначено несколько семестров");
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
}
|
||||
|
||||
private static int courseNumber(StudentGroup group, LocalDate date) {
|
||||
if (group == null || date == null || group.getYearStartStudy() == null) {
|
||||
return 0;
|
||||
}
|
||||
int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1;
|
||||
return academicYearStart - group.getYearStartStudy() + 1;
|
||||
}
|
||||
|
||||
private record GroupAcademicYearKey(Long groupId, Long academicYearId) {
|
||||
}
|
||||
|
||||
private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicYearDto;
|
||||
import com.magistr.app.dto.SemesterDto;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@Service
|
||||
public class AcademicPeriodService {
|
||||
|
||||
private static final String YEAR_CONFLICT_MESSAGE =
|
||||
"Название или период учебного года конфликтует с существующим учебным годом";
|
||||
private static final String SEMESTER_CONFLICT_MESSAGE =
|
||||
"Тип или период семестра конфликтует с существующим семестром учебного года";
|
||||
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicPeriodService(AcademicYearRepository academicYearRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicYear createYear(AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
String title = request.title().trim();
|
||||
if (academicYearRepository.existsByTitle(title)
|
||||
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
applyYear(year, title, request.startDate(), request.endDate());
|
||||
AcademicYear saved = saveYear(year);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicYear updateYear(Long id, AcademicYearDto request) {
|
||||
validateYear(request);
|
||||
AcademicYear year = lockYear(id);
|
||||
String title = request.title().trim();
|
||||
|
||||
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|
||||
|| academicYearRepository.existsOverlappingExcluding(
|
||||
id, request.startDate(), request.endDate())) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
boolean semesterOutsideNewRange = semesterRepository
|
||||
.findByAcademicYearIdOrderByStartDateAsc(id)
|
||||
.stream()
|
||||
.anyMatch(semester -> !contains(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
));
|
||||
if (semesterOutsideNewRange) {
|
||||
throw new IllegalArgumentException(
|
||||
"Новые границы учебного года не включают все его семестры"
|
||||
);
|
||||
}
|
||||
|
||||
applyYear(year, title, request.startDate(), request.endDate());
|
||||
AcademicYear saved = saveYear(year);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteYear(Long id) {
|
||||
AcademicYear year = lockYear(id);
|
||||
academicYearRepository.delete(year);
|
||||
academicYearRepository.flush();
|
||||
clearScheduleCacheAfterCommit();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Semester createSemester(Long academicYearId, SemesterDto request) {
|
||||
validateSemester(request);
|
||||
AcademicYear year = lockYear(academicYearId);
|
||||
validateSemesterWithinYear(year, request.startDate(), request.endDate());
|
||||
ensureSemesterAvailable(year.getId(), request, null);
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setAcademicYear(year);
|
||||
applySemester(semester, request);
|
||||
Semester saved = saveSemester(semester);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Semester updateSemester(Long id, SemesterDto request) {
|
||||
validateSemester(request);
|
||||
Semester snapshot = semesterRepository.findById(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
|
||||
AcademicYear year = lockYear(snapshot.getAcademicYear().getId());
|
||||
Semester semester = semesterRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
|
||||
if (!semester.getAcademicYear().getId().equals(year.getId())) {
|
||||
throw new ScheduleConflictException("Учебный год семестра был изменён конкурентно");
|
||||
}
|
||||
|
||||
validateSemesterWithinYear(year, request.startDate(), request.endDate());
|
||||
ensureSemesterAvailable(year.getId(), request, id);
|
||||
applySemester(semester, request);
|
||||
Semester saved = saveSemester(semester);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
private void validateYear(AcademicYearDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные учебного года обязательны");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название учебного года обязательно");
|
||||
}
|
||||
validateDateRange(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
"Даты начала и окончания учебного года обязательны",
|
||||
"Дата окончания учебного года не может быть раньше даты начала"
|
||||
);
|
||||
}
|
||||
|
||||
private void validateSemester(SemesterDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные семестра обязательны");
|
||||
}
|
||||
if (request.semesterType() == null) {
|
||||
throw new IllegalArgumentException("Тип семестра обязателен");
|
||||
}
|
||||
validateDateRange(
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
"Даты начала и окончания семестра обязательны",
|
||||
"Дата окончания семестра не может быть раньше даты начала"
|
||||
);
|
||||
}
|
||||
|
||||
private void validateDateRange(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String requiredMessage,
|
||||
String orderMessage) {
|
||||
if (startDate == null || endDate == null) {
|
||||
throw new IllegalArgumentException(requiredMessage);
|
||||
}
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException(orderMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSemesterWithinYear(AcademicYear year,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
if (!contains(year.getStartDate(), year.getEndDate(), startDate, endDate)) {
|
||||
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean contains(LocalDate outerStart,
|
||||
LocalDate outerEnd,
|
||||
LocalDate innerStart,
|
||||
LocalDate innerEnd) {
|
||||
return !innerStart.isBefore(outerStart) && !innerEnd.isAfter(outerEnd);
|
||||
}
|
||||
|
||||
private void ensureSemesterAvailable(Long academicYearId, SemesterDto request, Long currentId) {
|
||||
boolean duplicateType = currentId == null
|
||||
? semesterRepository.existsByAcademicYearIdAndSemesterType(
|
||||
academicYearId, request.semesterType())
|
||||
: semesterRepository.existsByAcademicYearIdAndSemesterTypeAndIdNot(
|
||||
academicYearId, request.semesterType(), currentId);
|
||||
boolean overlapping = currentId == null
|
||||
? semesterRepository.existsOverlapping(
|
||||
academicYearId, request.startDate(), request.endDate())
|
||||
: semesterRepository.existsOverlappingExcluding(
|
||||
academicYearId, currentId, request.startDate(), request.endDate());
|
||||
if (duplicateType || overlapping) {
|
||||
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private AcademicYear lockYear(Long id) {
|
||||
return academicYearRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Учебный год не найден"));
|
||||
}
|
||||
|
||||
private void applyYear(AcademicYear year,
|
||||
String title,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
year.setTitle(title);
|
||||
year.setStartDate(startDate);
|
||||
year.setEndDate(endDate);
|
||||
}
|
||||
|
||||
private void applySemester(Semester semester, SemesterDto request) {
|
||||
semester.setSemesterType(request.semesterType());
|
||||
semester.setStartDate(request.startDate());
|
||||
semester.setEndDate(request.endDate());
|
||||
}
|
||||
|
||||
private AcademicYear saveYear(AcademicYear year) {
|
||||
try {
|
||||
return academicYearRepository.saveAndFlush(year);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private Semester saveSemester(Semester semester) {
|
||||
try {
|
||||
return semesterRepository.saveAndFlush(semester);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearScheduleCacheAfterCommit() {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()
|
||||
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
scheduleGeneratorService.clearCache();
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
scheduleGeneratorService.clearCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.CreateGroupRequest;
|
||||
import com.magistr.app.dto.GroupCalendarAssignmentDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
import com.magistr.app.repository.EducationFormRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.SpecialtiesRepository;
|
||||
import com.magistr.app.repository.SpecialtyProfileRepository;
|
||||
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
@Service
|
||||
public class AcademicStructureService {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository profileRepository;
|
||||
private final EducationFormRepository educationFormRepository;
|
||||
private final GroupRepository groupRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
GroupRepository groupRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
|
||||
validateCalendarRequest(request);
|
||||
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Календарный график не найден"));
|
||||
CalendarDimensions candidate = resolveCalendarDimensions(request);
|
||||
|
||||
List<StudentGroupCalendarAssignment> assignments =
|
||||
assignmentRepository.findByCalendarIdWithDetails(id);
|
||||
boolean incompatibleAssignment = assignments.stream()
|
||||
.anyMatch(assignment -> !isCompatible(
|
||||
assignment.getStudentGroup(),
|
||||
assignment.getAcademicYear(),
|
||||
candidate.academicYear(),
|
||||
candidate.speciality(),
|
||||
candidate.profile(),
|
||||
candidate.studyForm(),
|
||||
request.courseCount()
|
||||
));
|
||||
if (incompatibleAssignment) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
id, request.courseCount())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
|
||||
);
|
||||
}
|
||||
if (calendarSubjectRepository.existsByAcademicCalendarIdAndSemesterNumberGreaterThan(
|
||||
id, request.courseCount() * 2)) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsOutsideDateRange(
|
||||
id,
|
||||
candidate.academicYear().getStartDate(),
|
||||
candidate.academicYear().getEndDate())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя изменить учебный год графика: сохранённая сетка содержит даты за его границами"
|
||||
);
|
||||
}
|
||||
|
||||
calendar.setTitle(request.title().trim());
|
||||
applyCalendarDimensions(calendar, candidate, request.courseCount());
|
||||
AcademicCalendar saved = saveCalendar(calendar);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StudentGroup updateGroup(Long id, CreateGroupRequest request) {
|
||||
validateGroupRequest(request);
|
||||
StudentGroup group = groupRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
|
||||
GroupDimensions candidate = resolveGroupDimensions(request);
|
||||
|
||||
boolean incompatibleAssignment = assignmentRepository.findByGroupIdWithDetails(id).stream()
|
||||
.anyMatch(assignment -> !isCompatible(
|
||||
request.getYearStartStudy(),
|
||||
candidate.speciality(),
|
||||
candidate.profile(),
|
||||
candidate.studyForm(),
|
||||
assignment.getAcademicYear(),
|
||||
assignment.getAcademicCalendar()
|
||||
));
|
||||
if (incompatibleAssignment) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми"
|
||||
);
|
||||
}
|
||||
|
||||
group.setName(request.getName().trim());
|
||||
group.setGroupSize(request.getGroupSize());
|
||||
group.setEducationForm(candidate.studyForm());
|
||||
group.setDepartmentId(request.getDepartmentId());
|
||||
group.setYearStartStudy(request.getYearStartStudy());
|
||||
group.setSpeciality(candidate.speciality());
|
||||
group.setSpecialtyProfile(candidate.profile());
|
||||
StudentGroup saved = saveGroup(group);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StudentGroupCalendarAssignment saveAssignment(Long groupId,
|
||||
GroupCalendarAssignmentDto request) {
|
||||
if (request == null || request.academicYearId() == null || request.calendarId() == null) {
|
||||
throw new IllegalArgumentException("Учебный год и календарный график обязательны");
|
||||
}
|
||||
StudentGroup group = groupRepository.findByIdForUpdate(groupId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Учебная группа не найдена"));
|
||||
AcademicCalendar calendar = calendarRepository.findByIdWithDetailsForUpdate(request.calendarId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Календарный график не найден"));
|
||||
AcademicYear academicYear = academicYearRepository.findByIdForUpdate(request.academicYearId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
||||
|
||||
if (!isCompatible(group, academicYear, calendar)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Календарный график не соответствует учебному году, параметрам или курсу группы"
|
||||
);
|
||||
}
|
||||
|
||||
StudentGroupCalendarAssignment assignment = assignmentRepository
|
||||
.findByGroupIdAndAcademicYearIdForUpdate(groupId, academicYear.getId())
|
||||
.orElseGet(StudentGroupCalendarAssignment::new);
|
||||
assignment.setStudentGroup(group);
|
||||
assignment.setAcademicYear(academicYear);
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
try {
|
||||
StudentGroupCalendarAssignment saved = assignmentRepository.saveAndFlush(assignment);
|
||||
clearScheduleCacheAfterCommit();
|
||||
return saved;
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Назначение календарного графика было изменено конкурентно"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCalendarRequest(AcademicCalendarDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные календарного графика");
|
||||
}
|
||||
if (request.title() == null || request.title().isBlank()) {
|
||||
throw new IllegalArgumentException("Название графика обязательно");
|
||||
}
|
||||
if (request.courseCount() == null || request.courseCount() <= 0) {
|
||||
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
|
||||
}
|
||||
if (request.courseCount() > 8) {
|
||||
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
|
||||
}
|
||||
if (request.academicYearId() == null
|
||||
|| request.specialtyId() == null
|
||||
|| request.specialtyProfileId() == null
|
||||
|| request.studyFormId() == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Учебный год, специальность, профиль и форма обучения обязательны"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGroupRequest(CreateGroupRequest request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные учебной группы");
|
||||
}
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
throw new IllegalArgumentException("Название группы обязательно");
|
||||
}
|
||||
if (request.getGroupSize() == null) {
|
||||
throw new IllegalArgumentException("Численность группы обязательна");
|
||||
}
|
||||
if (request.getEducationFormId() == null) {
|
||||
throw new IllegalArgumentException("Форма обучения обязательна");
|
||||
}
|
||||
if (request.getDepartmentId() == null || request.getDepartmentId() == 0) {
|
||||
throw new IllegalArgumentException("ID кафедры обязателен");
|
||||
}
|
||||
if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) {
|
||||
throw new IllegalArgumentException("Год начала обучения обязателен");
|
||||
}
|
||||
if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) {
|
||||
throw new IllegalArgumentException("Код специальности обязателен");
|
||||
}
|
||||
if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) {
|
||||
throw new IllegalArgumentException("Профиль обучения обязателен");
|
||||
}
|
||||
}
|
||||
|
||||
private CalendarDimensions resolveCalendarDimensions(AcademicCalendarDto request) {
|
||||
AcademicYear academicYear = academicYearRepository.findById(request.academicYearId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
|
||||
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
|
||||
SpecialtyProfile profile = resolveProfile(request.specialtyProfileId(), speciality);
|
||||
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
|
||||
return new CalendarDimensions(academicYear, speciality, profile, studyForm);
|
||||
}
|
||||
|
||||
private GroupDimensions resolveGroupDimensions(CreateGroupRequest request) {
|
||||
EducationForm studyForm = educationFormRepository.findById(request.getEducationFormId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
|
||||
Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
|
||||
SpecialtyProfile profile = resolveProfile(request.getSpecialtyProfileId(), speciality);
|
||||
return new GroupDimensions(speciality, profile, studyForm);
|
||||
}
|
||||
|
||||
private SpecialtyProfile resolveProfile(Long profileId, Speciality speciality) {
|
||||
SpecialtyProfile profile = profileRepository.findById(profileId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Профиль обучения не найден для выбранной специальности"
|
||||
));
|
||||
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Профиль обучения не найден для выбранной специальности"
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
private boolean isCompatible(StudentGroup group,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicCalendar calendar) {
|
||||
return isCompatible(
|
||||
group,
|
||||
assignmentYear,
|
||||
calendar.getAcademicYear(),
|
||||
calendar.getSpeciality(),
|
||||
calendar.getSpecialtyProfile(),
|
||||
calendar.getStudyForm(),
|
||||
calendar.getCourseCount()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isCompatible(StudentGroup group,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicYear calendarYear,
|
||||
Speciality calendarSpeciality,
|
||||
SpecialtyProfile calendarProfile,
|
||||
EducationForm calendarStudyForm,
|
||||
Integer courseCount) {
|
||||
return isCompatible(
|
||||
group.getYearStartStudy(),
|
||||
group.getSpeciality(),
|
||||
group.getSpecialtyProfile(),
|
||||
group.getEducationForm(),
|
||||
assignmentYear,
|
||||
calendarYear,
|
||||
calendarSpeciality,
|
||||
calendarProfile,
|
||||
calendarStudyForm,
|
||||
courseCount
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isCompatible(Integer yearStartStudy,
|
||||
Speciality groupSpeciality,
|
||||
SpecialtyProfile groupProfile,
|
||||
EducationForm groupStudyForm,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicCalendar calendar) {
|
||||
return isCompatible(
|
||||
yearStartStudy,
|
||||
groupSpeciality,
|
||||
groupProfile,
|
||||
groupStudyForm,
|
||||
assignmentYear,
|
||||
calendar.getAcademicYear(),
|
||||
calendar.getSpeciality(),
|
||||
calendar.getSpecialtyProfile(),
|
||||
calendar.getStudyForm(),
|
||||
calendar.getCourseCount()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isCompatible(Integer yearStartStudy,
|
||||
Speciality groupSpeciality,
|
||||
SpecialtyProfile groupProfile,
|
||||
EducationForm groupStudyForm,
|
||||
AcademicYear assignmentYear,
|
||||
AcademicYear calendarYear,
|
||||
Speciality calendarSpeciality,
|
||||
SpecialtyProfile calendarProfile,
|
||||
EducationForm calendarStudyForm,
|
||||
Integer courseCount) {
|
||||
if (!sameId(assignmentYear, calendarYear)
|
||||
|| !sameId(groupSpeciality, calendarSpeciality)
|
||||
|| !sameId(groupProfile, calendarProfile)
|
||||
|| !sameId(groupStudyForm, calendarStudyForm)) {
|
||||
return false;
|
||||
}
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(
|
||||
yearStartStudy,
|
||||
assignmentYear.getStartDate()
|
||||
);
|
||||
return course >= 1 && courseCount != null && course <= courseCount;
|
||||
}
|
||||
|
||||
private boolean sameId(AcademicYear first, AcademicYear second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private boolean sameId(Speciality first, Speciality second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private boolean sameId(SpecialtyProfile first, SpecialtyProfile second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private boolean sameId(EducationForm first, EducationForm second) {
|
||||
return first != null && second != null && first.getId().equals(second.getId());
|
||||
}
|
||||
|
||||
private void applyCalendarDimensions(AcademicCalendar calendar,
|
||||
CalendarDimensions dimensions,
|
||||
Integer courseCount) {
|
||||
calendar.setAcademicYear(dimensions.academicYear());
|
||||
calendar.setSpeciality(dimensions.speciality());
|
||||
calendar.setSpecialtyProfile(dimensions.profile());
|
||||
calendar.setStudyForm(dimensions.studyForm());
|
||||
calendar.setCourseCount(courseCount);
|
||||
}
|
||||
|
||||
private AcademicCalendar saveCalendar(AcademicCalendar calendar) {
|
||||
try {
|
||||
return calendarRepository.saveAndFlush(calendar);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Изменение календарного графика конфликтует с сохранёнными данными"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private StudentGroup saveGroup(StudentGroup group) {
|
||||
try {
|
||||
return groupRepository.saveAndFlush(group);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Изменение учебной группы конфликтует с сохранёнными данными"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearScheduleCacheAfterCommit() {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()
|
||||
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
scheduleGeneratorService.clearCache();
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
scheduleGeneratorService.clearCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record CalendarDimensions(AcademicYear academicYear,
|
||||
Speciality speciality,
|
||||
SpecialtyProfile profile,
|
||||
EducationForm studyForm) {
|
||||
}
|
||||
|
||||
private record GroupDimensions(Speciality speciality,
|
||||
SpecialtyProfile profile,
|
||||
EducationForm studyForm) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.zone.ZoneRulesException;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Единый источник бизнес-даты и абсолютного времени приложения. */
|
||||
@Service
|
||||
public class BusinessTimeService {
|
||||
|
||||
public static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Moscow");
|
||||
|
||||
private final Clock clock;
|
||||
private final ZoneId zoneId;
|
||||
|
||||
@Autowired
|
||||
public BusinessTimeService(@Value("${app.time.business-zone:Europe/Moscow}") String zoneId) {
|
||||
this(Clock.systemUTC(), parseZone(zoneId));
|
||||
}
|
||||
|
||||
public BusinessTimeService(Clock clock, ZoneId zoneId) {
|
||||
this.clock = Objects.requireNonNull(clock, "Часы приложения обязательны");
|
||||
this.zoneId = Objects.requireNonNull(zoneId, "Часовой пояс бизнес-даты обязателен");
|
||||
}
|
||||
|
||||
public LocalDate today() {
|
||||
return LocalDate.ofInstant(clock.instant(), zoneId);
|
||||
}
|
||||
|
||||
public Instant now() {
|
||||
return clock.instant();
|
||||
}
|
||||
|
||||
public ZoneId zoneId() {
|
||||
return zoneId;
|
||||
}
|
||||
|
||||
public static BusinessTimeService systemDefault() {
|
||||
return new BusinessTimeService(Clock.systemUTC(), DEFAULT_ZONE);
|
||||
}
|
||||
|
||||
private static ZoneId parseZone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value == null || value.isBlank() ? DEFAULT_ZONE.getId() : value.trim());
|
||||
} catch (ZoneRulesException exception) {
|
||||
throw new IllegalStateException("Некорректный часовой пояс бизнес-даты: " + value, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
@@ -33,6 +34,7 @@ public class ScheduleGeneratorService {
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
GroupRepository groupRepository,
|
||||
@@ -41,6 +43,20 @@ public class ScheduleGeneratorService {
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
this(scheduleRuleRepository, groupRepository, userRepository, academicDateService,
|
||||
timeSlotRepository, timeSlotScopeRepository, dateAssignmentRepository,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
GroupRepository groupRepository,
|
||||
UserRepository userRepository,
|
||||
AcademicDateService academicDateService,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository,
|
||||
BusinessTimeService businessTime) {
|
||||
this.scheduleRuleRepository = scheduleRuleRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -48,45 +64,134 @@ public class ScheduleGeneratorService {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
StudentGroup group = groupRepository.findById(groupId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
|
||||
return buildScheduleForGroupsInternal(List.of(group), startDate, endDate);
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForGroups(Collection<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
List<StudentGroup> groupSnapshot = Optional.ofNullable(groups)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
StudentGroup::getId,
|
||||
group -> group,
|
||||
(first, second) -> first,
|
||||
LinkedHashMap::new
|
||||
))
|
||||
.values()
|
||||
.stream()
|
||||
.toList();
|
||||
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
if (groups.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
|
||||
if (semesters.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> semesterIds = semesters.stream()
|
||||
.map(Semester::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> groupIds = groups.stream()
|
||||
.map(StudentGroup::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
|
||||
.createScheduleSnapshot(groups, semesters, endDate);
|
||||
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
|
||||
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester = indexRulesByGroupAndSemester(
|
||||
rules,
|
||||
groupIds
|
||||
);
|
||||
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
for (StudentGroup group : groups) {
|
||||
result.addAll(buildScheduleForGroup(
|
||||
group,
|
||||
startDate,
|
||||
endDate,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot,
|
||||
rulesByGroupAndSemester
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroup(StudentGroup group,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||
TimeSlotSnapshot timeSlotSnapshot,
|
||||
Map<GroupSemesterKey, List<ScheduleRule>> rulesByGroupAndSemester) {
|
||||
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
Set<Long> warnedAcademicYears = new HashSet<>();
|
||||
Set<Long> primedSemesterIds = new HashSet<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
|
||||
if (semesterOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
if (!academicDateService.hasCalendarAssignment(group, semester)
|
||||
if (!dateSnapshot.hasCalendarAssignment(group, semester)
|
||||
&& warnedAcademicYears.add(semester.getAcademicYear().getId())) {
|
||||
logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}",
|
||||
group.getName(), semester.getAcademicYear().getTitle());
|
||||
}
|
||||
if (!academicDateService.isTheoryDay(group, semester, date)) {
|
||||
if (!dateSnapshot.isTheoryDay(group, semester, date)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
||||
semester.getId(),
|
||||
semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId)
|
||||
List<ScheduleRule> rules = rulesByGroupAndSemester.getOrDefault(
|
||||
new GroupSemesterKey(group.getId(), semester.getId()),
|
||||
List.of()
|
||||
);
|
||||
if (primedSemesterIds.add(semester.getId())) {
|
||||
primeConsumedHoursBeforeRange(semester, rules, startDate, group, null, consumedHoursProgress);
|
||||
primeConsumedHoursBeforeRange(
|
||||
semester,
|
||||
rules,
|
||||
startDate,
|
||||
group,
|
||||
null,
|
||||
consumedHoursProgress,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
for (ScheduleRule rule : sortRules(rules)) {
|
||||
processRuleForDate(rule, date, group, null, consumedHoursProgress, result);
|
||||
processRuleForDate(
|
||||
rule,
|
||||
semester,
|
||||
date,
|
||||
group,
|
||||
null,
|
||||
consumedHoursProgress,
|
||||
result,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,28 +204,68 @@ public class ScheduleGeneratorService {
|
||||
throw new IllegalArgumentException("Преподаватель не найден");
|
||||
}
|
||||
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
|
||||
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
|
||||
if (semesters.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> semesterIds = semesters.stream()
|
||||
.map(Semester::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findByTeacherIdAndSemesterIds(teacherId, semesterIds);
|
||||
List<StudentGroup> groups = rules.stream()
|
||||
.flatMap(rule -> rule.getGroups().stream())
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
StudentGroup::getId,
|
||||
group -> group,
|
||||
(first, second) -> first,
|
||||
LinkedHashMap::new
|
||||
))
|
||||
.values()
|
||||
.stream()
|
||||
.toList();
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
|
||||
.createScheduleSnapshot(groups, semesters, endDate);
|
||||
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
|
||||
Map<Long, List<ScheduleRule>> rulesBySemester = rules.stream()
|
||||
.collect(Collectors.groupingBy(rule -> rule.getSemester().getId()));
|
||||
Map<String, Integer> consumedHoursProgress = new HashMap<>();
|
||||
List<RenderedLessonDto> result = new ArrayList<>();
|
||||
Set<Long> primedSemesterIds = new HashSet<>();
|
||||
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
Optional<Semester> semesterOpt = dateSnapshot.findSemester(date);
|
||||
if (semesterOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
List<ScheduleRule> rules = rulesBySemester.computeIfAbsent(
|
||||
semester.getId(),
|
||||
semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId)
|
||||
);
|
||||
List<ScheduleRule> semesterRules = rulesBySemester.getOrDefault(semester.getId(), List.of());
|
||||
if (primedSemesterIds.add(semester.getId())) {
|
||||
primeConsumedHoursBeforeRange(semester, rules, startDate, null, teacherId, consumedHoursProgress);
|
||||
primeConsumedHoursBeforeRange(
|
||||
semester,
|
||||
semesterRules,
|
||||
startDate,
|
||||
null,
|
||||
teacherId,
|
||||
consumedHoursProgress,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
for (ScheduleRule rule : sortRules(rules)) {
|
||||
processRuleForDate(rule, date, null, teacherId, consumedHoursProgress, result);
|
||||
for (ScheduleRule rule : sortRules(semesterRules)) {
|
||||
processRuleForDate(
|
||||
rule,
|
||||
semester,
|
||||
date,
|
||||
null,
|
||||
teacherId,
|
||||
consumedHoursProgress,
|
||||
result,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,12 +306,48 @@ public class ScheduleGeneratorService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<GroupSemesterKey, List<ScheduleRule>> indexRulesByGroupAndSemester(
|
||||
Collection<ScheduleRule> rules,
|
||||
Set<Long> requestedGroupIds
|
||||
) {
|
||||
Map<GroupSemesterKey, List<ScheduleRule>> result = new HashMap<>();
|
||||
for (ScheduleRule rule : rules) {
|
||||
Long semesterId = rule.getSemester().getId();
|
||||
for (StudentGroup group : rule.getGroups()) {
|
||||
if (!requestedGroupIds.contains(group.getId())) {
|
||||
continue;
|
||||
}
|
||||
result.computeIfAbsent(
|
||||
new GroupSemesterKey(group.getId(), semesterId),
|
||||
ignored -> new ArrayList<>()
|
||||
).add(rule);
|
||||
}
|
||||
}
|
||||
result.replaceAll((key, value) -> sortRules(value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private TimeSlotSnapshot loadTimeSlotSnapshot(LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Collection<ScheduleRule> rules) {
|
||||
if (rules.isEmpty()) {
|
||||
return TimeSlotSnapshot.empty();
|
||||
}
|
||||
return new TimeSlotSnapshot(
|
||||
dateAssignmentRepository.findForScheduleRange(startDate, endDate),
|
||||
timeSlotScopeRepository.findByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_WEEKDAY),
|
||||
timeSlotRepository.findAllForSchedule()
|
||||
);
|
||||
}
|
||||
|
||||
private void primeConsumedHoursBeforeRange(Semester semester,
|
||||
List<ScheduleRule> rules,
|
||||
LocalDate rangeStart,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
Map<String, Integer> consumedHoursProgress) {
|
||||
Map<String, Integer> consumedHoursProgress,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||
TimeSlotSnapshot timeSlotSnapshot) {
|
||||
LocalDate endExclusive = minDate(rangeStart, semester.getEndDate().plusDays(1));
|
||||
if (!semester.getStartDate().isBefore(endExclusive)) {
|
||||
return;
|
||||
@@ -175,7 +356,17 @@ public class ScheduleGeneratorService {
|
||||
List<ScheduleRule> sortedRules = sortRules(rules);
|
||||
for (LocalDate date = semester.getStartDate(); date.isBefore(endExclusive); date = date.plusDays(1)) {
|
||||
for (ScheduleRule rule : sortedRules) {
|
||||
processRuleForDate(rule, date, targetGroup, targetTeacherId, consumedHoursProgress, null);
|
||||
processRuleForDate(
|
||||
rule,
|
||||
semester,
|
||||
date,
|
||||
targetGroup,
|
||||
targetTeacherId,
|
||||
consumedHoursProgress,
|
||||
null,
|
||||
dateSnapshot,
|
||||
timeSlotSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,19 +376,21 @@ public class ScheduleGeneratorService {
|
||||
}
|
||||
|
||||
private void processRuleForDate(ScheduleRule rule,
|
||||
LocalDate date,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
Map<String, Integer> consumedHoursProgress,
|
||||
List<RenderedLessonDto> result) {
|
||||
Semester semester,
|
||||
LocalDate date,
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
Map<String, Integer> consumedHoursProgress,
|
||||
List<RenderedLessonDto> result,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot,
|
||||
TimeSlotSnapshot timeSlotSnapshot) {
|
||||
if (rule.getLectureAcademicHours() == null
|
||||
|| rule.getLaboratoryAcademicHours() == null
|
||||
|| rule.getPracticeAcademicHours() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
|
||||
if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) {
|
||||
if (semester == null || !Objects.equals(semester.getId(), rule.getSemester().getId())) {
|
||||
return;
|
||||
}
|
||||
if (!rule.getSubject().isActiveOn(date)) {
|
||||
@@ -207,8 +400,7 @@ public class ScheduleGeneratorService {
|
||||
return;
|
||||
}
|
||||
|
||||
Semester semester = semesterOpt.get();
|
||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
|
||||
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date, dateSnapshot);
|
||||
if (eligibleGroups.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +425,7 @@ public class ScheduleGeneratorService {
|
||||
if (!slot.getTeacher().isActiveOn(date) || !slot.getClassroom().isActiveOn(date)) {
|
||||
continue;
|
||||
}
|
||||
if (date.isAfter(LocalDate.now()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
|
||||
if (date.isAfter(businessTime.today()) && Boolean.FALSE.equals(slot.getClassroom().getIsAvailable())) {
|
||||
continue;
|
||||
}
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
||||
@@ -283,7 +475,7 @@ public class ScheduleGeneratorService {
|
||||
.orElse(0);
|
||||
|
||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
|
||||
totalHours, consumedHours, remainingAfterLesson));
|
||||
totalHours, consumedHours, remainingAfterLesson, timeSlotSnapshot));
|
||||
}
|
||||
for (ActiveLessonScope scope : activeScopes) {
|
||||
consumedHoursProgress.put(
|
||||
@@ -294,16 +486,20 @@ public class ScheduleGeneratorService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<StudentGroup> eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) {
|
||||
private List<StudentGroup> eligibleGroups(ScheduleRule rule,
|
||||
StudentGroup targetGroup,
|
||||
Semester semester,
|
||||
LocalDate date,
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot) {
|
||||
if (targetGroup != null) {
|
||||
return targetGroup.isActiveOn(date) && academicDateService.isTheoryDay(targetGroup, semester, date)
|
||||
return targetGroup.isActiveOn(date) && dateSnapshot.isTheoryDay(targetGroup, semester, date)
|
||||
? List.of(targetGroup)
|
||||
: List.of();
|
||||
}
|
||||
|
||||
return rule.getGroups().stream()
|
||||
.filter(group -> group.isActiveOn(date))
|
||||
.filter(group -> academicDateService.isTheoryDay(group, semester, date))
|
||||
.filter(group -> dateSnapshot.isTheoryDay(group, semester, date))
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
}
|
||||
@@ -356,6 +552,12 @@ public class ScheduleGeneratorService {
|
||||
private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
|
||||
}
|
||||
|
||||
private record GroupSemesterKey(Long groupId, Long semesterId) {
|
||||
}
|
||||
|
||||
private record ScopeOrderKey(Long scopeId, Integer orderNumber) {
|
||||
}
|
||||
|
||||
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
|
||||
ScheduleRuleSlot slot,
|
||||
LocalDate date,
|
||||
@@ -365,8 +567,9 @@ public class ScheduleGeneratorService {
|
||||
List<Subgroup> subgroups,
|
||||
int lessonTypeAcademicHours,
|
||||
int consumedBeforeLesson,
|
||||
int remainingAfterLesson) {
|
||||
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
||||
int remainingAfterLesson,
|
||||
TimeSlotSnapshot timeSlotSnapshot) {
|
||||
TimeSlot timeSlot = timeSlotSnapshot.resolve(slot.getTimeSlot(), date);
|
||||
List<StudentGroup> sortedGroups = groups.stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
.toList();
|
||||
@@ -415,23 +618,55 @@ public class ScheduleGeneratorService {
|
||||
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
|
||||
}
|
||||
|
||||
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
|
||||
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
|
||||
return baseSlot;
|
||||
}
|
||||
return effectiveScope(date)
|
||||
.flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
|
||||
scope.getId(),
|
||||
baseSlot.getOrderNumber()))
|
||||
.orElse(baseSlot);
|
||||
}
|
||||
private static final class TimeSlotSnapshot {
|
||||
|
||||
private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
|
||||
return dateAssignmentRepository.findByDate(date)
|
||||
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||
APPLY_MODE_WEEKDAY,
|
||||
date.getDayOfWeek().getValue()));
|
||||
private final Map<LocalDate, Long> dateScopeIds;
|
||||
private final Map<Integer, Long> weekdayScopeIds;
|
||||
private final Map<ScopeOrderKey, TimeSlot> slotsByScopeAndOrder;
|
||||
|
||||
private TimeSlotSnapshot(Collection<TimeSlotDateAssignment> dateAssignments,
|
||||
Collection<TimeSlotScope> weekdayScopes,
|
||||
Collection<TimeSlot> timeSlots) {
|
||||
this.dateScopeIds = dateAssignments.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
TimeSlotDateAssignment::getDate,
|
||||
assignment -> assignment.getTimeSlotScope().getId()
|
||||
));
|
||||
this.weekdayScopeIds = weekdayScopes.stream()
|
||||
.filter(scope -> scope.getDayOfWeek() != null)
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
TimeSlotScope::getDayOfWeek,
|
||||
TimeSlotScope::getId,
|
||||
(first, second) -> first
|
||||
));
|
||||
this.slotsByScopeAndOrder = timeSlots.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
slot -> new ScopeOrderKey(
|
||||
slot.getTimeSlotScope().getId(),
|
||||
slot.getOrderNumber()
|
||||
),
|
||||
slot -> slot
|
||||
));
|
||||
}
|
||||
|
||||
private static TimeSlotSnapshot empty() {
|
||||
return new TimeSlotSnapshot(List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
private TimeSlot resolve(TimeSlot baseSlot, LocalDate date) {
|
||||
if (baseSlot == null || baseSlot.getOrderNumber() == null || date == null) {
|
||||
return baseSlot;
|
||||
}
|
||||
Long scopeId = Optional.ofNullable(dateScopeIds.get(date))
|
||||
.orElseGet(() -> weekdayScopeIds.get(date.getDayOfWeek().getValue()));
|
||||
if (scopeId == null) {
|
||||
return baseSlot;
|
||||
}
|
||||
return slotsByScopeAndOrder.getOrDefault(
|
||||
new ScopeOrderKey(scopeId, baseSlot.getOrderNumber()),
|
||||
baseSlot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static String dayName(DayOfWeek dayOfWeek) {
|
||||
|
||||
@@ -41,10 +41,10 @@ public class ScheduleQueryService {
|
||||
if (date == null) {
|
||||
throw new IllegalArgumentException("Дата расписания обязательна");
|
||||
}
|
||||
List<RenderedLessonDto> generatedLessons = groupRepository.findAll().stream()
|
||||
List<StudentGroup> groups = groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, date, date))
|
||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), date, date).stream())
|
||||
.toList();
|
||||
List<RenderedLessonDto> generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, date, date);
|
||||
return deduplicate(generatedLessons);
|
||||
}
|
||||
|
||||
@@ -85,16 +85,70 @@ public class ScheduleQueryService {
|
||||
String parity,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
return searchInternal(
|
||||
groupId,
|
||||
teacherId,
|
||||
classroomId,
|
||||
departmentId,
|
||||
subjectId,
|
||||
lessonTypeId,
|
||||
timeSlotId,
|
||||
parity,
|
||||
startDate,
|
||||
endDate,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит расписание для workload-агрегатов без интерактивного ограничения 50 групп.
|
||||
* Набор поддерживаемых фильтров намеренно сужен до кафедры и временного слота, чтобы
|
||||
* этот путь не превратился во второй общий поисковый API.
|
||||
*/
|
||||
public List<RenderedLessonDto> searchForAggregation(Long departmentId,
|
||||
Long timeSlotId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
return searchInternal(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
departmentId,
|
||||
null,
|
||||
null,
|
||||
timeSlotId,
|
||||
null,
|
||||
startDate,
|
||||
endDate,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> searchInternal(Long groupId,
|
||||
Long teacherId,
|
||||
Long classroomId,
|
||||
Long departmentId,
|
||||
Long subjectId,
|
||||
Long lessonTypeId,
|
||||
Long timeSlotId,
|
||||
String parity,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
boolean enforceInteractiveGroupLimit) {
|
||||
validateRange(startDate, endDate);
|
||||
List<RenderedLessonDto> generatedLessons;
|
||||
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
|
||||
if (teacherOnlySearch) {
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
} else {
|
||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
|
||||
generatedLessons = groups.stream()
|
||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
||||
.toList();
|
||||
List<StudentGroup> groups = resolveGroups(
|
||||
groupId,
|
||||
departmentId,
|
||||
startDate,
|
||||
endDate,
|
||||
enforceInteractiveGroupLimit
|
||||
);
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate);
|
||||
}
|
||||
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
|
||||
.findByLessonDateBetweenWithDetails(startDate, endDate);
|
||||
@@ -249,7 +303,11 @@ public class ScheduleQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
|
||||
private List<StudentGroup> resolveGroups(Long groupId,
|
||||
Long departmentId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
boolean enforceInteractiveGroupLimit) {
|
||||
if (groupId != null) {
|
||||
return groupRepository.findById(groupId)
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
@@ -265,7 +323,9 @@ public class ScheduleQueryService {
|
||||
groups = groups.stream()
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
.toList();
|
||||
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||
if (enforceInteractiveGroupLimit
|
||||
&& departmentId == null
|
||||
&& groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
|
||||
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.CreateSubjectRequest;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class SubjectImportService {
|
||||
|
||||
private static final int MAX_NAME_LENGTH = 200;
|
||||
private static final int MAX_CODE_LENGTH = 20;
|
||||
|
||||
private final SubjectRepository subjectRepository;
|
||||
|
||||
public SubjectImportService(SubjectRepository subjectRepository) {
|
||||
this.subjectRepository = subjectRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<Subject> importSubjects(List<CreateSubjectRequest> requests, Long departmentId) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
throw new IllegalArgumentException("Передайте список дисциплин для загрузки");
|
||||
}
|
||||
if (departmentId == null || departmentId <= 0) {
|
||||
throw new IllegalArgumentException("Кафедра обязательна");
|
||||
}
|
||||
|
||||
List<SubjectCandidate> candidates = normalizeCandidates(requests);
|
||||
Map<String, Subject> existingByCanonicalName = new LinkedHashMap<>();
|
||||
for (SubjectCandidate candidate : candidates) {
|
||||
subjectRepository.findByNameIgnoreCase(candidate.name()).ifPresent(existing -> {
|
||||
if (!Objects.equals(existing.getDepartmentId(), departmentId)) {
|
||||
throw new ScheduleConflictException(
|
||||
"Дисциплина «" + candidate.name() + "» уже принадлежит другой кафедре"
|
||||
);
|
||||
}
|
||||
existingByCanonicalName.put(candidate.canonicalName(), existing);
|
||||
});
|
||||
}
|
||||
|
||||
List<Subject> subjects = new ArrayList<>(candidates.size());
|
||||
for (SubjectCandidate candidate : candidates) {
|
||||
Subject subject = existingByCanonicalName.getOrDefault(
|
||||
candidate.canonicalName(),
|
||||
new Subject()
|
||||
);
|
||||
subject.setName(candidate.name());
|
||||
subject.setCode(candidate.code());
|
||||
subject.setDepartmentId(departmentId);
|
||||
if (subject.isArchivedRecord()) {
|
||||
subject.restore();
|
||||
}
|
||||
subjects.add(subject);
|
||||
}
|
||||
|
||||
try {
|
||||
return subjectRepository.saveAllAndFlush(subjects);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Дисциплина с таким названием уже существует"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private List<SubjectCandidate> normalizeCandidates(List<CreateSubjectRequest> requests) {
|
||||
Map<String, SubjectCandidate> unique = new LinkedHashMap<>();
|
||||
for (CreateSubjectRequest request : requests) {
|
||||
if (request == null || request.getName() == null || request.getName().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String name = request.getName().trim();
|
||||
String code = trimToNull(request.getCode());
|
||||
if (name.length() > MAX_NAME_LENGTH) {
|
||||
throw new IllegalArgumentException("Название дисциплины не может быть длиннее 200 символов");
|
||||
}
|
||||
if (code != null && code.length() > MAX_CODE_LENGTH) {
|
||||
throw new IllegalArgumentException("Код дисциплины не может быть длиннее 20 символов");
|
||||
}
|
||||
String canonicalName = name.toLowerCase(Locale.ROOT);
|
||||
unique.put(canonicalName, new SubjectCandidate(canonicalName, name, code));
|
||||
}
|
||||
return List.copyOf(unique.values());
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private record SubjectCandidate(String canonicalName, String name, String code) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.DepartmentTransferRequest;
|
||||
import com.magistr.app.model.Department;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.TeacherDepartmentAssignment;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.DepartmentRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TeacherDepartmentService {
|
||||
|
||||
private final TeacherDepartmentAssignmentRepository assignmentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final DepartmentRepository departmentRepository;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository) {
|
||||
this(assignmentRepository, userRepository, departmentRepository,
|
||||
BusinessTimeService.systemDefault());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public TeacherDepartmentService(TeacherDepartmentAssignmentRepository assignmentRepository,
|
||||
UserRepository userRepository,
|
||||
DepartmentRepository departmentRepository,
|
||||
BusinessTimeService businessTime) {
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<User> findTeachersForDepartmentAtDate(Long departmentId, LocalDate date) {
|
||||
if (departmentId == null || date == null) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, User> teachersById = new LinkedHashMap<>();
|
||||
assignmentRepository.findDepartmentTeachersAtDate(departmentId, date).stream()
|
||||
.map(TeacherDepartmentAssignment::getTeacher)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(teacher -> teacher.getRole() == Role.TEACHER)
|
||||
.filter(teacher -> teacher.isActiveOn(date))
|
||||
.forEach(teacher -> teachersById.putIfAbsent(teacher.getId(), teacher));
|
||||
return List.copyOf(teachersById.values());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasAssignmentAtDate(Long teacherId, Long departmentId, LocalDate date) {
|
||||
return teacherId != null
|
||||
&& departmentId != null
|
||||
&& date != null
|
||||
&& assignmentRepository.existsActiveAssignment(teacherId, departmentId, date);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Long> findPrimaryDepartmentIdAtDate(Long teacherId, LocalDate date) {
|
||||
if (teacherId == null || date == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return assignmentRepository.findPrimaryAtDate(teacherId, date)
|
||||
.map(TeacherDepartmentAssignment::getDepartment)
|
||||
.filter(Objects::nonNull)
|
||||
.map(Department::getId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, List<TeacherDepartmentAssignment>> findAssignmentsForTeachersBetween(
|
||||
Collection<Long> teacherIds,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
) {
|
||||
if (teacherIds == null || teacherIds.isEmpty() || startDate == null || endDate == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Set<Long> normalizedIds = teacherIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (normalizedIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return assignmentRepository.findForTeachersBetween(normalizedIds, startDate, endDate).stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
assignment -> assignment.getTeacher().getId(),
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
));
|
||||
}
|
||||
|
||||
public Optional<Long> resolveDepartmentIdAtDate(
|
||||
Collection<TeacherDepartmentAssignment> assignments,
|
||||
LocalDate date
|
||||
) {
|
||||
if (assignments == null || date == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return assignments.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(assignment -> isActiveAt(assignment, date))
|
||||
.filter(assignment -> assignment.getDepartment() != null)
|
||||
.sorted(Comparator
|
||||
.comparing(
|
||||
(TeacherDepartmentAssignment assignment) ->
|
||||
!Boolean.TRUE.equals(assignment.getPrimaryAssignment())
|
||||
)
|
||||
.thenComparing(
|
||||
TeacherDepartmentAssignment::getValidFrom,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())
|
||||
)
|
||||
.thenComparing(
|
||||
TeacherDepartmentAssignment::getId,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())
|
||||
))
|
||||
.map(assignment -> assignment.getDepartment().getId())
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
|
||||
DepartmentTransferRequest request,
|
||||
LocalDate today,
|
||||
Long createdBy) {
|
||||
if (request == null || request.departmentId() == null) {
|
||||
throw new IllegalArgumentException("Кафедра обязательна");
|
||||
}
|
||||
LocalDate operationDate = today == null ? businessTime.today() : today;
|
||||
LocalDate validFrom = request.validFrom() == null ? operationDate : request.validFrom();
|
||||
User teacher = userRepository.findByIdForUpdate(teacherId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
if (teacher.getRole() != Role.TEACHER) {
|
||||
throw new IllegalArgumentException("Преподаватель не найден");
|
||||
}
|
||||
if (teacher.isArchivedRecord()) {
|
||||
throw new IllegalArgumentException("Архивного преподавателя нельзя перевести");
|
||||
}
|
||||
Department department = departmentRepository.findById(request.departmentId())
|
||||
.filter(candidate -> !candidate.isArchivedRecord())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Активная кафедра не найдена"));
|
||||
|
||||
List<TeacherDepartmentAssignment> primaryHistory = new ArrayList<>(
|
||||
assignmentRepository.findPrimaryHistoryForUpdate(teacherId)
|
||||
);
|
||||
TeacherDepartmentAssignment sameDate = primaryHistory.stream()
|
||||
.filter(assignment -> validFrom.equals(assignment.getValidFrom()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (sameDate != null) {
|
||||
sameDate.setDepartment(department);
|
||||
sameDate.setComment(normalizeComment(request.comment()));
|
||||
sameDate.setCreatedBy(createdBy);
|
||||
TeacherDepartmentAssignment saved = saveAssignment(sameDate);
|
||||
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
|
||||
return saved;
|
||||
}
|
||||
|
||||
primaryHistory.stream()
|
||||
.filter(assignment -> isActiveAt(assignment, validFrom))
|
||||
.findFirst()
|
||||
.ifPresent(current -> current.setValidTo(validFrom.minusDays(1)));
|
||||
|
||||
LocalDate nextValidFrom = primaryHistory.stream()
|
||||
.map(TeacherDepartmentAssignment::getValidFrom)
|
||||
.filter(date -> date != null && date.isAfter(validFrom))
|
||||
.min(LocalDate::compareTo)
|
||||
.orElse(null);
|
||||
|
||||
assignmentRepository.flush();
|
||||
TeacherDepartmentAssignment assignment = new TeacherDepartmentAssignment();
|
||||
assignment.setTeacher(teacher);
|
||||
assignment.setDepartment(department);
|
||||
assignment.setValidFrom(validFrom);
|
||||
assignment.setValidTo(nextValidFrom == null ? null : nextValidFrom.minusDays(1));
|
||||
assignment.setPrimaryAssignment(true);
|
||||
assignment.setComment(normalizeComment(request.comment()));
|
||||
assignment.setCreatedBy(createdBy);
|
||||
TeacherDepartmentAssignment saved = saveAssignment(assignment);
|
||||
|
||||
primaryHistory.add(saved);
|
||||
synchronizeLegacyDepartment(teacher, primaryHistory, operationDate);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private void synchronizeLegacyDepartment(User teacher,
|
||||
List<TeacherDepartmentAssignment> primaryHistory,
|
||||
LocalDate date) {
|
||||
resolveDepartmentIdAtDate(primaryHistory, date).ifPresent(departmentId -> {
|
||||
if (!Objects.equals(teacher.getDepartmentId(), departmentId)) {
|
||||
teacher.setDepartmentId(departmentId);
|
||||
userRepository.save(teacher);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private TeacherDepartmentAssignment saveAssignment(TeacherDepartmentAssignment assignment) {
|
||||
try {
|
||||
return assignmentRepository.saveAndFlush(assignment);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(
|
||||
"Период основной кафедры пересекается с другим назначением преподавателя"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isActiveAt(TeacherDepartmentAssignment assignment, LocalDate date) {
|
||||
return assignment.getValidFrom() != null
|
||||
&& !assignment.getValidFrom().isAfter(date)
|
||||
&& (assignment.getValidTo() == null || !assignment.getValidTo().isBefore(date));
|
||||
}
|
||||
|
||||
private String normalizeComment(String comment) {
|
||||
return comment == null || comment.isBlank() ? null : comment.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.TimeSlotDto;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.TimeSlotScope;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
public class TimeSlotService {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
private static final String SLOT_CONFLICT_MESSAGE =
|
||||
"Номер пары или интервал времени уже занят в выбранной сетке";
|
||||
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
|
||||
public TimeSlotService(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TimeSlot create(TimeSlotDto request) {
|
||||
validate(request);
|
||||
TimeSlotScope targetScope = lockScope(request.scopeId());
|
||||
ensureUniqueAndNonOverlapping(request, null);
|
||||
|
||||
TimeSlot timeSlot = new TimeSlot();
|
||||
apply(timeSlot, targetScope, request);
|
||||
return save(timeSlot);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TimeSlot update(Long id, TimeSlotDto request) {
|
||||
validate(request);
|
||||
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
|
||||
|
||||
Long currentScopeId = timeSlot.getTimeSlotScope().getId();
|
||||
List<Long> scopeIds = Stream.of(currentScopeId, request.scopeId())
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
TimeSlotScope targetScope = null;
|
||||
for (Long scopeId : scopeIds) {
|
||||
TimeSlotScope lockedScope = lockScope(scopeId);
|
||||
if (scopeId.equals(request.scopeId())) {
|
||||
targetScope = lockedScope;
|
||||
}
|
||||
}
|
||||
if (targetScope == null) {
|
||||
throw new NoSuchElementException("Сетка времени не найдена");
|
||||
}
|
||||
|
||||
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)
|
||||
&& !APPLY_MODE_DEFAULT.equals(targetScope.getApplyMode())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Слот базовой сетки, используемый в правилах расписания, нельзя перенести в другую сетку"
|
||||
);
|
||||
}
|
||||
|
||||
ensureUniqueAndNonOverlapping(request, id);
|
||||
apply(timeSlot, targetScope, request);
|
||||
return save(timeSlot);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
TimeSlot timeSlot = timeSlotRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Временной слот не найден"));
|
||||
if (scheduleRuleSlotRepository.existsByTimeSlotId(id)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Нельзя удалить слот, который используется в правилах расписания"
|
||||
);
|
||||
}
|
||||
try {
|
||||
timeSlotRepository.delete(timeSlot);
|
||||
timeSlotRepository.flush();
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"Нельзя удалить слот, который используется в расписании",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(TimeSlotDto request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Данные временного слота обязательны");
|
||||
}
|
||||
if (request.orderNumber() == null || request.orderNumber() <= 0) {
|
||||
throw new IllegalArgumentException("Номер пары должен быть больше нуля");
|
||||
}
|
||||
if (request.scopeId() == null) {
|
||||
throw new IllegalArgumentException("Выберите сетку времени");
|
||||
}
|
||||
if (request.startTime() == null || request.endTime() == null) {
|
||||
throw new IllegalArgumentException("Время начала и окончания обязательно");
|
||||
}
|
||||
if (!request.startTime().isBefore(request.endTime())) {
|
||||
throw new IllegalArgumentException("Время начала должно быть раньше времени окончания");
|
||||
}
|
||||
if (Duration.between(request.startTime(), request.endTime()).toMinutes() < 1) {
|
||||
throw new IllegalArgumentException("Длительность временного слота должна быть не меньше одной минуты");
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSlotScope lockScope(Long scopeId) {
|
||||
return timeSlotScopeRepository.findByIdForUpdate(scopeId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Сетка времени не найдена"));
|
||||
}
|
||||
|
||||
private void ensureUniqueAndNonOverlapping(TimeSlotDto request, Long currentId) {
|
||||
boolean duplicateOrder = currentId == null
|
||||
? timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumber(
|
||||
request.scopeId(), request.orderNumber())
|
||||
: timeSlotRepository.existsByTimeSlotScopeIdAndOrderNumberAndIdNot(
|
||||
request.scopeId(), request.orderNumber(), currentId);
|
||||
boolean overlapping = currentId == null
|
||||
? timeSlotRepository.existsOverlapping(
|
||||
request.scopeId(), request.startTime(), request.endTime())
|
||||
: timeSlotRepository.existsOverlappingExcluding(
|
||||
request.scopeId(), currentId, request.startTime(), request.endTime());
|
||||
if (duplicateOrder || overlapping) {
|
||||
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private void apply(TimeSlot timeSlot, TimeSlotScope scope, TimeSlotDto request) {
|
||||
timeSlot.setOrderNumber(request.orderNumber());
|
||||
timeSlot.setTimeSlotScope(scope);
|
||||
timeSlot.setStartTime(request.startTime());
|
||||
timeSlot.setEndTime(request.endTime());
|
||||
timeSlot.setDurationMinutes((int) Duration.between(request.startTime(), request.endTime()).toMinutes());
|
||||
}
|
||||
|
||||
private TimeSlot save(TimeSlot timeSlot) {
|
||||
try {
|
||||
return timeSlotRepository.saveAndFlush(timeSlot);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new ScheduleConflictException(SLOT_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import com.magistr.app.model.SemesterType;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@Service
|
||||
public class CourseAndSemesterCalculator {
|
||||
|
||||
public static int getActualCourse(Integer yearStartStudy) {
|
||||
return getActualCourse(yearStartStudy, LocalDate.now());
|
||||
return getActualCourse(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
|
||||
}
|
||||
|
||||
public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
|
||||
@@ -22,7 +23,7 @@ public class CourseAndSemesterCalculator {
|
||||
}
|
||||
|
||||
public static int getActualSemester(Integer yearStartStudy) {
|
||||
return getActualSemester(yearStartStudy, LocalDate.now());
|
||||
return getActualSemester(yearStartStudy, LocalDate.now(ZoneId.of("Europe/Moscow")));
|
||||
}
|
||||
|
||||
public static int getActualSemester(Integer yearStartStudy, LocalDate date) {
|
||||
|
||||
Reference in New Issue
Block a user