diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleApi.java
new file mode 100644
index 00000000..c812d319
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleApi.java
@@ -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}가 맡는다.
+ *
+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
+ *
+ * 근무는 평일(월~금) 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 getWorkSchedules(
+ AdminApiUser apiUser, @ParameterObject WorkScheduleWeekParams params);
+
+ /** 등록·수정 모달의 근무자 드롭다운. */
+ @Operation(summary = "근무자 목록 조회",
+ description = "근무 시간에 배정할 수 있는 회원(학생회 부서가 있는 회원)을 이름, 같은 이름이면 학번 오름차순으로 조회한다. "
+ + "운영진 권한(role)은 보지 않는다.")
+ ApiResponse> 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> 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 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 deleteWorkSchedule(AdminApiUser apiUser, Long scheduleId);
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleController.java
new file mode 100644
index 00000000..b83fd59d
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleController.java
@@ -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 getWorkSchedules(
+ AdminApiUser apiUser,
+ @Valid @ModelAttribute WorkScheduleWeekParams params
+ ) {
+ return ApiResponse.success(
+ WorkScheduleWeekResponse.from(adminWorkScheduleUseCase.getWeek(params.date())));
+ }
+
+ @Override
+ @GetMapping("/workers")
+ public ApiResponse> getWorkers(AdminApiUser apiUser) {
+ return ApiResponse.success(adminWorkScheduleUseCase.getWorkers().stream().map(WorkerResponse::from).toList());
+ }
+
+ @Override
+ @PostMapping
+ public ApiResponse> registerWorkSchedules(
+ AdminApiUser apiUser,
+ @Valid @RequestBody WorkScheduleRegisterRequest request
+ ) {
+ List registered =
+ adminWorkScheduleUseCase.register(request.memberId(), request.toCommands());
+ return ApiResponse.success(registered.stream().map(WorkScheduleResponse::from).toList());
+ }
+
+ @Override
+ @PutMapping("/{scheduleId}")
+ public ApiResponse 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 deleteWorkSchedule(AdminApiUser apiUser, @PathVariable Long scheduleId) {
+ adminWorkScheduleService.delete(scheduleId);
+ return ApiResponse.success();
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleRegisterRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleRegisterRequest.java
new file mode 100644
index 00000000..9924201d
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleRegisterRequest.java
@@ -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 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
+ ) {
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleUpdateRequest.java
new file mode 100644
index 00000000..e91b2f1d
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleUpdateRequest.java
@@ -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);
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleWeekParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleWeekParams.java
new file mode 100644
index 00000000..0a969292
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleWeekParams.java
@@ -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
+) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleResponse.java
new file mode 100644
index 00000000..53e4f580
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleResponse.java
@@ -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()
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleWeekResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleWeekResponse.java
new file mode 100644
index 00000000..c91d6ddb
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleWeekResponse.java
@@ -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 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()
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkerResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkerResponse.java
new file mode 100644
index 00000000..7120d0a2
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkerResponse.java
@@ -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());
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/AdminWorkScheduleUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/AdminWorkScheduleUseCase.java
new file mode 100644
index 00000000..5ffe94b2
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/AdminWorkScheduleUseCase.java
@@ -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)를 붙인다.
+ *
+ * 근무자는 학생회 부서가 있는 회원이다. 회원 도메인은 학생회 부서가 있는지만 알려주고, 그 회원을 근무자로 볼지는
+ * 근무 시간표의 규칙이라 이 UseCase에서만 정한다({@code getWorkers}, {@code getWorker}).
+ *
+ * 근무자 확인은 읽기뿐이고 근무 시간 쓰기는 서비스 트랜잭션 하나로 끝나므로 트랜잭션을 걸지 않는다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AdminWorkScheduleUseCase {
+
+ private final AdminWorkScheduleService adminWorkScheduleService;
+ private final MemberService memberService;
+
+ public List getWorkers() {
+ return memberService.findAllCouncilMembers();
+ }
+
+ public MemberWorkScheduleWeek getWeek(LocalDate date) {
+ WorkScheduleWeek week = adminWorkScheduleService.getWeek(date);
+ Map 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 register(Long memberId, List 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;
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkSchedule.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkSchedule.java
new file mode 100644
index 00000000..7976a288
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkSchedule.java
@@ -0,0 +1,17 @@
+package kr.ac.kookmin.stream.api.admin.internal.schedule.usecase;
+
+import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkSchedule;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+
+// 근무 시간에 근무자를 붙인 결과. DTO 변환은 Controller가 한다.
+// member는 탈퇴(소프트 삭제)한 회원의 근무 시간이면 null일 수 있다.
+public record MemberWorkSchedule(WorkSchedule schedule, Member member) {
+
+ public String memberName() {
+ return member == null ? null : member.getName();
+ }
+
+ public String memberStudentId() {
+ return member == null ? null : member.getStudentId();
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkScheduleWeek.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkScheduleWeek.java
new file mode 100644
index 00000000..d86e3f1e
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkScheduleWeek.java
@@ -0,0 +1,8 @@
+package kr.ac.kookmin.stream.api.admin.internal.schedule.usecase;
+
+import java.util.List;
+import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkWeek;
+
+// 한 주의 근무 시간표에 근무자를 붙인 결과.
+public record MemberWorkScheduleWeek(WorkWeek week, boolean editable, List schedules) {
+}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkSchedule.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkSchedule.java
index e010c2d0..22541dde 100644
--- a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkSchedule.java
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkSchedule.java
@@ -1,28 +1,83 @@
package kr.ac.kookmin.stream.internal.domain.schedule.domain;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import kr.ac.kookmin.stream.common.BusinessException;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
+/**
+ * 빌릴게 근무 시간 1건. 근무자 한 명이 근무일 하루의 startHour시부터 endHour시까지 근무한다.
+ * 근무는 평일 10~17시에 1시간 단위로 서고, 점심시간(12~13시)에는 서지 않는다.
+ */
@Getter
@EqualsAndHashCode
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class WorkSchedule {
+ private static final int OPEN_HOUR = 10;
+ private static final int CLOSE_HOUR = 17;
+ private static final int LUNCH_START_HOUR = 12;
+ private static final int LUNCH_END_HOUR = 13;
+
private Long id;
private Long memberId;
- private String dayOfWeek;
+ private LocalDate workDate;
private int startHour;
private int endHour;
+ public static WorkSchedule create(Long memberId, WorkScheduleCommand command) {
+ validateWorkDate(command.workDate());
+ validateWorkHours(command.startHour(), command.endHour());
+ return new WorkSchedule(null, memberId, command.workDate(), command.startHour(), command.endHour());
+ }
+
public static WorkSchedule of(
Long id,
Long memberId,
- String dayOfWeek,
+ LocalDate workDate,
int startHour,
int endHour
) {
- return new WorkSchedule(id, memberId, dayOfWeek, startHour, endHour);
+ return new WorkSchedule(id, memberId, workDate, startHour, endHour);
+ }
+
+ // 근무자·근무일·시간을 한꺼번에 바꾼다. 등록과 같은 규칙으로 검증한다
+ public void change(Long memberId, WorkScheduleCommand command) {
+ validateWorkDate(command.workDate());
+ validateWorkHours(command.startHour(), command.endHour());
+ this.memberId = memberId;
+ this.workDate = command.workDate();
+ this.startHour = command.startHour();
+ this.endHour = command.endHour();
+ }
+
+ public DayOfWeek getDayOfWeek() {
+ return workDate.getDayOfWeek();
+ }
+
+ // 끝 시각은 포함하지 않으므로 10~11시와 11~12시처럼 맞닿기만 한 시간대는 겹치지 않는다
+ public boolean overlaps(WorkSchedule other) {
+ return workDate.equals(other.workDate)
+ && startHour < other.endHour
+ && other.startHour < endHour;
+ }
+
+ private static void validateWorkDate(LocalDate workDate) {
+ DayOfWeek dayOfWeek = workDate.getDayOfWeek();
+ if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) {
+ throw new BusinessException(WorkScheduleErrorCode.INVALID_WORK_DATE);
+ }
+ }
+
+ // 점심시간에 걸치는 시간대(예: 11~14시)는 받지 않는다. 오전·오후 두 건으로 나눠 등록한다
+ private static void validateWorkHours(int startHour, int endHour) {
+ boolean outOfOpenHours = startHour < OPEN_HOUR || endHour > CLOSE_HOUR;
+ boolean overlapsLunch = startHour < LUNCH_END_HOUR && endHour > LUNCH_START_HOUR;
+ if (outOfOpenHours || startHour >= endHour || overlapsLunch) {
+ throw new BusinessException(WorkScheduleErrorCode.INVALID_WORK_HOURS);
+ }
}
}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleCommand.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleCommand.java
new file mode 100644
index 00000000..e1d65a82
--- /dev/null
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleCommand.java
@@ -0,0 +1,7 @@
+package kr.ac.kookmin.stream.internal.domain.schedule.domain;
+
+import java.time.LocalDate;
+
+// 근무 시간 등록·수정 요청 한 건. 근무자는 요청 단위 값이라 따로 받는다
+public record WorkScheduleCommand(LocalDate workDate, int startHour, int endHour) {
+}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleErrorCode.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleErrorCode.java
new file mode 100644
index 00000000..5c21c01d
--- /dev/null
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleErrorCode.java
@@ -0,0 +1,23 @@
+package kr.ac.kookmin.stream.internal.domain.schedule.domain;
+
+import kr.ac.kookmin.stream.common.ErrorCode;
+import kr.ac.kookmin.stream.common.ErrorStatus;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+@Getter
+@Accessors(fluent = true)
+@AllArgsConstructor
+public enum WorkScheduleErrorCode implements ErrorCode {
+
+ INVALID_WORK_DATE(ErrorStatus.BAD_REQUEST, "유효하지 않은 근무일입니다."),
+ INVALID_WORK_HOURS(ErrorStatus.BAD_REQUEST, "유효하지 않은 근무 시간입니다."),
+ PAST_WEEK_NOT_EDITABLE(ErrorStatus.BAD_REQUEST, "지난 주의 근무 시간은 변경할 수 없습니다."),
+ WORK_SCHEDULE_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 근무 시간입니다."),
+ WORKER_NOT_FOUND(ErrorStatus.NOT_FOUND, "근무자로 배정할 수 없는 회원입니다."),
+ WORK_SCHEDULE_CONFLICT(ErrorStatus.CONFLICT, "이미 근무자가 배정된 시간입니다.");
+
+ private final int status;
+ private final String message;
+}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleWeek.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleWeek.java
new file mode 100644
index 00000000..e922a623
--- /dev/null
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleWeek.java
@@ -0,0 +1,7 @@
+package kr.ac.kookmin.stream.internal.domain.schedule.domain;
+
+import java.util.List;
+
+// 한 주의 근무 시간표. editable은 그 주가 지난 주가 아니어서 근무 시간을 바꿀 수 있는지다
+public record WorkScheduleWeek(WorkWeek week, boolean editable, List schedules) {
+}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkWeek.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkWeek.java
new file mode 100644
index 00000000..5a47b923
--- /dev/null
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkWeek.java
@@ -0,0 +1,19 @@
+package kr.ac.kookmin.stream.internal.domain.schedule.domain;
+
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.temporal.TemporalAdjusters;
+
+// 근무 시간표의 한 주(월~금). 토·일은 그 앞 월요일의 주에 속한다
+public record WorkWeek(LocalDate startDate, LocalDate endDate) {
+
+ public static WorkWeek from(LocalDate date) {
+ LocalDate monday = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
+ return new WorkWeek(monday, monday.with(DayOfWeek.FRIDAY));
+ }
+
+ // today가 속한 주보다 앞선 주면 지난 주다. 이번 주는 이미 지난 요일이어도 지난 주가 아니다
+ public boolean isPastAt(LocalDate today) {
+ return startDate.isBefore(from(today).startDate());
+ }
+}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/repository/WorkScheduleRepository.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/repository/WorkScheduleRepository.java
new file mode 100644
index 00000000..07605cd8
--- /dev/null
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/repository/WorkScheduleRepository.java
@@ -0,0 +1,17 @@
+package kr.ac.kookmin.stream.internal.domain.schedule.repository;
+
+import java.time.LocalDate;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkSchedule;
+
+public interface WorkScheduleRepository {
+ Optional findById(Long id);
+ // from~to(양끝 포함) 근무일의 근무 시간. 근무일, 시작 시각 오름차순
+ List findAllByWorkDateBetween(LocalDate from, LocalDate to);
+ List findAllByWorkDateIn(Collection workDates);
+ List saveAll(List workSchedules);
+ WorkSchedule save(WorkSchedule workSchedule);
+ void deleteById(Long id);
+}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/AdminWorkScheduleService.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/AdminWorkScheduleService.java
new file mode 100644
index 00000000..0ee8ce60
--- /dev/null
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/AdminWorkScheduleService.java
@@ -0,0 +1,25 @@
+package kr.ac.kookmin.stream.internal.domain.schedule.service;
+
+import java.time.LocalDate;
+import java.util.List;
+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.WorkScheduleWeek;
+
+/**
+ * 운영진용 빌릴게 근무 시간표 서비스. 주 단위 조회와 근무 시간 등록·수정·삭제를 맡는다.
+ * 근무자로 배정할 수 있는 회원인지는 회원 도메인을 함께 보는 호출부(UseCase)가 확인한다.
+ */
+public interface AdminWorkScheduleService {
+
+ // date가 속한 주(월~금)의 근무 시간표. date가 null이면 이번 주(KST)
+ WorkScheduleWeek getWeek(LocalDate date);
+
+ // 근무자 한 명의 근무 시간을 한 번에 등록한다. 하나라도 규칙에 맞지 않으면 아무것도 저장하지 않는다
+ List register(Long memberId, List commands);
+
+ // 근무 시간 1건의 근무자·근무일·시간을 모두 바꾼다
+ WorkSchedule update(Long scheduleId, Long memberId, WorkScheduleCommand command);
+
+ void delete(Long scheduleId);
+}
diff --git a/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/impl/AdminWorkScheduleServiceImpl.java b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/impl/AdminWorkScheduleServiceImpl.java
new file mode 100644
index 00000000..3665f7f8
--- /dev/null
+++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/impl/AdminWorkScheduleServiceImpl.java
@@ -0,0 +1,111 @@
+package kr.ac.kookmin.stream.internal.domain.schedule.service.impl;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.DateUtil;
+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.domain.WorkWeek;
+import kr.ac.kookmin.stream.internal.domain.schedule.repository.WorkScheduleRepository;
+import kr.ac.kookmin.stream.internal.domain.schedule.service.AdminWorkScheduleService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+class AdminWorkScheduleServiceImpl implements AdminWorkScheduleService {
+
+ private final WorkScheduleRepository workScheduleRepository;
+
+ @Override
+ public WorkScheduleWeek getWeek(LocalDate date) {
+ LocalDate today = LocalDate.now(DateUtil.KST);
+ WorkWeek week = WorkWeek.from(date == null ? today : date);
+ List schedules = workScheduleRepository.findAllByWorkDateBetween(
+ week.startDate(), week.endDate());
+ return new WorkScheduleWeek(week, !week.isPastAt(today), schedules);
+ }
+
+ @Override
+ @Transactional
+ public List register(Long memberId, List commands) {
+ LocalDate today = LocalDate.now(DateUtil.KST);
+ List schedules = commands.stream()
+ .map(command -> WorkSchedule.create(memberId, command))
+ .toList();
+ schedules.forEach(schedule -> validateNotPastWeek(schedule.getWorkDate(), today));
+ validateNotConflicted(schedules);
+
+ return workScheduleRepository.saveAll(schedules);
+ }
+
+ // 지난 주 근무 시간을 이번 주로 옮기는 것도, 이번 주 근무 시간을 지난 주로 옮기는 것도 막으려고 옮기기 전과 후를 둘 다 본다
+ @Override
+ @Transactional
+ public WorkSchedule update(Long scheduleId, Long memberId, WorkScheduleCommand command) {
+ LocalDate today = LocalDate.now(DateUtil.KST);
+ WorkSchedule schedule = getById(scheduleId);
+ validateNotPastWeek(schedule.getWorkDate(), today);
+
+ schedule.change(memberId, command);
+ validateNotPastWeek(schedule.getWorkDate(), today);
+ validateNotConflicted(List.of(schedule));
+
+ return workScheduleRepository.save(schedule);
+ }
+
+ @Override
+ @Transactional
+ public void delete(Long scheduleId) {
+ WorkSchedule schedule = getById(scheduleId);
+ validateNotPastWeek(schedule.getWorkDate(), LocalDate.now(DateUtil.KST));
+ workScheduleRepository.deleteById(scheduleId);
+ }
+
+ private void validateNotPastWeek(LocalDate workDate, LocalDate today) {
+ if (WorkWeek.from(workDate).isPastAt(today)) {
+ throw new BusinessException(WorkScheduleErrorCode.PAST_WEEK_NOT_EDITABLE);
+ }
+ }
+
+ // 한 시간 칸에는 근무자 한 명만 들어간다. 저장된 근무 시간 위에 요청한 근무 시간을 하나씩 올려 보며
+ // 먼저 올라간 것(저장된 것·앞선 요청)과 겹치면 막는다. 근무자가 달라도 해당한다.
+ // 구간 겹침은 유니크 제약으로 막을 수 없어 저장 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다)
+ private void validateNotConflicted(List targets) {
+ List placed = new ArrayList<>(findSavedSchedulesOnSameDates(targets));
+ for (WorkSchedule target : targets) {
+ if (placed.stream().anyMatch(target::overlaps)) {
+ throw new BusinessException(WorkScheduleErrorCode.WORK_SCHEDULE_CONFLICT);
+ }
+ placed.add(target);
+ }
+ }
+
+ // 대상 근무일에 이미 저장된 근무 시간. 수정 대상의 원래 행은 자기 자신과 겹쳐 보이므로 뺀다
+ private List findSavedSchedulesOnSameDates(List targets) {
+ Set workDates = targets.stream()
+ .map(WorkSchedule::getWorkDate)
+ .collect(Collectors.toSet());
+ Set targetIds = targets.stream()
+ .map(WorkSchedule::getId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+
+ return workScheduleRepository.findAllByWorkDateIn(workDates).stream()
+ .filter(saved -> !targetIds.contains(saved.getId()))
+ .toList();
+ }
+
+ private WorkSchedule getById(Long scheduleId) {
+ return workScheduleRepository.findById(scheduleId)
+ .orElseThrow(() -> new BusinessException(WorkScheduleErrorCode.WORK_SCHEDULE_NOT_FOUND));
+ }
+}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
index 3f666e53..716c1b7a 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
@@ -68,6 +68,10 @@ public boolean hasPhoneNumber() {
return phoneNumber != null;
}
+ public boolean hasCouncilDepartment() {
+ return councilDepartment != null;
+ }
+
// 회원이 바꾼 전화번호로 갱신한다. 바뀐 값이 있으면 true를 돌려준다
public boolean updatePhoneNumber(String phoneNumber) {
if (Objects.equals(this.phoneNumber, phoneNumber)) {
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java
index bd0465e3..f2dbb255 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java
@@ -10,6 +10,8 @@ public interface MemberRepository {
boolean existsByPhoneNumber(String phoneNumber);
List findAllByIds(List ids);
List searchIdsByKeyword(String keyword);
+ // 학생회 구성원(학생회 부서가 있는 회원). 이름, 같은 이름이면 학번 오름차순
+ List findAllCouncilMembers();
Member save(Member member);
void clearFcmTokenOfOthers(Long memberId, String fcmToken);
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
index 4042dbe5..6328f69f 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
@@ -16,6 +16,8 @@ public interface MemberService {
* 검색할지 말지는 호출하는 쪽이 정하므로, 검색어가 null이거나 공백뿐이면 일치하는 회원이 없어 빈 목록이다.
*/
List searchIdsByKeyword(String keyword);
+ // 학생회 구성원(학생회 부서가 있는 회원). 이름, 같은 이름이면 학번 오름차순
+ List findAllCouncilMembers();
Member updateProfile(Long id, MemberProfileCommand command);
Member registerOrUpdateByStudentId(MemberProfileCommand command);
Member signUp(Long memberId, MemberSignUpCommand command);
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
index 16babadf..7f588363 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
@@ -49,6 +49,11 @@ public List searchIdsByKeyword(String keyword) {
return memberRepository.searchIdsByKeyword(keyword);
}
+ @Override
+ public List findAllCouncilMembers() {
+ return memberRepository.findAllCouncilMembers();
+ }
+
@Override
@Transactional
public Member updateProfile(Long id, MemberProfileCommand command) {
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaEntity.java
index 024a5ab6..0ed45248 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaEntity.java
@@ -7,6 +7,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
+import java.time.LocalDate;
import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkSchedule;
import lombok.AccessLevel;
@@ -16,7 +17,8 @@
@Table(
name = "work_schedules",
indexes = {
- @Index(name = "idx_work_schedules_member_id", columnList = "member_id")
+ @Index(name = "idx_work_schedules_member_id", columnList = "member_id"),
+ @Index(name = "idx_work_schedules_work_date", columnList = "work_date")
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@@ -30,8 +32,8 @@ public class WorkScheduleJpaEntity extends BaseTimeEntity {
@Column(name = "member_id", nullable = false)
private Long memberId;
- @Column(name = "day_of_week", nullable = false, length = 10)
- private String dayOfWeek;
+ @Column(name = "work_date", nullable = false)
+ private LocalDate workDate;
@Column(name = "start_hour", nullable = false, columnDefinition = "TINYINT")
private int startHour;
@@ -42,7 +44,7 @@ public class WorkScheduleJpaEntity extends BaseTimeEntity {
private WorkScheduleJpaEntity(WorkSchedule schedule) {
this.id = schedule.getId();
this.memberId = schedule.getMemberId();
- this.dayOfWeek = schedule.getDayOfWeek();
+ this.workDate = schedule.getWorkDate();
this.startHour = schedule.getStartHour();
this.endHour = schedule.getEndHour();
}
@@ -52,6 +54,6 @@ public static WorkScheduleJpaEntity from(WorkSchedule schedule) {
}
public WorkSchedule toDomain() {
- return WorkSchedule.of(id, memberId, dayOfWeek, startHour, endHour);
+ return WorkSchedule.of(id, memberId, workDate, startHour, endHour);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaRepository.java
new file mode 100644
index 00000000..18cf51c9
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaRepository.java
@@ -0,0 +1,20 @@
+package kr.ac.kookmin.stream.db.internal;
+
+import java.time.LocalDate;
+import java.util.Collection;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface WorkScheduleJpaRepository extends JpaRepository {
+
+ @Query("""
+ SELECT s FROM WorkScheduleJpaEntity s
+ WHERE s.workDate BETWEEN :from AND :to
+ ORDER BY s.workDate, s.startHour
+ """)
+ List findAllByWorkDateBetween(@Param("from") LocalDate from, @Param("to") LocalDate to);
+
+ List findAllByWorkDateIn(Collection workDates);
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleRepositoryImpl.java
new file mode 100644
index 00000000..690994cf
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleRepositoryImpl.java
@@ -0,0 +1,54 @@
+package kr.ac.kookmin.stream.db.internal;
+
+import java.time.LocalDate;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import kr.ac.kookmin.stream.internal.domain.schedule.domain.WorkSchedule;
+import kr.ac.kookmin.stream.internal.domain.schedule.repository.WorkScheduleRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class WorkScheduleRepositoryImpl implements WorkScheduleRepository {
+
+ private final WorkScheduleJpaRepository workScheduleJpaRepository;
+
+ @Override
+ public Optional findById(Long id) {
+ return workScheduleJpaRepository.findById(id).map(WorkScheduleJpaEntity::toDomain);
+ }
+
+ @Override
+ public List findAllByWorkDateBetween(LocalDate from, LocalDate to) {
+ return workScheduleJpaRepository.findAllByWorkDateBetween(from, to).stream()
+ .map(WorkScheduleJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public List findAllByWorkDateIn(Collection workDates) {
+ return workScheduleJpaRepository.findAllByWorkDateIn(workDates).stream()
+ .map(WorkScheduleJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public List saveAll(List workSchedules) {
+ return workScheduleJpaRepository.saveAll(workSchedules.stream().map(WorkScheduleJpaEntity::from).toList())
+ .stream()
+ .map(WorkScheduleJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public WorkSchedule save(WorkSchedule workSchedule) {
+ return workScheduleJpaRepository.save(WorkScheduleJpaEntity.from(workSchedule)).toDomain();
+ }
+
+ @Override
+ public void deleteById(Long id) {
+ workScheduleJpaRepository.deleteById(id);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
index 7502c8fe..a9256225 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
@@ -31,6 +31,14 @@ OR m.studentId LIKE CONCAT('%', :keyword, '%') ESCAPE '!')
@Query("SELECT m.id FROM MemberJpaEntity m WHERE m.isDeleted = false")
List findAllActiveIds();
+ @Query("""
+ SELECT m FROM MemberJpaEntity m
+ WHERE m.isDeleted = false
+ AND m.councilDepartment IS NOT NULL
+ ORDER BY m.name, m.studentId
+ """)
+ List findAllCouncilMembers();
+
// 알림 설정이 없는 회원(로그인만 하고 가입을 마치지 않은 회원)은 푸시 대상에서 빠진다
@Query("""
SELECT new kr.ac.kookmin.stream.db.member.PushTargetProjection(m.id, m.fcmToken, s)
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java
index e8a654fe..ecfe2b93 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java
@@ -50,6 +50,13 @@ public List searchIdsByKeyword(String keyword) {
return memberJpaRepository.searchIdsByKeyword(LikePatterns.escape(keyword));
}
+ @Override
+ public List findAllCouncilMembers() {
+ return memberJpaRepository.findAllCouncilMembers().stream()
+ .map(MemberJpaEntity::toDomain)
+ .toList();
+ }
+
@Override
public Member save(Member member) {
try {
diff --git a/infrastructure/db/src/main/resources/db/migration/V28__delete_rows_from_work_schedules.sql b/infrastructure/db/src/main/resources/db/migration/V28__delete_rows_from_work_schedules.sql
new file mode 100644
index 00000000..11b2ab22
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V28__delete_rows_from_work_schedules.sql
@@ -0,0 +1,3 @@
+-- 요일만 저장된 근무 시간은 어느 주의 근무인지 알 수 없어 근무일로 옮길 수 없다.
+-- 이 테이블에 쓰는 API도 아직 없었으므로 비우고 근무일 기준으로 바꾼다(V29).
+DELETE FROM work_schedules;
diff --git a/infrastructure/db/src/main/resources/db/migration/V29__replace_day_of_week_with_work_date_in_work_schedules.sql b/infrastructure/db/src/main/resources/db/migration/V29__replace_day_of_week_with_work_date_in_work_schedules.sql
new file mode 100644
index 00000000..d58aa3cd
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V29__replace_day_of_week_with_work_date_in_work_schedules.sql
@@ -0,0 +1,7 @@
+-- 근무 시간표는 주마다 달라서 요일 대신 근무일을 저장한다.
+ALTER TABLE work_schedules
+ DROP COLUMN day_of_week,
+ ADD COLUMN work_date DATE NOT NULL AFTER member_id;
+
+-- 주 단위 범위 조회(work_date BETWEEN 월 AND 금)와 겹침 검사(work_date IN ...)용
+CREATE INDEX idx_work_schedules_work_date ON work_schedules (work_date);