Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerStatusChangeResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
import kr.ac.kookmin.stream.common.CommonErrorCode;
Expand All @@ -19,7 +21,7 @@
* <p>
* 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다.
*/
@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회")
@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회와 사물함 상태 변경")
public interface AdminLockerSectionApi {

/** 구역별 전체·선택 가능·배정 사물함 수. */
Expand Down Expand Up @@ -47,4 +49,20 @@ ApiResponse<AdminLockerSectionDetailResponse> getLockerSection(
Long sectionId,
@ParameterObject LockerPeriodParams params
);

/** 구역 안의 사물함 여러 개를 같은 상태로 바꾼다. */
@Operation(summary = "사물함 상태 일괄 변경",
description = "구역 안의 사물함 여러 개를 AVAILABLE(선택가능) 또는 DISABLED(선택불가)로 한 번에 바꾼다. "
+ "하나라도 바꿀 수 없으면 아무것도 바뀌지 않는다. "
+ "사용 기간이 끝나지 않은 회차에 배정된 사물함은 이용 중인 학생이 있으므로 DISABLED로 바꿀 수 없고, "
+ "AVAILABLE로 되돌리는 것은 막지 않는다. "
+ "응답은 요청한 사물함 전체로, 이미 그 상태였던 사물함도 담긴다. "
+ "구역 상세가 내려주는 ASSIGNED는 신청으로 생기는 표시용 값이라 이 경로로 바꿀 수 없다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_NOT_IN_SECTION", "LOCKER_IN_USE"})
ApiResponse<AdminLockerStatusChangeResponse> changeLockerStatus(
AdminApiUser apiUser,
Long sectionId,
LockerStatusChangeRequest request
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,24 @@
import java.util.Set;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerStatusChangeResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
import kr.ac.kookmin.stream.file.domain.File;
import kr.ac.kookmin.stream.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -27,6 +33,7 @@
public class AdminLockerSectionController implements AdminLockerSectionApi {

private final LockerService lockerService;
private final AdminLockerService adminLockerService;
private final FileService fileService;

@Override
Expand Down Expand Up @@ -56,4 +63,16 @@ public ApiResponse<AdminLockerSectionDetailResponse> getLockerSection(
return ApiResponse.success(
AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds));
}

@Override
@PatchMapping("/{sectionId}/lockers/status")
public ApiResponse<AdminLockerStatusChangeResponse> changeLockerStatus(
AdminApiUser apiUser,
@PathVariable Long sectionId,
@Valid @RequestBody LockerStatusChangeRequest request
) {
List<Locker> lockers = adminLockerService.changeLockerStatus(
sectionId, request.lockerIds(), request.lockerStatus());
return ApiResponse.success(AdminLockerStatusChangeResponse.from(lockers));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;

/**
* 구역 안의 사물함 여러 개를 같은 상태로 바꾼다.
* <p>
* 구역 상세가 내려주는 {@code ASSIGNED}는 신청으로 생기는 표시용 값이라 이 경로로는 바꿀 수 없다.
*/
public record LockerStatusChangeRequest(
@NotEmpty(message = "사물함을 하나 이상 선택해 주세요.")
List<@NotNull(message = "사물함 ID는 비워 둘 수 없습니다.") Long> lockerIds,

@NotNull(message = "변경할 상태를 입력해 주세요.")
LockerStatus lockerStatus
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.ac.kookmin.stream.api.admin.event.locker.response;

import java.util.List;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;

/**
* 요청한 사물함 전체를 바뀐 뒤 상태로 돌려준다. 이미 그 상태였던 사물함도 담긴다.
*/
public record AdminLockerStatusChangeResponse(List<LockerItem> lockers) {

public static AdminLockerStatusChangeResponse from(List<Locker> lockers) {
return new AdminLockerStatusChangeResponse(lockers.stream()
.map(LockerItem::from)
.toList());
}

/** 도메인 {@link Locker}와 이름이 겹치지 않도록 {@code LockerItem}으로 둔다. */
public record LockerItem(Long lockerId, int lockerNumber, LockerStatus lockerStatus) {

public static LockerItem from(Locker locker) {
return new LockerItem(locker.getId(), locker.getLockerNumber(), locker.getStatus());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,17 @@ public static Locker of(
public boolean isSelectable(boolean applied) {
return status == LockerStatus.AVAILABLE && !applied;
}

/**
* 사물함 자체 상태를 바꾼다. 바뀐 값이 있으면 true를 돌려준다.
* <p>
* 운영진이 여러 칸을 한 번에 바꾸므로, 이미 그 상태인 사물함은 저장하지 않도록 바뀌었는지 알려준다.
*/
public boolean changeStatus(LockerStatus status) {
if (this.status == status) {
return false;
}
this.status = status;
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public enum LockerErrorCode implements ErrorCode {
LOCKER_PERIOD_APPLY_ALREADY_STARTED(ErrorStatus.CONFLICT, "신청이 시작된 회차는 신청 시작일을 바꿀 수 없습니다."),
LOCKER_PERIOD_HAS_APPLICATIONS(ErrorStatus.CONFLICT, "신청이 있는 회차는 삭제할 수 없습니다."),
LOCKER_APPLY_NOT_OPEN(ErrorStatus.CONFLICT, "사물함 신청 기간이 아닙니다."),
LOCKER_NOT_IN_SECTION(ErrorStatus.BAD_REQUEST, "해당 구역의 사물함이 아닙니다."),
LOCKER_IN_USE(ErrorStatus.CONFLICT, "이용 중인 사물함은 사용 중지할 수 없습니다."),
LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다.");

private final int status;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.event.domain.locker.repository;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -35,6 +36,11 @@ public interface LockerApplicationRepository {
*/
Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId);

/**
* 해당 사물함들의 신청 전체. 어느 운영 회차에 배정됐는지 보려고 읽는다.
*/
List<LockerApplication> findByLockerIds(Collection<Long> lockerIds);

/**
* 회원의 사물함 신청 전체를 신청 일시 최신순으로 조회한다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,9 @@ public interface LockerRepository {
* 식별자에 해당하는 사물함. 지난 신청 이력의 사물함 이름을 보여줘야 하므로 삭제된 사물함도 포함한다.
*/
List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds);

/**
* 사물함 여러 건을 저장한다. 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다.
*/
void saveLockers(List<Locker> lockers);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package kr.ac.kookmin.stream.event.domain.locker.service;

import java.util.List;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;

/**
* 운영진 사물함 API가 쓰는 서비스.
Expand Down Expand Up @@ -36,4 +38,14 @@ public interface AdminLockerService {
* 운영 회차를 소프트 삭제한다. 신청이 한 건이라도 있으면 거부한다.
*/
void deletePeriod(Long lockerPeriodId);

/**
* 구역 안의 사물함 여러 개의 상태를 한 번에 바꾸고, 바뀐 사물함을 돌려준다.
* <p>
* 하나라도 바꿀 수 없으면 아무것도 바꾸지 않는다. 사용 기간이 끝나지 않은 회차에 배정된 사물함은
* 이용 중인 학생이 있으므로 {@code DISABLED}로 바꿀 수 없다.
*
* @return 요청한 사물함 전체. 이미 그 상태였던 사물함도 포함한다
*/
List<Locker> changeLockerStatus(Long sectionId, List<Long> lockerIds, LockerStatus status);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package kr.ac.kookmin.stream.event.domain.locker.service.impl;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationStatus;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
Expand Down Expand Up @@ -66,6 +75,66 @@ public void deletePeriod(Long lockerPeriodId) {
}
}

@Override
@Transactional
public List<Locker> changeLockerStatus(Long sectionId, List<Long> lockerIds, LockerStatus status) {
List<Locker> lockers = findLockersInSection(sectionId, lockerIds);
if (status == LockerStatus.DISABLED) {
requireNotInUse(lockerIds);
}

// 이미 그 상태인 사물함은 저장하지 않는다. 36칸 구역에서 두 칸만 바꿔도 전체를 쓰지 않도록 한다
lockerRepository.saveLockers(lockers.stream()
.filter(locker -> locker.changeStatus(status))
.toList());
return lockers;
}

/**
* 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다.
*/
private List<Locker> findLockersInSection(Long sectionId, List<Long> lockerIds) {
Map<Long, Locker> lockersInSection = lockerRepository.findLockersBySectionId(sectionId).stream()
.collect(Collectors.toMap(Locker::getId, Function.identity()));

return lockerIds.stream()
.map(lockerId -> {
Locker locker = lockersInSection.get(lockerId);
if (locker == null) {
throw new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION);
}
return locker;
})
.toList();
}

/**
* 아직 배정 상태인 신청이 걸린 사물함이 하나라도 있으면 끊는다.
* <p>
* 배정인지 만료인지는 내 신청 내역 화면과 같은 기준을 써야 하므로 {@link LockerApplicationStatus}에 맡긴다.
* 저장소는 신청과 회차를 읽어 오기만 한다.
*/
private void requireNotInUse(List<Long> lockerIds) {
List<LockerApplication> applications = lockerApplicationRepository.findByLockerIds(lockerIds);
if (applications.isEmpty()) {
return;
}

LocalDate today = LocalDate.now();
Set<Long> assignedPeriodIds = lockerRepository.findPublishedPeriodsByIds(applications.stream()
.map(LockerApplication::getLockerPeriodId)
.collect(Collectors.toSet())).stream()
.filter(period -> LockerApplicationStatus.from(period, today) == LockerApplicationStatus.ASSIGNED)
.map(LockerPeriod::getId)
.collect(Collectors.toSet());

boolean inUse = applications.stream()
.anyMatch(application -> assignedPeriodIds.contains(application.getLockerPeriodId()));
if (inUse) {
throw new BusinessException(LockerErrorCode.LOCKER_IN_USE);
}
}

private LockerPeriod getPeriod(Long lockerPeriodId) {
return lockerRepository.findPeriodById(lockerPeriodId)
.orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,11 @@ public List<LockerPeriod> findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt
throw new UnsupportedOperationException();
}

@Override
public void saveLockers(List<Locker> lockers) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds) {
batchLookups++;
Expand Down Expand Up @@ -427,6 +432,11 @@ private static final class FakeLockerApplicationRepository implements LockerAppl
private final List<LockerApplication> saved = new ArrayList<>();
private List<LockerApplication> memberApplications = List.of();

@Override
public List<LockerApplication> findByLockerIds(Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
}

@Override
public boolean existsByPeriod(Long lockerPeriodId) {
throw new UnsupportedOperationException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,11 @@ public List<LockerPeriod> findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt
throw new UnsupportedOperationException();
}

@Override
public void saveLockers(List<Locker> lockers) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
Expand Down Expand Up @@ -349,6 +354,11 @@ public Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId) {

// 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다

@Override
public List<LockerApplication> findByLockerIds(Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
}

@Override
public boolean existsByPeriod(Long lockerPeriodId) {
throw new UnsupportedOperationException();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.db.event;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -30,6 +31,8 @@ public interface LockerApplicationJpaRepository extends JpaRepository<LockerAppl
""")
List<Long> findMemberIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId);

List<LockerApplicationJpaEntity> findAllByLockerIdIn(Collection<Long> lockerIds);

/**
* 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.db.event;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -45,6 +46,16 @@ public Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId) {
return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId);
}

@Override
public List<LockerApplication> findByLockerIds(Collection<Long> lockerIds) {
if (lockerIds.isEmpty()) {
return List.of();
}
return lockerApplicationJpaRepository.findAllByLockerIdIn(lockerIds).stream()
.map(LockerApplicationJpaEntity::toDomain)
.toList();
}

@Override
public List<LockerApplication> findByMemberId(Long memberId) {
return lockerApplicationJpaRepository.findAllByMemberIdOrderByAppliedAtDescIdDesc(memberId).stream()
Expand Down
Loading
Loading