feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)
Phase 0: wbx-spring-core 라이브러리 전환 - java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화 - 루트 settings.gradle + build.gradle (멀티모듈) Phase 1: wtm-api 모듈 생성 - 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트 - Flyway V100~V107 MySQL 마이그레이션 - TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser Phase 2: wtm-frontend (Vue 3 + PrimeVue 4) - 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트 - 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar) - BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트 - JWT 인터셉터, 역할 기반 메뉴 필터링 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
@@ -0,0 +1,185 @@
|
||||
# 06. 리포트 모듈
|
||||
|
||||
## PH1-1차 리포트 (2종)
|
||||
|
||||
### 1. 프로젝트별 시수 분석 (No.82)
|
||||
|
||||
```
|
||||
GET /api/reports/project-hours
|
||||
?projectId=1
|
||||
&from=2025-04-01
|
||||
&to=2025-05-31
|
||||
&groupBy=month // month, week, discipline
|
||||
&format=json // json, excel
|
||||
```
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ReportService {
|
||||
|
||||
private final TimesheetEntryRepository entryRepository;
|
||||
|
||||
public ProjectHoursReport getProjectHoursReport(ProjectHoursFilter filter) {
|
||||
// QueryDSL 동적 쿼리
|
||||
QTimesheetEntry e = QTimesheetEntry.timesheetEntry;
|
||||
QTimesheet ts = QTimesheet.timesheet;
|
||||
QUser u = QUser.user;
|
||||
|
||||
var query = queryFactory
|
||||
.select(Projections.constructor(ProjectHoursRow.class,
|
||||
e.epcProject.projectCode,
|
||||
e.epcProject.name,
|
||||
u.discipline,
|
||||
e.entryDate,
|
||||
e.hours.sum()
|
||||
))
|
||||
.from(e)
|
||||
.join(e.timesheet, ts)
|
||||
.join(ts.user, u)
|
||||
.where(
|
||||
ts.status.eq(TimesheetStatus.APPROVED),
|
||||
epcProjectIdEq(filter.getProjectId()),
|
||||
entryDateBetween(filter.getFrom(), filter.getTo())
|
||||
)
|
||||
.groupBy(e.epcProject.projectCode, e.epcProject.name,
|
||||
u.discipline, e.entryDate);
|
||||
|
||||
List<ProjectHoursRow> rows = query.fetch();
|
||||
|
||||
return ProjectHoursReport.builder()
|
||||
.filter(filter)
|
||||
.rows(rows)
|
||||
.totalHours(rows.stream()
|
||||
.map(ProjectHoursRow::getHours)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. WBS Level별 시수 분석 (No.83)
|
||||
|
||||
```
|
||||
GET /api/reports/wbs-hours
|
||||
?projectId=1
|
||||
&wbsLevel=3 // 1~5
|
||||
&from=2025-04-01
|
||||
&to=2025-05-31
|
||||
&format=json
|
||||
```
|
||||
|
||||
```java
|
||||
public WbsHoursReport getWbsHoursReport(WbsHoursFilter filter) {
|
||||
var rows = queryFactory
|
||||
.select(Projections.constructor(WbsHoursRow.class,
|
||||
cw.wbsCode,
|
||||
cw.name,
|
||||
cw.level,
|
||||
cw.discipline,
|
||||
e.hours.sum(),
|
||||
ts.user.countDistinct()
|
||||
))
|
||||
.from(e)
|
||||
.join(e.timesheet, ts)
|
||||
.join(e.canonicalWbs, cw)
|
||||
.where(
|
||||
ts.status.eq(TimesheetStatus.APPROVED),
|
||||
cw.projectId.eq(filter.getProjectId()),
|
||||
cw.level.eq(filter.getWbsLevel()),
|
||||
entryDateBetween(filter.getFrom(), filter.getTo())
|
||||
)
|
||||
.groupBy(cw.wbsCode, cw.name, cw.level, cw.discipline)
|
||||
.orderBy(cw.wbsCode.asc())
|
||||
.fetch();
|
||||
|
||||
return WbsHoursReport.builder()
|
||||
.filter(filter)
|
||||
.rows(rows)
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
## PH1-2차 리포트 (2종)
|
||||
|
||||
### 3. Phase별 시수 비율 (No.85)
|
||||
|
||||
```
|
||||
GET /api/reports/phase-ratio?projectId=1&from=...&to=...
|
||||
```
|
||||
|
||||
### 4. Non-Project 시수 비율 (No.86)
|
||||
|
||||
```
|
||||
GET /api/reports/np-ratio?department=Engineering&from=...&to=...
|
||||
```
|
||||
|
||||
## Excel Export
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ReportExcelExporter {
|
||||
|
||||
public byte[] exportProjectHours(ProjectHoursReport report) {
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("프로젝트별 시수");
|
||||
|
||||
// 헤더
|
||||
CellStyle headerStyle = createHeaderStyle(wb);
|
||||
Row header = sheet.createRow(0);
|
||||
String[] headers = {"프로젝트코드", "프로젝트명", "Discipline", "날짜", "시수(h)"};
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
Cell cell = header.createCell(i);
|
||||
cell.setCellValue(headers[i]);
|
||||
cell.setCellStyle(headerStyle);
|
||||
}
|
||||
|
||||
// 데이터
|
||||
int rowIdx = 1;
|
||||
for (ProjectHoursRow row : report.getRows()) {
|
||||
Row r = sheet.createRow(rowIdx++);
|
||||
r.createCell(0).setCellValue(row.getProjectCode());
|
||||
r.createCell(1).setCellValue(row.getProjectName());
|
||||
r.createCell(2).setCellValue(row.getDiscipline());
|
||||
r.createCell(3).setCellValue(row.getDate().toString());
|
||||
r.createCell(4).setCellValue(row.getHours().doubleValue());
|
||||
}
|
||||
|
||||
// 합계 행
|
||||
Row totalRow = sheet.createRow(rowIdx);
|
||||
totalRow.createCell(3).setCellValue("합계");
|
||||
totalRow.createCell(4).setCellValue(report.getTotalHours().doubleValue());
|
||||
|
||||
// Auto-size
|
||||
for (int i = 0; i < headers.length; i++) sheet.autoSizeColumn(i);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
wb.write(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## REST API (리포트)
|
||||
|
||||
```
|
||||
# PH1-1차
|
||||
GET /api/reports/project-hours 프로젝트별 시수 분석
|
||||
GET /api/reports/project-hours/export Excel 다운로드
|
||||
GET /api/reports/wbs-hours WBS Level별 시수 분석
|
||||
GET /api/reports/wbs-hours/export Excel 다운로드
|
||||
|
||||
# PH1-2차
|
||||
GET /api/reports/phase-ratio Phase별 시수 비율
|
||||
GET /api/reports/np-ratio Non-Project 시수 비율
|
||||
GET /api/reports/wbs-version-history WBS 버전 이력 조회
|
||||
|
||||
# 공통 파라미터
|
||||
# projectId - 프로젝트 ID (필수/선택)
|
||||
# from, to - 기간 (ISO 8601)
|
||||
# groupBy - 집계 기준 (month, week, discipline)
|
||||
# format - 응답 형식 (json, excel)
|
||||
```
|
||||
새 Issue에서 참조
사용자 차단