feat: approval workflow with approver/backupApprover
This commit is contained in:
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user