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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
- **표시명은 서버가 내린다.** 유형·상세·승인함 응답이 `authorizerAuthorityCode`와 함께 `authorizerAuthorityName`(`authrt_nm`, `AuthorityNameFinder` 경유)을 싣고, 유형 폼의 선택지는 `GET /v1/sub-work-types/authorizer-authorities`가 준다 — 권한 이름은 화면에서 바뀌는 데이터라 웹이 코드 → 이름 사전을 하드코딩하면(옛 `AUTZR_ROLE_NM`) 개명 즉시 어긋난다.
- 직위 코드 시절 `PATCH /v1/roles/{roleId}`에 있던 **'빈 문자열 = 해제' 특례는 필드와 함께 사라졌다.** 역할 CRUD에는 자격 관련 필드가 없다 — 자격 부여·회수는 `PUT /v1/roles/{roleId}/authorities` 하나로 수렴한다.

- **담당자 후보 목록의 역할 칸은 `displayRoleName`이다** (ssccops#628 · #709 · `AssignableMemberResponse`). 운영진 역할(리더 역할이 아닌 역할)을 먼저 싣는다 — ① 대표 역할이 운영진 역할이면 그것 ② 운영진 역할 중 표시 순번이 가장 앞선 것 ③ 대표 역할(리더 포함) ④ null. `representativeRoleName`은 뜻을 바꾸지 않고 남겼다(lms 팀원 추가 시트가 «대표 역할»로 읽는다). 대표 역할만 그리면 대표 표시 없는 회장이 «정회원»(등급)으로, 대표가 트랙장인 최고관리자가 «트랙장»으로 찍혀 «역할 없는 회원이 담당자 후보에 있다»는 제보가 됐다(dev 2026-11-11 실측 — 후보 7명 모두 운영진이었다). 운영진 여부는 `LeaderRole.isLeaderRoleName` 하나로 가른다. 후보를 거르는 기준(`authority`)과 등록·수정의 판정은 운영 도메인 `OperationOwnerPolicy`(`../operation/AGENTS.md` «담당자는 그 일을 처리할 권한이 있는 활동 회원이다»).

## 다른 도메인과 닿는 곳

- 운영: 담당 하위 업무 건수는 `MemberSubWorkLoadProvider` 하나로 묻는다. 운영의 `ApprovalAuthorityPolicy`는 `AuthorityPolicy`의 펼침을 부른다(위 «승인·투표 자격도 권한이 준다» 항목).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,20 @@ public ApiResponse<List<MemberSummaryResponse>> searchMembers(
* 목록이고, 동아리 회원 수 규모에서 나누어 받을 이유가 없다 (#37의 응답 목록과 같은 판단).
*
* authority 쿼리 파라미터(#101): 업무 등록의 담당자·회의 등록의 책임자는 국장 이상
* (WORK_MANAGE·MEETING_MANAGE)만 고를 수 있어야 한다 — 생략하면 지금처럼 전체(하위 업무
* 담당자는 국원도 될 수 있다). 판정은 AuthorityPolicy.memberIdsWithAuthority 하나뿐이다.
* (WORK_MANAGE·MEETING_MANAGE)만 고를 수 있어야 한다. 하위 업무 담당자는 WORK_READ
* 보유자다(ssccops#628 · #709 — 국원 이상, 리더 역할·역할 없는 회원 제외). 판정은
* AuthorityPolicy.memberIdsWithAuthority 하나뿐이고, 등록·수정이 같은 기준으로 거절한다
* (OperationOwnerPolicy). 생략하면 탈퇴·제명만 뺀 전체다 — 학술 팀원 추가(lms)·행사 수동
* 등록처럼 운영진이 아닌 회원을 고르는 화면이 쓴다.
*/
@Operation(
summary = "담당자 후보 조회",
description =
"담당자·회의 책임자로 지정할 수 있는 회원 목록. 탈퇴·제명 회원은 빠진다."
+ " authority를 주면 그 권한을 오늘 행사할 수 있는 회원으로 좁힌다(예:"
+ " WORK_MANAGE·MEETING_MANAGE). 권한 없이 부를 수 있는 목록이라"
+ " authority를 주면 그 권한을 오늘 행사할 수 있는 회원으로 좁힌다(업무"
+ " 담당자 WORK_MANAGE · 하위 업무 담당자 WORK_READ · 회의 책임자"
+ " MEETING_MANAGE). displayRoleName은 목록에 적을 역할명으로, 운영진"
+ " 역할을 리더 역할보다 먼저 싣는다. 권한 없이 부를 수 있는 목록이라"
+ " 연락처·이메일·학번은 내리지 않는다.")
@GetMapping("/assignable")
public ApiResponse<List<AssignableMemberResponse>> findAssignableMembers(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,35 @@
* representativeRoleName은 현재 역할 중 rprs_role_yn = true인 것의 이름이다. 대표 역할은
* **표시용**이라 여기서만 쓰고 정렬·판정에는 쓰지 않는다 (BR-M26). 대표로 지정된 역할이
* 없으면 null이며, 화면은 그 자리를 비워 둔다.
*
* displayRoleName은 **담당자 목록 한 줄에 적을 역할명**이다 (ssccops#628 · #709). 위의 «어떤
* 사람인지»를 더 정확히 말하려고 더했다 — 새 정보가 아니라 같은 현재 역할에서 고르는 순서만
* 다르다. 운영진 역할(리더 역할이 아닌 역할)을 먼저 싣는다:
* ① 대표 역할이 운영진 역할이면 그것 ② 운영진 역할 중 표시 순번이 가장 앞선 것
* ③ 대표 역할(리더 역할 포함) ④ null
* 대표 표시만 보면 운영진 역할에 대표 표시가 없는 회원은 «정회원»(등급)으로, 대표 역할이 트랙장인
* 최고관리자는 «트랙장»으로 찍힌다 — 그래서 «역할 없는 회원이 담당자 후보에 있다»로 읽혔다(dev
* 2026-11-11). representativeRoleName의 뜻을 바꾸지 않고 필드를 나눈 것은 lms 팀원 추가 시트가
* 그 값을 «대표 역할»로 읽기 때문이다.
*/
public record AssignableMemberResponse(
Long memberId,
String name,
Integer generationNumber,
String membershipGradeCode,
String membershipGradeName,
String representativeRoleName) {
String representativeRoleName,
String displayRoleName) {

public static AssignableMemberResponse of(MemberEntity member, String representativeRoleName) {
public static AssignableMemberResponse of(
MemberEntity member, String representativeRoleName, String displayRoleName) {
return new AssignableMemberResponse(
member.getId(),
member.getName(),
member.getGenerationNumber(),
member.getMembershipGrade().getCode(),
member.getMembershipGrade().getName(),
representativeRoleName);
representativeRoleName,
displayRoleName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ MemberDetailResponse updateMember(
* 업무·회의 등록의 담당자는 국장 이상(WORK_MANAGE·MEETING_MANAGE)만 고를 수 있어야
* 하는데, 그 판정은 AuthorityPolicy.memberIdsWithAuthority 한 곳에서만 한다(BR-M28과
* 같은 이유 — 화면이 이 판정을 다시 구현하면 후보 목록과 실제 등록 가능 여부가 갈린다).
* null이면 지금처럼 탈퇴·제명만 뺀 전체다(하위 업무 담당자는 국원도 될 수 있어야 한다).
* 하위 업무 담당자는 WORK_READ다(ssccops#628). 등록·수정 쪽 판정은 운영 도메인의
* OperationOwnerPolicy가 같은 두 규칙(findAssignableMember · AuthorityPolicy)으로 한다.
* null이면 탈퇴·제명만 뺀 전체다(학술 팀원 추가·행사 수동 등록 — 운영진이 아닌 회원을 고른다).
*/
List<AssignableMemberResponse> findAssignableMembers(AuthorityCode requiredAuthority);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.sscc.ssccopsserver.domain.member.code.AuthorityCode;
import org.sscc.ssccopsserver.domain.member.code.LeaderRole;
import org.sscc.ssccopsserver.domain.member.code.MemberGradeCode;
import org.sscc.ssccopsserver.domain.member.code.MemberStatusCode;
import org.sscc.ssccopsserver.domain.member.code.error.MemberErrorCode;
Expand Down Expand Up @@ -655,15 +656,39 @@ public List<AssignableMemberResponse> findAssignableMembers(AuthorityCode requir

return candidates.stream()
.map(
member ->
AssignableMemberResponse.of(
member,
representativeRoleNameOf(
rolesByMemberId.getOrDefault(
member.getId(), List.of()))))
member -> {
List<MemberRoleResponse> roles =
rolesByMemberId.getOrDefault(member.getId(), List.of());
return AssignableMemberResponse.of(
member,
representativeRoleNameOf(roles),
displayRoleNameOf(roles));
})
.toList();
}

/*
* 담당자 목록 한 줄에 적을 역할명 (ssccops#628 · #709) — 운영진 역할을 먼저 싣는다.
* 고르는 순서와 그 이유는 AssignableMemberResponse 주석.
*
* roles는 currentRolesOf가 표시 순번(role.displayOrder) → 역할 식별자 순으로 받아 온 그대로다
* (findValidByMemberIds의 order by). 그래서 «운영진 역할 중 첫 것»이 곧 표시 순번이 가장 앞선
* 것이고, 여기서 다시 정렬하지 않는다. 운영진인지는 역할 이름이 리더 역할(LeaderRole)인지로만
* 가른다 — 리더 역할의 이름이 정해진 곳이 거기 한 곳이다(ADR-0067).
*/
private static String displayRoleNameOf(List<MemberRoleResponse> roles) {
List<MemberRoleResponse> staffRoles =
roles.stream()
.filter(role -> !LeaderRole.isLeaderRoleName(role.roleName()))
.toList();
return staffRoles.stream()
.filter(MemberRoleResponse::representative)
.findFirst()
.or(() -> staffRoles.stream().findFirst())
.map(MemberRoleResponse::roleName)
.orElseGet(() -> representativeRoleNameOf(roles));
}

@Override
@Transactional(readOnly = true)
public List<MemberGradeResponse> findAllGrades() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
- 운영 태그(#637 · ssccops#576 · V31 · 처음 #624 · V29): `OperationTagEntity`(`oper_tag`) · `OperationTagRelationEntity`(`oper_tag_rel` · `oper_id`) · `OperationTagService` · `OperationTagController`. 업무·하위 업무·회의가 같은 태그 목록을 쓴다. 주관 국도 태그다(별도 칸 없음).
- 회의 안건 응답의 `targetOperation.targetId`는 운영 유형의 상세 ID다(업무 `work_id` · 하위 업무 `sub_work_id` · 회의 `mtg_id` · #635 · ssccops#575) — 상세 주소는 운영 ID(`oper_id`)가 아니라 이 값을 받는다(화면이 운영 ID로 업무 상세를 열어 같은 숫자의 다른 업무가 열렸다). `OperationRepository.findDetailIdsByOperationIds`가 세 확장 테이블을 left join 해 안건 묶음당 1회로 읽는다(`MeetingServiceImpl.toAgendaResponses` · 안건마다 조회하면 N+1).
- 서비스: `WorkService` · `SubWorkService`(전이·투표·체크리스트) · `SubWorkTypeService` · `MeetingService` · `ApprovalService`(승인함) · `DashboardService` · `OperationService`(운영 통합 조회). 승인함·대시보드·통합 조회는 **다른 서비스의 조회를 그대로 불러 조립만 한다** — 필터·정렬·집계 규칙을 새로 만들지 않는다(각 Impl 주석).
- «유일한 구현» 클래스: `ApprovalAuthorityPolicy`(누가 승인·반려·투표할 수 있는가 — 판정 재료는 권한이다, #123) · `SubWorkOwnershipPolicy`(이 하위 업무를 다룰 수 있는 사람인가 — `WORK_MANAGE` 또는 담당자 본인, #101) · `DeadlinePolicy`(마감 경계는 **일자**, #121).
- «유일한 구현» 클래스: `ApprovalAuthorityPolicy`(누가 승인·반려·투표할 수 있는가 — 판정 재료는 권한이다, #123) · `SubWorkOwnershipPolicy`(이 하위 업무를 다룰 수 있는 사람인가 — `WORK_MANAGE` 또는 담당자 본인, #101) · `DeadlinePolicy`(마감 경계는 **일자**, #121) · `OperationOwnerPolicy`(누구를 업무·하위 업무의 담당자로 지정할 수 있는가 — 활동 회원이면서 업무 `WORK_MANAGE` · 하위 업무 `WORK_READ`, ssccops#628 · #709).
- 포트 구현: `SubWorkOwnerLoadProvider`(회원의 `MemberSubWorkLoadProvider`) · `SubWorkSharePreviewProvider`·`WorkSharePreviewProvider`·`MeetingSharePreviewProvider`(공유의 `SharePreviewProvider`). 포트 구현을 서비스 구현체에 얹지 않는 이유는 `SubWorkOwnerLoadProvider` 주석.

## 규칙

- **담당자는 그 일을 처리할 권한이 있는 활동 회원이다** (ssccops#628 · #709). 업무는 `WORK_MANAGE`(#101 «국장 이상»), 하위 업무는 `WORK_READ`(국원 이상 — 하위 업무 API가 전부 이 권한을 요구하므로 없으면 자기 건을 못 연다)를 오늘 행사할 수 있어야 한다. 판정은 `OperationOwnerPolicy` 한 곳이고 재료는 회원 도메인의 둘(`MemberService.findAssignableMember` — 탈퇴·제명 · `AuthorityPolicy.hasAuthority`)이다 — 담당자 후보 목록(`GET /v1/members/assignable?authority=`)이 같은 두 규칙으로 거르므로 «목록에는 있는데 등록하면 거절»이 없다. 그전에는 목록만 권한으로 걸렀고 등록·수정은 탈퇴·제명만 봐서 API·MCP로는 누구든 업무 담당자가 됐고, 하위 업무 후보는 활동 회원 전원이었다. 활동 회원이 아니면 400 `OWNER_NOT_ACTIVE_MEMBER`, 권한이 없으면 400 `WORK_OWNER_NOT_ELIGIBLE`·`SUB_WORK_OWNER_NOT_ELIGIBLE`(코드 문자열은 셋 다 `VALIDATION_FAILED` — 고칠 길이 달라 enum만 나눴다). **수정은 담당자가 바뀔 때만 판정한다** — 같은 담당자를 다시 보내면 탈퇴·제명·임기 종료여도 그대로 둔다(완료된 업무의 총평을 쓰려고 «누가 맡았나»를 지우지 않게). 회의 안건 승격은 등록 서비스를 부르므로 따라온다. 리더 역할은 권한을 갖지 못하므로(ADR-0067) 리더 역할만 가진 회원은 어느 담당자도 못 된다. 기각: 역할 이름(리더 셋 제외)으로 가르기 — `WORK_READ` 없는 역할의 담당자가 403으로 자기 건을 못 열고 판정이 두 벌이 된다. 회의 책임자(`MEETING_MANAGE`)는 같은 구조(목록만 거른다)지만 ssccops#628 범위 밖이다 — 넣을 때는 `OperationOwnerPolicy.Kind`에 한 줄.
- 전이 판단은 엔티티가 한다 — `SubWorkEntity.applyTransition`·`requireChecklistEditable`·`requireVotable` · `MeetingEntity.applyTransition`·`requireAgendaEditable` · `WorkEntity.applyTransition`. 서비스(`SubWorkServiceImpl.transitionSubWork`·`WorkServiceImpl.transitionWork`)는 조회·기록·집계만 맡는다.
- **상위 업무 전이** (#622 · ssccops#563) — `POST /v1/works/{workId}/transitions`, `WORK_MANAGE`. 착수(기획→진행)·검토 요청(진행→검토)·완료(검토→완료)·검토 되돌리기(검토→진행)·재개(완료→진행) 다섯 줄(`WorkTransitionAction`)이고 나머지는 409 `TRANSITION_NOT_ALLOWED`. **완료가 아니고 지우지 않은 하위 업무가 하나라도 있으면 완료는 409 `SUB_WORK_UNFINISHED`**이며 남은 수는 코드가 아니라 메시지(`GeneralException` detail)에 실린다(하위 업무 0건이면 통과). 지운 하위 업무를 세지 않는 것은 되살릴 길이 없어(`OperationEntity`에 restore가 없다) 그 업무가 영영 완료되지 못하기 때문이다. **상태 이력 표는 없다** — 운영진이 «감사 로그로 충분»을 골랐고 기록은 `work.transition` 감사 줄(decision = 전이 · change = 상태 전후)뿐이다. 하위 업무 전이(`TransitionAction`)와 enum을 나눈 이유는 `WorkTransitionAction` 주석. 하위 업무처럼 담당자 본인에게 열지 않은 이유는 상위 업무의 담당자가 운영 건 수준이라서다(ssccops#563 판단 표).
- 승인·완료·반려는 유형이 지정한 결재 권한 보유자만, 찬반 투표는 `APPROVAL_VOTE` 보유자만 — 자격의 시드와 표시명 규칙은 회원 도메인 문서의 «승인·투표 자격도 권한이 준다» 항목(`../member/AGENTS.md`). 자가 승인 판정은 담당자가 아니라 **등록자** 기준이다(`SubWorkServiceImpl` 주석).
Expand All @@ -39,6 +40,7 @@

## 테스트 함정

- **업무·하위 업무를 만드는 시험의 담당자에게는 권한이 있어야 한다** (ssccops#628) — 역할 없는 회원을 `ownerId`로 넣으면 400이다. 시드된 국장(`MemberRoleFixture.DIRECTOR`)이 `WORK_MANAGE`를 주지만 **투표(`APPROVAL_VOTE`)·국장 결재까지 함께 준다** — 승인·투표 자격을 세는 시험은 `MemberRoleFixture.WORK_OWNER`에 `grantAuthorities(…, WORK_MANAGE)`만 붙여 담당자가 투표자가 되지 않게 한다(`ApprovalServiceImplTest`·`SubWorkApprovalVoteServiceTest`). 하위 업무만 맡는 회원은 국원(`STAFF`)이면 된다.
- 승인함·목록 테스트는 쿼리 횟수를 숫자로 못 박는다 — 조회를 하나 더하면 그 숫자를 함께 고친다(주석의 셈도).
- 트랜잭션을 건 컨트롤러 테스트에서 **실패하는 요청은 마지막 하나**다 — 근거는 `../member/AGENTS.md`의 `RoleManageSelfLockGuard` 항목(참여 트랜잭션의 rollback-only 표시).
- `MemberChangeControllerTest`가 `MemberSubWorkLoadProvider`를 `@MockitoBean`으로 바꾼다 — 그래서 포트 구현이 `SubWorkServiceImpl`에 얹혀 있으면 안 된다(`SubWorkOwnerLoadProvider` 주석).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ public enum OperationErrorCode implements ErrorCode {
// 400 — 담당자로 지정한 회원이 없거나 활동 회원이 아닐 때
OWNER_NOT_ACTIVE_MEMBER(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "담당자로 지정할 수 없는 회원입니다."),

/*
* 400 — 활동 회원이지만 그 운영 건을 처리할 권한이 없을 때 (ssccops#628 · #709). 업무 담당자는
* WORK_MANAGE, 하위 업무 담당자는 WORK_READ를 오늘 행사할 수 있어야 한다 — 기준은 담당자 후보
* 목록(GET /v1/members/assignable?authority=)이 거르는 것과 같다.
*
* OWNER_NOT_ACTIVE_MEMBER와 나눈 것은 고칠 길이 달라서다. 저쪽은 회원 상태를, 이쪽은 역할과
* 역할별 권한을 손봐야 한다 — MCP 호출자는 메시지만 보고 다음 행동을 고른다. 코드 문자열은
* 정의서에 없는 값을 새로 만들지 않으려고 같은 VALIDATION_FAILED다(INVALID_CURSOR와 같은 판단).
*/
WORK_OWNER_NOT_ELIGIBLE(
HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "업무 담당자는 업무 관리 권한이 있는 회원만 지정할 수 있습니다."),

SUB_WORK_OWNER_NOT_ELIGIBLE(
HttpStatus.BAD_REQUEST,
"VALIDATION_FAILED",
"하위 업무 담당자는 업무·하위 업무 조회 권한이 있는 회원만 지정할 수 있습니다."),

// 400 — 종료 일시가 시작 일시보다 빠를 때. DTO 검증을 통과해도 서비스에서 한 번 더 막는다
INVALID_OPERATION_PERIOD(
HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "종료 일시는 시작 일시보다 빠를 수 없습니다."),
Expand Down
Loading
Loading