feat(budget): 1회성 예산 API 추가
- BudgetOnetime 도메인/DTO/Mapper/Service 신규 - BudgetController: GET/POST/PUT/DELETE /api/account/budgets/onetime 엔드포인트 추가 - account.sql: budget_onetime 테이블 CREATE TABLE IF NOT EXISTS 추가 - WalletRequest: MINUS 유형 허용 추가 - CategoryService: importDefaults, validateParent, ensureCategory 추가 ※ 최초 배포 시 서버 .env에 SQL_INIT_MODE=always 추가 후 재시작 필요 (budget_onetime 테이블 생성) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
24858f23e8
commit
a0509099f7
@@ -1,11 +1,14 @@
|
||||
package com.sb.web.account.controller;
|
||||
|
||||
import com.sb.web.account.dto.BudgetOnetimeRequest;
|
||||
import com.sb.web.account.dto.BudgetOnetimeResponse;
|
||||
import com.sb.web.account.dto.BudgetPeriodStat;
|
||||
import com.sb.web.account.dto.BudgetRequest;
|
||||
import com.sb.web.account.dto.BudgetResponse;
|
||||
import com.sb.web.account.dto.BudgetStatus;
|
||||
import com.sb.web.account.dto.ExpectedIncomeRequest;
|
||||
import com.sb.web.account.service.AccountSettingService;
|
||||
import com.sb.web.account.service.BudgetOnetimeService;
|
||||
import com.sb.web.account.service.BudgetService;
|
||||
import com.sb.web.auth.dto.SessionUser;
|
||||
import com.sb.web.auth.web.AuthInterceptor;
|
||||
@@ -26,6 +29,7 @@ import java.util.List;
|
||||
public class BudgetController {
|
||||
|
||||
private final BudgetService budgetService;
|
||||
private final BudgetOnetimeService onetimeService;
|
||||
private final AccountSettingService settingService;
|
||||
|
||||
/** 월 예상 수입 조회 (연·월별) */
|
||||
@@ -110,4 +114,35 @@ public class BudgetController {
|
||||
budgetService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ===== 1회성 예산 =====
|
||||
|
||||
@GetMapping("/onetime")
|
||||
public List<BudgetOnetimeResponse> onetimeList(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return onetimeService.list(current.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/onetime")
|
||||
public ResponseEntity<BudgetOnetimeResponse> onetimeCreate(
|
||||
@Valid @RequestBody BudgetOnetimeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(onetimeService.create(req, current.getId()));
|
||||
}
|
||||
|
||||
@PutMapping("/onetime/{id}")
|
||||
public BudgetOnetimeResponse onetimeUpdate(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody BudgetOnetimeRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return onetimeService.update(id, req, current.getId());
|
||||
}
|
||||
|
||||
@DeleteMapping("/onetime/{id}")
|
||||
public ResponseEntity<Void> onetimeDelete(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
onetimeService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 1회성 예산 — 특정 기간(start_date~end_date)에 단발 예산을 설정.
|
||||
* category=null 이면 전체 지출 합산, 값 있으면 해당 카테고리만.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BudgetOnetime {
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String title;
|
||||
private String category; // null = 전체
|
||||
private Long amount;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class BudgetOnetimeRequest {
|
||||
|
||||
@NotBlank(message = "제목을 입력하세요.")
|
||||
@Size(max = 100, message = "제목은 100자 이내여야 합니다.")
|
||||
private String title;
|
||||
|
||||
@Size(max = 50, message = "카테고리는 50자 이내여야 합니다.")
|
||||
private String category; // null 또는 빈값 = 전체 지출
|
||||
|
||||
@NotNull(message = "금액을 입력하세요.")
|
||||
@Positive(message = "금액은 0보다 커야 합니다.")
|
||||
private Long amount;
|
||||
|
||||
@NotNull(message = "시작일을 입력하세요.")
|
||||
private LocalDate startDate;
|
||||
|
||||
@NotNull(message = "종료일을 입력하세요.")
|
||||
private LocalDate endDate;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import com.sb.web.account.domain.BudgetOnetime;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class BudgetOnetimeResponse {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String category; // null = 전체
|
||||
private Long amount;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private long spent;
|
||||
private long remaining;
|
||||
private String status; // UPCOMING / ACTIVE / ENDED
|
||||
|
||||
public static BudgetOnetimeResponse from(BudgetOnetime b, long spent) {
|
||||
LocalDate today = LocalDate.now();
|
||||
String status;
|
||||
if (today.isBefore(b.getStartDate())) status = "UPCOMING";
|
||||
else if (today.isAfter(b.getEndDate())) status = "ENDED";
|
||||
else status = "ACTIVE";
|
||||
|
||||
return BudgetOnetimeResponse.builder()
|
||||
.id(b.getId())
|
||||
.title(b.getTitle())
|
||||
.category(b.getCategory())
|
||||
.amount(b.getAmount())
|
||||
.startDate(b.getStartDate())
|
||||
.endDate(b.getEndDate())
|
||||
.spent(spent)
|
||||
.remaining(b.getAmount() - spent)
|
||||
.status(status)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import java.time.LocalDate;
|
||||
public class WalletRequest {
|
||||
|
||||
@NotBlank(message = "유형을 선택하세요.")
|
||||
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.")
|
||||
@Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST|MINUS", message = "유형이 올바르지 않습니다.")
|
||||
private String type;
|
||||
|
||||
@NotBlank(message = "이름을 입력하세요.")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.BudgetOnetime;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface BudgetOnetimeMapper {
|
||||
|
||||
List<BudgetOnetime> findByMember(@Param("memberId") Long memberId);
|
||||
|
||||
BudgetOnetime findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
long sumExpenseInRange(@Param("memberId") Long memberId,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("category") String category);
|
||||
|
||||
void insert(BudgetOnetime b);
|
||||
|
||||
void update(BudgetOnetime b);
|
||||
|
||||
void delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.BudgetOnetime;
|
||||
import com.sb.web.account.dto.BudgetOnetimeRequest;
|
||||
import com.sb.web.account.dto.BudgetOnetimeResponse;
|
||||
import com.sb.web.account.mapper.BudgetOnetimeMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BudgetOnetimeService {
|
||||
|
||||
private final BudgetOnetimeMapper mapper;
|
||||
|
||||
public List<BudgetOnetimeResponse> list(Long memberId) {
|
||||
return mapper.findByMember(memberId).stream()
|
||||
.map(b -> {
|
||||
long spent = mapper.sumExpenseInRange(
|
||||
memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
|
||||
return BudgetOnetimeResponse.from(b, spent);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetOnetimeResponse create(BudgetOnetimeRequest req, Long memberId) {
|
||||
validate(req);
|
||||
BudgetOnetime b = BudgetOnetime.builder()
|
||||
.memberId(memberId)
|
||||
.title(req.getTitle().trim())
|
||||
.category(blankToNull(req.getCategory()))
|
||||
.amount(req.getAmount())
|
||||
.startDate(req.getStartDate())
|
||||
.endDate(req.getEndDate())
|
||||
.build();
|
||||
mapper.insert(b);
|
||||
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
|
||||
return BudgetOnetimeResponse.from(b, spent);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BudgetOnetimeResponse update(Long id, BudgetOnetimeRequest req, Long memberId) {
|
||||
validate(req);
|
||||
BudgetOnetime b = mustFind(id, memberId);
|
||||
b.setTitle(req.getTitle().trim());
|
||||
b.setCategory(blankToNull(req.getCategory()));
|
||||
b.setAmount(req.getAmount());
|
||||
b.setStartDate(req.getStartDate());
|
||||
b.setEndDate(req.getEndDate());
|
||||
mapper.update(b);
|
||||
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
|
||||
return BudgetOnetimeResponse.from(b, spent);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long memberId) {
|
||||
mustFind(id, memberId);
|
||||
mapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
private void validate(BudgetOnetimeRequest req) {
|
||||
if (req.getEndDate().isBefore(req.getStartDate())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "종료일이 시작일보다 빠를 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private BudgetOnetime mustFind(Long id, Long memberId) {
|
||||
BudgetOnetime b = mapper.findByIdAndMember(id, memberId);
|
||||
if (b == null) throw new ApiException(HttpStatus.NOT_FOUND, "1회성 예산을 찾을 수 없습니다.");
|
||||
return b;
|
||||
}
|
||||
|
||||
private String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user