From cc5ac8cce7e49c27cf56828031fca73d2c1a9e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:39:39 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C=EB=A5=BC=20=EC=9A=94=EC=9D=BC=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EA=B7=BC=EB=AC=B4=EC=9D=BC=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/schedule/domain/WorkSchedule.java | 7 ++++--- .../stream/db/internal/WorkScheduleJpaEntity.java | 12 +++++++----- .../V28__delete_rows_from_work_schedules.sql | 3 +++ ..._day_of_week_with_work_date_in_work_schedules.sql | 7 +++++++ 4 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V28__delete_rows_from_work_schedules.sql create mode 100644 infrastructure/db/src/main/resources/db/migration/V29__replace_day_of_week_with_work_date_in_work_schedules.sql 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..6fcd5210 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,5 +1,6 @@ package kr.ac.kookmin.stream.internal.domain.schedule.domain; +import java.time.LocalDate; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -12,17 +13,17 @@ public class WorkSchedule { private Long id; private Long memberId; - private String dayOfWeek; + private LocalDate workDate; private int startHour; private int 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); } } 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/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); From d070bb1095cb054426efec1ab84ff4815ce1ae6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:30:15 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=ED=9A=8C=20?= =?UTF-8?q?=EB=B6=80=EC=84=9C=EA=B0=80=20=EC=9E=88=EB=8A=94=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/member/domain/member/domain/Member.java | 4 ++++ .../member/domain/member/repository/MemberRepository.java | 2 ++ .../member/domain/member/service/MemberService.java | 2 ++ .../domain/member/service/impl/MemberServiceImpl.java | 5 +++++ .../ac/kookmin/stream/db/member/MemberJpaRepository.java | 8 ++++++++ .../ac/kookmin/stream/db/member/MemberRepositoryImpl.java | 7 +++++++ 6 files changed, 28 insertions(+) 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..1174128d 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 findAllWithCouncilDepartment(); 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..0b91236c 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 findAllWithCouncilDepartment(); 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..b54ef489 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 findAllWithCouncilDepartment() { + return memberRepository.findAllWithCouncilDepartment(); + } + @Override @Transactional public Member updateProfile(Long id, MemberProfileCommand command) { 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..fab75dc9 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 findAllWithCouncilDepartment(); + // 알림 설정이 없는 회원(로그인만 하고 가입을 마치지 않은 회원)은 푸시 대상에서 빠진다 @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..850ee1c2 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 findAllWithCouncilDepartment() { + return memberJpaRepository.findAllWithCouncilDepartment().stream() + .map(MemberJpaEntity::toDomain) + .toList(); + } + @Override public Member save(Member member) { try { From d0d0e0cfdee224947766623b796e1a16518def88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:59:39 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C=20=EB=93=B1=EB=A1=9D=C2=B7=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=C2=B7=EC=88=98=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/schedule/domain/WorkSchedule.java | 54 +++++++++ .../schedule/domain/WorkScheduleCommand.java | 11 ++ .../domain/WorkScheduleErrorCode.java | 23 ++++ .../schedule/domain/WorkScheduleWeek.java | 11 ++ .../domain/schedule/domain/WorkWeek.java | 19 ++++ .../repository/WorkScheduleRepository.java | 17 +++ .../service/AdminWorkScheduleService.java | 25 +++++ .../impl/AdminWorkScheduleServiceImpl.java | 103 ++++++++++++++++++ .../internal/WorkScheduleJpaRepository.java | 20 ++++ .../internal/WorkScheduleRepositoryImpl.java | 54 +++++++++ 10 files changed, 337 insertions(+) create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleCommand.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleErrorCode.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleWeek.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkWeek.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/repository/WorkScheduleRepository.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/AdminWorkScheduleService.java create mode 100644 core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/impl/AdminWorkScheduleServiceImpl.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/internal/WorkScheduleRepositoryImpl.java 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 6fcd5210..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,22 +1,39 @@ 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 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, @@ -26,4 +43,41 @@ public static WorkSchedule of( ) { 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..1b768f09 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleCommand.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.internal.domain.schedule.domain; + +import java.time.LocalDate; + +// 근무 시간 등록·수정 요청 한 건. 근무자는 요청 단위 값이라 따로 받는다 +public record WorkScheduleCommand(LocalDate workDate, int startHour, int endHour) { + + public static WorkScheduleCommand of(LocalDate workDate, int startHour, int endHour) { + return new WorkScheduleCommand(workDate, startHour, 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..0e34792e --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/domain/WorkScheduleWeek.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.internal.domain.schedule.domain; + +import java.util.List; + +// 한 주의 근무 시간표. editable은 그 주가 지난 주가 아니어서 근무 시간을 바꿀 수 있는지다 +public record WorkScheduleWeek(WorkWeek week, boolean editable, List schedules) { + + public static WorkScheduleWeek of(WorkWeek week, boolean editable, List schedules) { + return new WorkScheduleWeek(week, editable, 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..828e0ba9 --- /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 of(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(of(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..252917d2 --- /dev/null +++ b/core/domain/internal/src/main/java/kr/ac/kookmin/stream/internal/domain/schedule/service/impl/AdminWorkScheduleServiceImpl.java @@ -0,0 +1,103 @@ +package kr.ac.kookmin.stream.internal.domain.schedule.service.impl; + +import java.time.LocalDate; +import java.util.List; +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.of(date == null ? today : date); + List schedules = workScheduleRepository.findAllByWorkDateBetween( + week.startDate(), week.endDate()); + return WorkScheduleWeek.of(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.of(workDate).isPastAt(today)) { + throw new BusinessException(WorkScheduleErrorCode.PAST_WEEK_NOT_EDITABLE); + } + } + + // 한 시간 칸에는 근무자 한 명만 들어간다. 근무자가 달라도 이미 있는 근무 시간과 겹치면 막고, 요청 안의 근무 시간끼리 겹쳐도 막는다. + // 구간 겹침은 유니크 제약으로 막을 수 없어 저장 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) + private void validateNotConflicted(List targets) { + Set workDates = targets.stream() + .map(WorkSchedule::getWorkDate) + .collect(Collectors.toSet()); + List existing = workScheduleRepository.findAllByWorkDateIn(workDates); + + for (int i = 0; i < targets.size(); i++) { + WorkSchedule target = targets.get(i); + // 수정할 때는 수정 대상의 원래 행도 함께 조회되므로 같은 id는 빼고 본다 + boolean overlapsExisting = existing.stream() + .filter(schedule -> !schedule.getId().equals(target.getId())) + .anyMatch(target::overlaps); + boolean overlapsRequested = targets.subList(i + 1, targets.size()).stream() + .anyMatch(target::overlaps); + if (overlapsExisting || overlapsRequested) { + throw new BusinessException(WorkScheduleErrorCode.WORK_SCHEDULE_CONFLICT); + } + } + } + + private WorkSchedule getById(Long scheduleId) { + return workScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new BusinessException(WorkScheduleErrorCode.WORK_SCHEDULE_NOT_FOUND)); + } +} 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); + } +} From 8f5261277eb17efd90a3957809908990fd3ab86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:07:10 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=EA=B7=BC=EB=AC=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C=20record=20=EC=83=9D=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=ED=91=9C=EC=A4=80=20=EC=83=9D=EC=84=B1=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/schedule/domain/WorkScheduleCommand.java | 4 ---- .../internal/domain/schedule/domain/WorkScheduleWeek.java | 4 ---- .../stream/internal/domain/schedule/domain/WorkWeek.java | 4 ++-- .../schedule/service/impl/AdminWorkScheduleServiceImpl.java | 6 +++--- 4 files changed, 5 insertions(+), 13 deletions(-) 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 index 1b768f09..e1d65a82 100644 --- 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 @@ -4,8 +4,4 @@ // 근무 시간 등록·수정 요청 한 건. 근무자는 요청 단위 값이라 따로 받는다 public record WorkScheduleCommand(LocalDate workDate, int startHour, int endHour) { - - public static WorkScheduleCommand of(LocalDate workDate, int startHour, int endHour) { - return new WorkScheduleCommand(workDate, startHour, endHour); - } } 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 index 0e34792e..e922a623 100644 --- 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 @@ -4,8 +4,4 @@ // 한 주의 근무 시간표. editable은 그 주가 지난 주가 아니어서 근무 시간을 바꿀 수 있는지다 public record WorkScheduleWeek(WorkWeek week, boolean editable, List schedules) { - - public static WorkScheduleWeek of(WorkWeek week, boolean editable, List schedules) { - return new WorkScheduleWeek(week, editable, 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 index 828e0ba9..5a47b923 100644 --- 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 @@ -7,13 +7,13 @@ // 근무 시간표의 한 주(월~금). 토·일은 그 앞 월요일의 주에 속한다 public record WorkWeek(LocalDate startDate, LocalDate endDate) { - public static WorkWeek of(LocalDate date) { + 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(of(today).startDate()); + return startDate.isBefore(from(today).startDate()); } } 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 index 252917d2..40d709a5 100644 --- 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 @@ -26,10 +26,10 @@ class AdminWorkScheduleServiceImpl implements AdminWorkScheduleService { @Override public WorkScheduleWeek getWeek(LocalDate date) { LocalDate today = LocalDate.now(DateUtil.KST); - WorkWeek week = WorkWeek.of(date == null ? today : date); + WorkWeek week = WorkWeek.from(date == null ? today : date); List schedules = workScheduleRepository.findAllByWorkDateBetween( week.startDate(), week.endDate()); - return WorkScheduleWeek.of(week, !week.isPastAt(today), schedules); + return new WorkScheduleWeek(week, !week.isPastAt(today), schedules); } @Override @@ -69,7 +69,7 @@ public void delete(Long scheduleId) { } private void validateNotPastWeek(LocalDate workDate, LocalDate today) { - if (WorkWeek.of(workDate).isPastAt(today)) { + if (WorkWeek.from(workDate).isPastAt(today)) { throw new BusinessException(WorkScheduleErrorCode.PAST_WEEK_NOT_EDITABLE); } } From d85a0cd83ae95409233bb005efc8f0919b423d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:07:10 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/AdminWorkScheduleApi.java | 80 +++++++++++++++++++ .../schedule/AdminWorkScheduleController.java | 80 +++++++++++++++++++ .../request/WorkScheduleRegisterRequest.java | 47 +++++++++++ .../request/WorkScheduleUpdateRequest.java | 31 +++++++ .../request/WorkScheduleWeekParams.java | 13 +++ .../response/WorkScheduleResponse.java | 34 ++++++++ .../response/WorkScheduleWeekResponse.java | 23 ++++++ .../schedule/response/WorkerResponse.java | 10 +++ .../usecase/AdminWorkScheduleUseCase.java | 70 ++++++++++++++++ .../schedule/usecase/MemberWorkSchedule.java | 17 ++++ .../usecase/MemberWorkScheduleWeek.java | 8 ++ 11 files changed, 413 insertions(+) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/AdminWorkScheduleController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleRegisterRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleUpdateRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/request/WorkScheduleWeekParams.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkScheduleWeekResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/response/WorkerResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/AdminWorkScheduleUseCase.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkSchedule.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/internal/schedule/usecase/MemberWorkScheduleWeek.java 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..7632f311 --- /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.findAllWithCouncilDepartment(); + } + + 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) { +} From 6783f693622d4383df3f4bc476e5ee6f18ab58c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:23:11 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20=ED=95=99=EC=83=9D=ED=9A=8C=20?= =?UTF-8?q?=EB=B6=80=EC=84=9C=20=ED=9A=8C=EC=9B=90=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/schedule/usecase/AdminWorkScheduleUseCase.java | 2 +- .../member/domain/member/repository/MemberRepository.java | 4 ++-- .../stream/member/domain/member/service/MemberService.java | 4 ++-- .../member/domain/member/service/impl/MemberServiceImpl.java | 4 ++-- .../kr/ac/kookmin/stream/db/member/MemberJpaRepository.java | 2 +- .../kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) 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 index 7632f311..5ffe94b2 100644 --- 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 @@ -30,7 +30,7 @@ public class AdminWorkScheduleUseCase { private final MemberService memberService; public List getWorkers() { - return memberService.findAllWithCouncilDepartment(); + return memberService.findAllCouncilMembers(); } public MemberWorkScheduleWeek getWeek(LocalDate date) { 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 1174128d..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,8 +10,8 @@ public interface MemberRepository { boolean existsByPhoneNumber(String phoneNumber); List findAllByIds(List ids); List searchIdsByKeyword(String keyword); - // 이름, 같은 이름이면 학번 오름차순 - List findAllWithCouncilDepartment(); + // 학생회 구성원(학생회 부서가 있는 회원). 이름, 같은 이름이면 학번 오름차순 + 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 0b91236c..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,8 +16,8 @@ public interface MemberService { * 검색할지 말지는 호출하는 쪽이 정하므로, 검색어가 null이거나 공백뿐이면 일치하는 회원이 없어 빈 목록이다. */ List searchIdsByKeyword(String keyword); - // 학생회 부서가 있는 회원. 이름, 같은 이름이면 학번 오름차순 - List findAllWithCouncilDepartment(); + // 학생회 구성원(학생회 부서가 있는 회원). 이름, 같은 이름이면 학번 오름차순 + 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 b54ef489..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 @@ -50,8 +50,8 @@ public List searchIdsByKeyword(String keyword) { } @Override - public List findAllWithCouncilDepartment() { - return memberRepository.findAllWithCouncilDepartment(); + public List findAllCouncilMembers() { + return memberRepository.findAllCouncilMembers(); } @Override 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 fab75dc9..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 @@ -37,7 +37,7 @@ OR m.studentId LIKE CONCAT('%', :keyword, '%') ESCAPE '!') AND m.councilDepartment IS NOT NULL ORDER BY m.name, m.studentId """) - List findAllWithCouncilDepartment(); + List findAllCouncilMembers(); // 알림 설정이 없는 회원(로그인만 하고 가입을 마치지 않은 회원)은 푸시 대상에서 빠진다 @Query(""" 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 850ee1c2..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 @@ -51,8 +51,8 @@ public List searchIdsByKeyword(String keyword) { } @Override - public List findAllWithCouncilDepartment() { - return memberJpaRepository.findAllWithCouncilDepartment().stream() + public List findAllCouncilMembers() { + return memberJpaRepository.findAllCouncilMembers().stream() .map(MemberJpaEntity::toDomain) .toList(); } From 29b64fa79585fe26d87227759cc889ad9ea570b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:42:25 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EA=B7=BC=EB=AC=B4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B2=B9=EC=B9=A8=20=EA=B2=80=EC=82=AC=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/AdminWorkScheduleServiceImpl.java | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) 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 index 40d709a5..3665f7f8 100644 --- 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 @@ -1,7 +1,9 @@ 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; @@ -74,26 +76,32 @@ private void validateNotPastWeek(LocalDate workDate, LocalDate today) { } } - // 한 시간 칸에는 근무자 한 명만 들어간다. 근무자가 달라도 이미 있는 근무 시간과 겹치면 막고, 요청 안의 근무 시간끼리 겹쳐도 막는다. + // 한 시간 칸에는 근무자 한 명만 들어간다. 저장된 근무 시간 위에 요청한 근무 시간을 하나씩 올려 보며 + // 먼저 올라간 것(저장된 것·앞선 요청)과 겹치면 막는다. 근무자가 달라도 해당한다. // 구간 겹침은 유니크 제약으로 막을 수 없어 저장 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) 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()); - List existing = workScheduleRepository.findAllByWorkDateIn(workDates); + Set targetIds = targets.stream() + .map(WorkSchedule::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); - for (int i = 0; i < targets.size(); i++) { - WorkSchedule target = targets.get(i); - // 수정할 때는 수정 대상의 원래 행도 함께 조회되므로 같은 id는 빼고 본다 - boolean overlapsExisting = existing.stream() - .filter(schedule -> !schedule.getId().equals(target.getId())) - .anyMatch(target::overlaps); - boolean overlapsRequested = targets.subList(i + 1, targets.size()).stream() - .anyMatch(target::overlaps); - if (overlapsExisting || overlapsRequested) { - throw new BusinessException(WorkScheduleErrorCode.WORK_SCHEDULE_CONFLICT); - } - } + return workScheduleRepository.findAllByWorkDateIn(workDates).stream() + .filter(saved -> !targetIds.contains(saved.getId())) + .toList(); } private WorkSchedule getById(Long scheduleId) {