diff --git a/src/main/java/com/sb/web/account/controller/BudgetController.java b/src/main/java/com/sb/web/account/controller/BudgetController.java index 1522d33..401f94d 100644 --- a/src/main/java/com/sb/web/account/controller/BudgetController.java +++ b/src/main/java/com/sb/web/account/controller/BudgetController.java @@ -50,8 +50,22 @@ public class BudgetController { @GetMapping public List list( + @RequestParam int year, + @RequestParam int month, @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - return budgetService.list(current.getId()); + return budgetService.list(current.getId(), year, month); + } + + /** 다른 월의 예산을 이 달로 복사(전월 복사 등). 같은 분류는 덮어씀 */ + @PostMapping("/copy") + public List copy( + @RequestParam int fromYear, + @RequestParam int fromMonth, + @RequestParam int toYear, + @RequestParam int toMonth, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + budgetService.copy(current.getId(), fromYear, fromMonth, toYear, toMonth); + return budgetService.list(current.getId(), toYear, toMonth); } @GetMapping("/status") @@ -74,9 +88,11 @@ public class BudgetController { @PostMapping public ResponseEntity create( + @RequestParam int year, + @RequestParam int month, @Valid @RequestBody BudgetRequest req, @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId())); + return ResponseEntity.status(HttpStatus.CREATED).body(budgetService.create(req, current.getId(), year, month)); } @PutMapping("/{id}") diff --git a/src/main/java/com/sb/web/account/domain/Budget.java b/src/main/java/com/sb/web/account/domain/Budget.java index 9e912d7..6363f23 100644 --- a/src/main/java/com/sb/web/account/domain/Budget.java +++ b/src/main/java/com/sb/web/account/domain/Budget.java @@ -28,6 +28,8 @@ public class Budget implements Serializable { private Long weekly; private Long monthly; private Long yearly; + private Integer year; // 예산 연도(월별 예산) + private Integer month; // 예산 월(1~12) private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/src/main/java/com/sb/web/account/mapper/BudgetMapper.java b/src/main/java/com/sb/web/account/mapper/BudgetMapper.java index 0c147ec..335748e 100644 --- a/src/main/java/com/sb/web/account/mapper/BudgetMapper.java +++ b/src/main/java/com/sb/web/account/mapper/BudgetMapper.java @@ -12,13 +12,24 @@ import java.util.List; @Mapper public interface BudgetMapper { - List findByMember(@Param("memberId") Long memberId); + List findByMember(@Param("memberId") Long memberId, + @Param("year") Integer year, + @Param("month") Integer month); Budget findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); int countByCategory(@Param("memberId") Long memberId, @Param("category") String category, - @Param("excludeId") Long excludeId); + @Param("excludeId") Long excludeId, + @Param("year") Integer year, + @Param("month") Integer month); + + /** fromYear/fromMonth 예산을 toYear/toMonth 로 복사(같은 분류는 덮어씀). 복사된 건수 반환 */ + int copyMonth(@Param("memberId") Long memberId, + @Param("fromYear") Integer fromYear, + @Param("fromMonth") Integer fromMonth, + @Param("toYear") Integer toYear, + @Param("toMonth") Integer toMonth); int insert(Budget budget); diff --git a/src/main/java/com/sb/web/account/service/BackupService.java b/src/main/java/com/sb/web/account/service/BackupService.java index 7bfaf00..94974f8 100644 --- a/src/main/java/com/sb/web/account/service/BackupService.java +++ b/src/main/java/com/sb/web/account/service/BackupService.java @@ -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()); } } diff --git a/src/main/java/com/sb/web/account/service/BudgetService.java b/src/main/java/com/sb/web/account/service/BudgetService.java index 9707f70..8af93c2 100644 --- a/src/main/java/com/sb/web/account/service/BudgetService.java +++ b/src/main/java/com/sb/web/account/service/BudgetService.java @@ -31,36 +31,46 @@ public class BudgetService { private final BudgetMapper budgetMapper; private final AccountEntryMapper entryMapper; - public List list(Long memberId) { - return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList(); + public List 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 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 budgets = budgetMapper.findByMember(memberId); + // 기간 차트는 선택 월의 예산을 기준으로 각 버킷에 적용(월별 예산 도입 전 동작과 동일) + List budgets = budgetMapper.findByMember(memberId, y, m); Map expense = new HashMap<>(); for (Map row : entryMapper.statsByPeriod(memberId, unit, year, month)) { expense.put(String.valueOf(((Number) row.get("bucket")).longValue()), diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 936711e..823290a 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -165,6 +165,15 @@ CREATE TABLE IF NOT EXISTS budget ( UNIQUE KEY uk_budget_member_category (member_id, category) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- 월별 예산: 예산을 연/월별로 분리(전월 복사로 다음 달에 동일 예산 저장 가능). +ALTER TABLE budget ADD COLUMN IF NOT EXISTS `year` INT NULL COMMENT '예산 연도'; +ALTER TABLE budget ADD COLUMN IF NOT EXISTS `month` INT NULL COMMENT '예산 월(1~12)'; +-- 기존 상시 예산을 현재 연/월로 1회 이관 +UPDATE budget SET `year` = YEAR(NOW()), `month` = MONTH(NOW()) WHERE `year` IS NULL OR `month` IS NULL; +-- 분류 유니크를 (member,category) → (member,category,year,month) 로 교체 +ALTER TABLE budget DROP INDEX IF EXISTS uk_budget_member_category; +ALTER TABLE budget ADD UNIQUE KEY IF NOT EXISTS uk_budget_member_cat_ym (member_id, category, `year`, `month`); + -- 가계부 항목 - 태그 (다대다, tag_id 는 account_tag.id 참조) CREATE TABLE IF NOT EXISTS account_entry_tag ( entry_id BIGINT NOT NULL, diff --git a/src/main/resources/mapper/BudgetMapper.xml b/src/main/resources/mapper/BudgetMapper.xml index 1a57727..5a76cc0 100644 --- a/src/main/resources/mapper/BudgetMapper.xml +++ b/src/main/resources/mapper/BudgetMapper.xml @@ -14,15 +14,19 @@ + + id, member_id, category, fixed, base_unit, base_amount, - daily, weekly, monthly, yearly, created_at, updated_at + daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at - + SELECT FROM budget + WHERE member_id = #{memberId} AND `year` = #{year} AND `month` = #{month} + ORDER BY category SELECT COUNT(*) FROM budget WHERE member_id = #{memberId} AND category = #{category} + AND `year` = #{year} AND `month` = #{month} AND id != #{excludeId} INSERT INTO budget (member_id, category, fixed, base_unit, base_amount, - daily, weekly, monthly, yearly, created_at, updated_at) + daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at) VALUES (#{memberId}, #{category}, #{fixed}, #{baseUnit}, #{baseAmount}, - #{daily}, #{weekly}, #{monthly}, #{yearly}, NOW(), NOW()) + #{daily}, #{weekly}, #{monthly}, #{yearly}, #{year}, #{month}, NOW(), NOW()) + + + + + INSERT INTO budget (member_id, category, fixed, base_unit, base_amount, + daily, weekly, monthly, yearly, `year`, `month`, created_at, updated_at) + SELECT member_id, category, fixed, base_unit, base_amount, + daily, weekly, monthly, yearly, #{toYear}, #{toMonth}, NOW(), NOW() + FROM budget + WHERE member_id = #{memberId} AND `year` = #{fromYear} AND `month` = #{fromMonth} + ON DUPLICATE KEY UPDATE + fixed = VALUES(fixed), base_unit = VALUES(base_unit), base_amount = VALUES(base_amount), + daily = VALUES(daily), weekly = VALUES(weekly), monthly = VALUES(monthly), + yearly = VALUES(yearly), updated_at = NOW()