feat: approval workflow with approver/backupApprover

This commit is contained in:
pray4skylark
2026-06-08 17:49:39 +09:00
parent 9c09a89643
commit 3047354571
17 changed files with 461 additions and 82 deletions
@@ -237,6 +237,13 @@ public class EmployeeController {
return ResponseEntity.ok(ApiResponse.ok("비상연락망이 삭제되었습니다", null));
}
@Operation(summary = "승인자 목록 조회", description = "티켓 승인자로 지정 가능한 MANAGER/ADMIN 목록을 조회합니다")
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
@GetMapping("/approvers")
public ResponseEntity<ApiResponse<List<EmployeeSearchResult>>> getApprovers() {
return ResponseEntity.ok(ApiResponse.ok(employeeService.getApprovers()));
}
@Operation(summary = "프로필 이미지 설정", description = "직원의 프로필 이미지 URL을 저장합니다")
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
@PutMapping("/{empId}/profile-image")
@@ -110,6 +110,31 @@ public class TicketController {
return ResponseEntity.ok(ApiResponse.ok("티켓이 삭제되었습니다", null));
}
// ---- Approval ----
@Operation(summary = "결재 대기 티켓 조회", description = "내가 승인자/부승인자로 지정된 PENDING_APPROVAL 티켓 목록")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@GetMapping("/pending")
public ResponseEntity<ApiResponse<List<IssueTicket>>> getPendingApprovals(@CurrentUser Employee currentUser) {
return ResponseEntity.ok(ApiResponse.ok(ticketService.getPendingApprovals(currentUser.getEmpId())));
}
@Operation(summary = "티켓 승인", description = "PENDING_APPROVAL 상태의 티켓을 승인합니다")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@PostMapping("/{ticketId}/approve")
public ResponseEntity<ApiResponse<IssueTicket>> approveTicket(
@PathVariable Long ticketId, @CurrentUser Employee currentUser) {
return ResponseEntity.ok(ApiResponse.ok("승인되었습니다", ticketService.approveTicket(ticketId, currentUser.getEmpId())));
}
@Operation(summary = "티켓 반려", description = "PENDING_APPROVAL 상태의 티켓을 반려합니다")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@PostMapping("/{ticketId}/reject")
public ResponseEntity<ApiResponse<IssueTicket>> rejectTicket(
@PathVariable Long ticketId, @RequestBody Map<String, String> body, @CurrentUser Employee currentUser) {
return ResponseEntity.ok(ApiResponse.ok("반려되었습니다", ticketService.rejectTicket(ticketId, currentUser.getEmpId(), body.get("reason"))));
}
@Operation(summary = "댓글 목록 조회", description = "티켓의 댓글 목록을 조회합니다")
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
@GetMapping("/{ticketId}/comments")
@@ -13,6 +13,8 @@ public record CreateTicketRequest(
@NotBlank String title,
@NotBlank String description,
@Nullable String attachmentUrl,
@Nullable String attachmentName
@Nullable String attachmentName,
@Nullable Long approverId,
@Nullable Long backupApproverId
) {
}
@@ -22,6 +22,10 @@ public record IssueTicket(
boolean slaWarnFlag,
LocalDateTime createdAt,
String attachmentUrl,
String attachmentName
String attachmentName,
Long approverId,
Long backupApproverId,
Long approvedBy,
LocalDateTime approvedAt
) {
}
@@ -77,6 +77,12 @@ public class Employee extends BaseEntity {
@Column(name = "school_name", length = 200)
private String schoolName;
@Column(name = "manager_id")
private Long managerId;
@Column(name = "backup_manager_id")
private Long backupManagerId;
@Column(name = "position", length = 50)
private String position;
@@ -72,6 +72,18 @@ public class Ticket extends BaseEntity {
@Column(name = "attachment_name", length = 255)
private String attachmentName;
@Column(name = "approver_id")
private Long approverId;
@Column(name = "backup_approver_id")
private Long backupApproverId;
@Column(name = "approved_by")
private Long approvedBy;
@Column(name = "approved_at")
private LocalDateTime approvedAt;
@Column(name = "sla_warn_flag", nullable = false, length = 1)
private char slaWarnFlag = 'N';
}
@@ -5,5 +5,8 @@ public enum TicketStatus {
ASSIGNED,
IN_PROGRESS,
RESOLVED,
CLOSED
CLOSED,
PENDING_APPROVAL,
APPROVED,
REJECTED
}
@@ -26,7 +26,8 @@ public enum ErrorCode {
CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "COD_ERR_001", "공통코드를 찾을 수 없습니다"),
DUPLICATE_GROUP_CODE(HttpStatus.CONFLICT, "COD_ERR_002", "이미 존재하는 그룹 코드입니다"),
GROUP_HAS_CODES(HttpStatus.BAD_REQUEST, "COD_ERR_003", "그룹에 속한 코드가 있어 삭제할 수 없습니다"),
PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLN_ERR_001", "주간 계획을 찾을 수 없습니다");
PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLN_ERR_001", "주간 계획을 찾을 수 없습니다"),
INVALID_STATUS(HttpStatus.BAD_REQUEST, "TCK_ERR_004", "처리할 수 없는 상태입니다");
private final HttpStatus httpStatus;
private final String errorCode;
@@ -2,6 +2,7 @@ package com.srsystem.repository;
import com.srsystem.entity.Employee;
import com.srsystem.entity.enums.EmpStatus;
import com.srsystem.entity.enums.RoleCode;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -19,4 +20,5 @@ public interface EmployeeRepository extends JpaRepository<Employee, Long> {
Optional<Employee> findByEmail(String email);
List<Employee> findByNameContainingIgnoreCase(String name);
Page<Employee> findByEmpStatus(EmpStatus status, Pageable pageable);
List<Employee> findByRoleCodeIn(List<RoleCode> roleCodes);
}
@@ -5,6 +5,8 @@ import com.srsystem.entity.enums.TicketStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@@ -20,4 +22,6 @@ public interface TicketRepository extends JpaRepository<Ticket, Long> {
List<Ticket> findByRequester_EmpIdAndStatusAndResolvedAtBetween(Long requesterId, TicketStatus status, LocalDateTime start, LocalDateTime end);
List<Ticket> findByRequester_EmpIdAndDueDateBetween(Long requesterId, LocalDateTime start, LocalDateTime end);
List<Ticket> findByDueDateBetween(LocalDateTime start, LocalDateTime end);
@Query("SELECT t FROM Ticket t WHERE t.status IN :statuses AND (t.approverId = :approverId OR t.backupApproverId = :approverId)")
List<Ticket> findByStatusInAndApproverIdOrBackupApproverId(@Param("statuses") List<TicketStatus> statuses, @Param("approverId") Long approverId);
}
@@ -65,6 +65,11 @@ public class EmployeeService {
return results;
}
public List<EmployeeSearchResult> getApprovers() {
return employeeRepository.findByRoleCodeIn(List.of(RoleCode.MANAGER, RoleCode.ADMIN))
.stream().map(this::toSearchResult).toList();
}
private EmployeeSearchResult toSearchResult(Employee e) {
return new EmployeeSearchResult(
e.getEmpId(),
@@ -107,6 +112,8 @@ public class EmployeeService {
employee.setProfileImage(updateRequest.getProfileImage());
employee.setSchoolName(updateRequest.getSchoolName());
employee.setPosition(updateRequest.getPosition());
employee.setManagerId(updateRequest.getManagerId());
employee.setBackupManagerId(updateRequest.getBackupManagerId());
return employee;
}
@@ -59,13 +59,20 @@ public class TicketService {
Employee requester = employeeRepository.findById(requesterId)
.orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND));
boolean needsApproval = switch (request.category()) {
case VACATION, EXPENSE, EQUIPMENT_REQUEST, GENERAL -> true;
default -> false;
};
TicketStatus initialStatus = needsApproval ? TicketStatus.PENDING_APPROVAL : TicketStatus.OPEN;
Ticket.TicketBuilder builder = Ticket.builder()
.requester(requester)
.category(request.category())
.priority(request.priority())
.title(request.title())
.description(request.description())
.status(TicketStatus.OPEN)
.status(initialStatus)
.slaWarnFlag('N')
.attachmentUrl(request.attachmentUrl())
.attachmentName(request.attachmentName());
@@ -76,6 +83,15 @@ public class TicketService {
builder.project(project);
}
if (needsApproval) {
Long approverId = request.approverId() != null ? request.approverId() : requester.getManagerId();
builder.approverId(approverId);
Long backupId = request.backupApproverId() != null ? request.backupApproverId() : requester.getBackupManagerId();
if (backupId != null && !backupId.equals(approverId)) {
builder.backupApproverId(backupId);
}
}
Ticket saved = ticketRepository.save(builder.build());
return toIssueTicket(saved);
}
@@ -185,6 +201,39 @@ public class TicketService {
ticketRepository.delete(ticket);
}
public List<IssueTicket> getPendingApprovals(Long approverId) {
List<TicketStatus> statuses = List.of(TicketStatus.PENDING_APPROVAL);
List<Ticket> tickets = ticketRepository.findByStatusInAndApproverIdOrBackupApproverId(statuses, approverId);
return tickets.stream().map(this::toIssueTicket).toList();
}
@Transactional
public IssueTicket approveTicket(Long ticketId, Long approverId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND));
if (ticket.getStatus() != TicketStatus.PENDING_APPROVAL) {
throw new BusinessException(ErrorCode.INVALID_STATUS);
}
ticket.setStatus(TicketStatus.APPROVED);
ticket.setApprovedBy(approverId);
ticket.setApprovedAt(LocalDateTime.now());
return toIssueTicket(ticketRepository.save(ticket));
}
@Transactional
public IssueTicket rejectTicket(Long ticketId, Long approverId, String reason) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND));
if (ticket.getStatus() != TicketStatus.PENDING_APPROVAL) {
throw new BusinessException(ErrorCode.INVALID_STATUS);
}
ticket.setStatus(TicketStatus.REJECTED);
ticket.setResolutionNote(reason);
ticket.setApprovedBy(approverId);
ticket.setApprovedAt(LocalDateTime.now());
return toIssueTicket(ticketRepository.save(ticket));
}
private IssueTicket toIssueTicket(Ticket ticket) {
return new IssueTicket(
ticket.getTicketId(),
@@ -202,7 +251,11 @@ public class TicketService {
ticket.getSlaWarnFlag() == 'Y',
ticket.getCreatedAt(),
ticket.getAttachmentUrl(),
ticket.getAttachmentName()
ticket.getAttachmentName(),
ticket.getApproverId(),
ticket.getBackupApproverId(),
ticket.getApprovedBy(),
ticket.getApprovedAt()
);
}
}
@@ -0,0 +1,11 @@
ALTER TABLE tb_employee ADD COLUMN manager_id BIGINT REFERENCES tb_employee(id);
ALTER TABLE tb_employee ADD COLUMN backup_manager_id BIGINT REFERENCES tb_employee(id);
ALTER TABLE tb_ticket ADD COLUMN approver_id BIGINT REFERENCES tb_employee(id);
ALTER TABLE tb_ticket ADD COLUMN backup_approver_id BIGINT REFERENCES tb_employee(id);
ALTER TABLE tb_ticket ADD COLUMN approved_by BIGINT REFERENCES tb_employee(id);
ALTER TABLE tb_ticket ADD COLUMN approved_at TIMESTAMP;
ALTER TABLE tb_ticket DROP CONSTRAINT IF EXISTS tb_ticket_status_check;
ALTER TABLE tb_ticket ADD CONSTRAINT tb_ticket_status_check
CHECK (status IN ('OPEN','ASSIGNED','IN_PROGRESS','RESOLVED','CLOSED','PENDING_APPROVAL','APPROVED','REJECTED'));