feat: 월별 예산 + 전월/다음달 복사

- budget 테이블에 year/month 추가, (member,category)→(member,category,year,month) 유니크 교체
  기존 상시 예산은 현재 월로 1회 이관
- 예산 조회/생성/현황/기간차트를 월별로(findByMember에 year/month)
- copyMonth: 다른 월 예산을 대상 월로 복사(같은 분류 덮어씀) + POST /budgets/copy
- 백업 복구 시 예산은 현재 월로 등록

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-30 22:35:32 +09:00
co-authored by Claude Opus 4.8
parent 10f51976d9
commit 24e235404b
7 changed files with 88 additions and 19 deletions
@@ -156,11 +156,12 @@ public class BackupService {
}
}
// 7) 예산
// 7) 예산 — 복구 시 현재 월 예산으로 등록(백업엔 월 정보 없음)
if (req.getBudgets() != null) {
java.time.LocalDate today = java.time.LocalDate.now();
for (BudgetRequest b : req.getBudgets()) {
if (b == null || b.getCategory() == null) continue;
budgetService.create(b, memberId);
budgetService.create(b, memberId, today.getYear(), today.getMonthValue());
}
}
@@ -31,36 +31,46 @@ public class BudgetService {
private final BudgetMapper budgetMapper;
private final AccountEntryMapper entryMapper;
public List<BudgetResponse> list(Long memberId) {
return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList();
public List<BudgetResponse> list(Long memberId, int year, int month) {
return budgetMapper.findByMember(memberId, year, month).stream().map(BudgetResponse::from).toList();
}
@Transactional
public BudgetResponse create(BudgetRequest req, Long memberId) {
public BudgetResponse create(BudgetRequest req, Long memberId, int year, int month) {
String category = req.getCategory().trim();
if (budgetMapper.countByCategory(memberId, category, null) > 0) {
if (budgetMapper.countByCategory(memberId, category, null, year, month) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
}
Budget budget = toBudget(req, category);
budget.setMemberId(memberId);
budget.setYear(year);
budget.setMonth(month);
budgetMapper.insert(budget);
return BudgetResponse.from(budget);
}
@Transactional
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
mustFind(id, memberId);
Budget existing = mustFind(id, memberId);
String category = req.getCategory().trim();
if (budgetMapper.countByCategory(memberId, category, id) > 0) {
if (budgetMapper.countByCategory(memberId, category, id, existing.getYear(), existing.getMonth()) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
}
Budget budget = toBudget(req, category);
budget.setId(id);
budget.setMemberId(memberId);
budget.setYear(existing.getYear());
budget.setMonth(existing.getMonth());
budgetMapper.update(budget);
return BudgetResponse.from(budget);
}
/** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류 덮어씀). 복사된 건수 반환 */
@Transactional
public int copy(Long memberId, int fromYear, int fromMonth, int toYear, int toMonth) {
return budgetMapper.copyMonth(memberId, fromYear, fromMonth, toYear, toMonth);
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
@@ -79,7 +89,7 @@ public class BudgetService {
}
List<BudgetStatus> result = new ArrayList<>();
for (Budget b : budgetMapper.findByMember(memberId)) {
for (Budget b : budgetMapper.findByMember(memberId, year, month)) {
long monthlyBudget = monthlyBudget(b, daysInMonth);
long spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
result.add(BudgetStatus.builder()
@@ -103,7 +113,8 @@ public class BudgetService {
int y = year != null ? year : Year.now().getValue();
int m = month != null ? month : 1;
List<Budget> budgets = budgetMapper.findByMember(memberId);
// 기간 차트는 선택 월의 예산을 기준으로 각 버킷에 적용(월별 예산 도입 전 동작과 동일)
List<Budget> budgets = budgetMapper.findByMember(memberId, y, m);
Map<String, Long> expense = new HashMap<>();
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),