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
@@ -0,0 +1,80 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.internal.schedule.request.WorkScheduleRegisterRequest;
import kr.ac.kookmin.stream.api.admin.internal.schedule.request.WorkScheduleUpdateRequest;
import kr.ac.kookmin.stream.api.admin.internal.schedule.request.WorkScheduleWeekParams;
import kr.ac.kookmin.stream.api.admin.internal.schedule.response.WorkScheduleResponse;
import kr.ac.kookmin.stream.api.admin.internal.schedule.response.WorkScheduleWeekResponse;
import kr.ac.kookmin.stream.api.admin.internal.schedule.response.WorkerResponse;
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;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkScheduleErrorCode;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 빌릴게 근무 시간표 API의 문서 명세. 구현은 {@link AdminWorkScheduleController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
* <p>
* 근무는 평일(월~금) 10~17시에 1시간 단위로 서고, 점심시간(12~13시)은 비운다. 한 시간 칸에는 근무자 한 명만 들어간다.
* 근무자는 학생회 부서가 있는 회원이다. 지난 주(이번 주 월요일보다 앞선 주)의 근무 시간은 바꿀 수 없다.
*/
@Tag(name = "근무 시간표 관리", description = "빌릴게 근무 시간표 조회·등록·수정·삭제와 근무자 목록")
public interface AdminWorkScheduleApi {

/** 근무 시간표 그리드. 한 주 단위로 조회한다. */
@Operation(summary = "근무 시간표 조회",
description = "date가 속한 주(월~금)의 근무 시간을 근무일, 시작 시각 순으로 조회한다. 주는 월요일에 시작하므로 토·일을 "
+ "보내면 그 앞 월요일의 주를 돌려준다. date를 생략하면 서버 시각(KST) 기준 이번 주다. editable이 false면 지난 주라 "
+ "등록·수정·삭제할 수 없다. 탈퇴한 회원의 근무 시간은 name·studentId가 null이다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<WorkScheduleWeekResponse> getWorkSchedules(
AdminApiUser apiUser, @ParameterObject WorkScheduleWeekParams params);

/** 등록·수정 모달의 근무자 드롭다운. */
@Operation(summary = "근무자 목록 조회",
description = "근무 시간에 배정할 수 있는 회원(학생회 부서가 있는 회원)을 이름, 같은 이름이면 학번 오름차순으로 조회한다. "
+ "운영진 권한(role)은 보지 않는다.")
ApiResponse<List<WorkerResponse>> getWorkers(AdminApiUser apiUser);

/** 근무 시간 등록. 근무자 한 명의 시간대를 여러 개 한 번에 등록한다. */
@Operation(summary = "근무 시간표 등록",
description = "근무자 한 명과 시간대 여러 개를 받아 한 번에 등록한다. 하나라도 규칙에 맞지 않으면 아무것도 등록하지 않는다. "
+ "점심시간에 걸치는 시간대(예: 11~14시)는 받지 않으므로 오전·오후 두 건으로 나눠 보낸다. 이미 근무 시간이 있는 칸과 "
+ "겹치거나(근무자가 달라도) 요청 안의 시간대끼리 겹치면 409다. 붙어 있는 시간대는 합치지 않고 보낸 그대로 저장한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"})
@ApiErrorCode(
type = WorkScheduleErrorCode.class,
codes = {"INVALID_WORK_DATE", "INVALID_WORK_HOURS", "PAST_WEEK_NOT_EDITABLE", "WORKER_NOT_FOUND",
"WORK_SCHEDULE_CONFLICT"}
)
ApiResponse<List<WorkScheduleResponse>> registerWorkSchedules(
AdminApiUser apiUser, WorkScheduleRegisterRequest request);

/** 근무 시간 수정. 근무자·근무일·시간을 모두 바꾼다. */
@Operation(summary = "근무 시간표 수정",
description = "근무 시간 1건의 근무자·근무일·시작·종료를 통째로 바꾼다. 규칙은 등록과 같고, 겹침 검사에서 수정 대상 자신은 "
+ "뺀다. 원래 근무일이나 옮길 근무일이 지난 주면 400이다. 같은 주 안에서 옮기는지는 검사하지 않는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"})
@ApiErrorCode(
type = WorkScheduleErrorCode.class,
codes = {"INVALID_WORK_DATE", "INVALID_WORK_HOURS", "PAST_WEEK_NOT_EDITABLE", "WORK_SCHEDULE_NOT_FOUND",
"WORKER_NOT_FOUND", "WORK_SCHEDULE_CONFLICT"}
)
ApiResponse<WorkScheduleResponse> updateWorkSchedule(
AdminApiUser apiUser, Long scheduleId, WorkScheduleUpdateRequest request);

/** 근무 시간 삭제. 행을 지운다. */
@Operation(summary = "근무 시간표 삭제",
description = "근무 시간 1건을 삭제한다. 삭제 표시 없이 행을 지운다. 지난 주의 근무 시간이면 400이다.")
@ApiErrorCode(type = WorkScheduleErrorCode.class, codes = {"PAST_WEEK_NOT_EDITABLE", "WORK_SCHEDULE_NOT_FOUND"})
ApiResponse<Void> deleteWorkSchedule(AdminApiUser apiUser, Long scheduleId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule;

import jakarta.validation.Valid;
import java.util.List;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.internal.schedule.request.WorkScheduleRegisterRequest;
import kr.ac.kookmin.stream.api.admin.internal.schedule.request.WorkScheduleUpdateRequest;
import kr.ac.kookmin.stream.api.admin.internal.schedule.request.WorkScheduleWeekParams;
import kr.ac.kookmin.stream.api.admin.internal.schedule.response.WorkScheduleResponse;
import kr.ac.kookmin.stream.api.admin.internal.schedule.response.WorkScheduleWeekResponse;
import kr.ac.kookmin.stream.api.admin.internal.schedule.response.WorkerResponse;
import kr.ac.kookmin.stream.api.admin.internal.schedule.usecase.AdminWorkScheduleUseCase;
import kr.ac.kookmin.stream.api.admin.internal.schedule.usecase.MemberWorkSchedule;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.internal.domain.schedule.service.AdminWorkScheduleService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/billilge/work-schedules")
@RequiredArgsConstructor
public class AdminWorkScheduleController implements AdminWorkScheduleApi {

private final AdminWorkScheduleService adminWorkScheduleService;
private final AdminWorkScheduleUseCase adminWorkScheduleUseCase;

@Override
@GetMapping
public ApiResponse<WorkScheduleWeekResponse> getWorkSchedules(
AdminApiUser apiUser,
@Valid @ModelAttribute WorkScheduleWeekParams params
) {
return ApiResponse.success(
WorkScheduleWeekResponse.from(adminWorkScheduleUseCase.getWeek(params.date())));
}

@Override
@GetMapping("/workers")
public ApiResponse<List<WorkerResponse>> getWorkers(AdminApiUser apiUser) {
return ApiResponse.success(adminWorkScheduleUseCase.getWorkers().stream().map(WorkerResponse::from).toList());
}

@Override
@PostMapping
public ApiResponse<List<WorkScheduleResponse>> registerWorkSchedules(
AdminApiUser apiUser,
@Valid @RequestBody WorkScheduleRegisterRequest request
) {
List<MemberWorkSchedule> registered =
adminWorkScheduleUseCase.register(request.memberId(), request.toCommands());
return ApiResponse.success(registered.stream().map(WorkScheduleResponse::from).toList());
}

@Override
@PutMapping("/{scheduleId}")
public ApiResponse<WorkScheduleResponse> updateWorkSchedule(
AdminApiUser apiUser,
@PathVariable Long scheduleId,
@Valid @RequestBody WorkScheduleUpdateRequest request
) {
MemberWorkSchedule updated =
adminWorkScheduleUseCase.update(scheduleId, request.memberId(), request.toCommand());
return ApiResponse.success(WorkScheduleResponse.from(updated));
}

@Override
@DeleteMapping("/{scheduleId}")
public ApiResponse<Void> deleteWorkSchedule(AdminApiUser apiUser, @PathVariable Long scheduleId) {
adminWorkScheduleService.delete(scheduleId);
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.List;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkScheduleCommand;

/**
* 근무 시간 등록 요청. 화면이 근무자 한 명을 고르고 시간대를 여러 개 넣어 한 번에 저장한다.
* 근무일·시간의 범위(월~금, 10~17시, 점심시간 제외)는 도메인이 검증한다.
*/
public record WorkScheduleRegisterRequest(

@Schema(description = "근무자 회원 아이디 (근무자 목록 조회의 memberId)", example = "11")
@NotNull(message = "근무자를 선택해 주세요.")
Long memberId,

@Schema(description = "등록할 시간대 목록. 최소 1건")
@NotEmpty(message = "시간대를 1개 이상 입력해 주세요.")
List<@Valid @NotNull(message = "시간대를 입력해 주세요.") Item> schedules
) {

public List<WorkScheduleCommand> toCommands() {
return schedules.stream()
.map(item -> new WorkScheduleCommand(item.workDate(), item.startHour(), item.endHour()))
.toList();
}

public record Item(

@Schema(description = "근무일(월~금)", example = "2026-10-20")
@NotNull(message = "근무일을 입력해 주세요.")
LocalDate workDate,

@Schema(description = "시작 시각(시)", example = "10")
@NotNull(message = "시작 시각을 입력해 주세요.")
Integer startHour,

@Schema(description = "종료 시각(시)", example = "12")
@NotNull(message = "종료 시각을 입력해 주세요.")
Integer endHour
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkScheduleCommand;

// 수정 모달이 근무자·근무일·시간을 한꺼번에 보내므로 일부가 아니라 전체를 바꾼다
public record WorkScheduleUpdateRequest(

@Schema(description = "근무자 회원 아이디. 다른 근무자로 바꿀 수 있다", example = "15")
@NotNull(message = "근무자를 선택해 주세요.")
Long memberId,

@Schema(description = "근무일(월~금)", example = "2026-10-21")
@NotNull(message = "근무일을 입력해 주세요.")
LocalDate workDate,

@Schema(description = "시작 시각(시)", example = "13")
@NotNull(message = "시작 시각을 입력해 주세요.")
Integer startHour,

@Schema(description = "종료 시각(시)", example = "15")
@NotNull(message = "종료 시각을 입력해 주세요.")
Integer endHour
) {

public WorkScheduleCommand toCommand() {
return new WorkScheduleCommand(workDate, startHour, endHour);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule.request;

import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;

public record WorkScheduleWeekParams(

@Schema(description = "조회할 주에 속한 날짜. 토·일이면 그 앞 월요일의 주, 생략하면 이번 주", example = "2026-10-21")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate date
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule.response;

import java.time.DayOfWeek;
import java.time.LocalDate;
import kr.ac.kookmin.stream.api.admin.internal.schedule.usecase.MemberWorkSchedule;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkSchedule;

// 근무 시간 1건. name·studentId는 탈퇴(소프트 삭제)한 회원의 근무 시간이면 null이다
public record WorkScheduleResponse(
Long scheduleId,
Long memberId,
String name,
String studentId,
LocalDate workDate,
DayOfWeek dayOfWeek,
int startHour,
int endHour
) {

public static WorkScheduleResponse from(MemberWorkSchedule memberWorkSchedule) {
WorkSchedule schedule = memberWorkSchedule.schedule();

return new WorkScheduleResponse(
schedule.getId(),
schedule.getMemberId(),
memberWorkSchedule.memberName(),
memberWorkSchedule.memberStudentId(),
schedule.getWorkDate(),
schedule.getDayOfWeek(),
schedule.getStartHour(),
schedule.getEndHour()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule.response;

import java.time.LocalDate;
import java.util.List;
import kr.ac.kookmin.stream.api.admin.internal.schedule.usecase.MemberWorkScheduleWeek;

// 한 주(월~금)의 근무 시간표. editable이 false면 지난 주라 등록·수정·삭제할 수 없다
public record WorkScheduleWeekResponse(
LocalDate weekStartDate,
LocalDate weekEndDate,
boolean editable,
List<WorkScheduleResponse> schedules
) {

public static WorkScheduleWeekResponse from(MemberWorkScheduleWeek week) {
return new WorkScheduleWeekResponse(
week.week().startDate(),
week.week().endDate(),
week.editable(),
week.schedules().stream().map(WorkScheduleResponse::from).toList()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule.response;

import kr.ac.kookmin.stream.member.domain.member.domain.Member;

public record WorkerResponse(Long memberId, String name, String studentId) {

public static WorkerResponse from(Member member) {
return new WorkerResponse(member.getId(), member.getName(), member.getStudentId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package kr.ac.kookmin.stream.api.admin.internal.schedule.usecase;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkSchedule;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkScheduleCommand;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkScheduleErrorCode;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkScheduleWeek;
import kr.ac.kookmin.stream.internal.domain.schedule.service.AdminWorkScheduleService;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 근무 시간표 화면이 쓰는 조합. 근무 시간(internal)에 근무자(member)를 붙인다.
* <p>
* 근무자는 학생회 부서가 있는 회원이다. 회원 도메인은 학생회 부서가 있는지만 알려주고, 그 회원을 근무자로 볼지는
* 근무 시간표의 규칙이라 이 UseCase에서만 정한다({@code getWorkers}, {@code getWorker}).
* <p>
* 근무자 확인은 읽기뿐이고 근무 시간 쓰기는 서비스 트랜잭션 하나로 끝나므로 트랜잭션을 걸지 않는다.
*/
@Component
@RequiredArgsConstructor
public class AdminWorkScheduleUseCase {

private final AdminWorkScheduleService adminWorkScheduleService;
private final MemberService memberService;

public List<Member> getWorkers() {
return memberService.findAllCouncilMembers();
}

public MemberWorkScheduleWeek getWeek(LocalDate date) {
WorkScheduleWeek week = adminWorkScheduleService.getWeek(date);
Map<Long, Member> members = memberService.getMapByIds(
week.schedules().stream().map(WorkSchedule::getMemberId).toList());

return new MemberWorkScheduleWeek(
week.week(),
week.editable(),
week.schedules().stream()
.map(schedule -> new MemberWorkSchedule(schedule, members.get(schedule.getMemberId())))
.toList()
);
}

public List<MemberWorkSchedule> register(Long memberId, List<WorkScheduleCommand> commands) {
Member worker = getWorker(memberId);
return adminWorkScheduleService.register(memberId, commands).stream()
.map(schedule -> new MemberWorkSchedule(schedule, worker))
.toList();
}

public MemberWorkSchedule update(Long scheduleId, Long memberId, WorkScheduleCommand command) {
Member worker = getWorker(memberId);
return new MemberWorkSchedule(adminWorkScheduleService.update(scheduleId, memberId, command), worker);
}

// 없는 회원(탈퇴 포함)은 MEMBER_NOT_FOUND, 학생회 부서가 없는 회원은 WORKER_NOT_FOUND다
private Member getWorker(Long memberId) {
Member member = memberService.getById(memberId);
if (!member.hasCouncilDepartment()) {
throw new BusinessException(WorkScheduleErrorCode.WORKER_NOT_FOUND);
}
return member;
}
}
Loading