diff --git a/src/main/java/com/sb/web/account/domain/AccountCategory.java b/src/main/java/com/sb/web/account/domain/AccountCategory.java index f124e53..33d8649 100644 --- a/src/main/java/com/sb/web/account/domain/AccountCategory.java +++ b/src/main/java/com/sb/web/account/domain/AccountCategory.java @@ -21,6 +21,7 @@ public class AccountCategory implements Serializable { private Long memberId; private String type; // INCOME / EXPENSE private String name; + private Long parentId; // 대분류 id (NULL=대분류, 값=소분류) private Integer sortOrder; private LocalDateTime createdAt; } diff --git a/src/main/java/com/sb/web/account/dto/CategoryRequest.java b/src/main/java/com/sb/web/account/dto/CategoryRequest.java index 21bb17d..1e83aba 100644 --- a/src/main/java/com/sb/web/account/dto/CategoryRequest.java +++ b/src/main/java/com/sb/web/account/dto/CategoryRequest.java @@ -18,4 +18,7 @@ public class CategoryRequest { @NotBlank(message = "분류 이름을 입력하세요.") @Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.") private String name; + + /** 대분류 id. null=대분류로 생성, 값=해당 대분류의 소분류로 생성/이동 */ + private Long parentId; } diff --git a/src/main/java/com/sb/web/account/dto/CategoryResponse.java b/src/main/java/com/sb/web/account/dto/CategoryResponse.java index 73eb086..980f534 100644 --- a/src/main/java/com/sb/web/account/dto/CategoryResponse.java +++ b/src/main/java/com/sb/web/account/dto/CategoryResponse.java @@ -14,8 +14,11 @@ public class CategoryResponse { private Long id; private String type; private String name; + private Long parentId; // 대분류 id (null=대분류) public static CategoryResponse from(AccountCategory c) { - return CategoryResponse.builder().id(c.getId()).type(c.getType()).name(c.getName()).build(); + return CategoryResponse.builder() + .id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId()) + .build(); } } diff --git a/src/main/java/com/sb/web/account/mapper/CategoryMapper.java b/src/main/java/com/sb/web/account/mapper/CategoryMapper.java index 3236e92..c15e2be 100644 --- a/src/main/java/com/sb/web/account/mapper/CategoryMapper.java +++ b/src/main/java/com/sb/web/account/mapper/CategoryMapper.java @@ -16,6 +16,9 @@ public interface CategoryMapper { AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + /** 해당 대분류(parent_id)에 속한 소분류 개수 */ + int countChildren(@Param("memberId") Long memberId, @Param("parentId") Long parentId); + int countByName(@Param("memberId") Long memberId, @Param("type") String type, @Param("name") String name, diff --git a/src/main/java/com/sb/web/account/service/CategoryService.java b/src/main/java/com/sb/web/account/service/CategoryService.java index c311137..69e53a9 100644 --- a/src/main/java/com/sb/web/account/service/CategoryService.java +++ b/src/main/java/com/sb/web/account/service/CategoryService.java @@ -35,14 +35,30 @@ public class CategoryService { if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) { throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다."); } + validateParent(req.getParentId(), req.getType(), memberId); Integer max = categoryMapper.maxSortOrder(memberId, req.getType()); int order = (max == null ? 0 : max + 1); AccountCategory c = AccountCategory.builder() - .memberId(memberId).type(req.getType()).name(name).sortOrder(order).build(); + .memberId(memberId).type(req.getType()).name(name) + .parentId(req.getParentId()).sortOrder(order).build(); categoryMapper.insert(c); return CategoryResponse.from(c); } + /** 대분류 지정 검증: null=대분류 / 값=대분류여야(2단계 제한·동일타입·본인) */ + private void validateParent(Long parentId, String type, Long memberId) { + if (parentId == null) { + return; + } + AccountCategory parent = categoryMapper.findByIdAndMember(parentId, memberId); + if (parent == null || !parent.getType().equals(type)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다."); + } + if (parent.getParentId() != null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계)."); + } + } + /** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */ @Transactional public List reorder(Long memberId, String type, List ids) { @@ -56,7 +72,8 @@ public class CategoryService { return list(memberId); } - /** 이름만 변경 가능. 변경 시 기존 내역·예산의 분류명도 함께 갱신 */ + /** 이름 변경 + 대분류 이동. 이름 변경 시 기존 내역·예산의 분류명도 함께 갱신. + * 주의: parentId 는 항상 현재값(또는 새 대분류)을 보내야 한다(미전송 시 대분류로 풀림). */ @Transactional public CategoryResponse update(Long id, CategoryRequest req, Long memberId) { AccountCategory c = mustFind(id, memberId); @@ -64,8 +81,20 @@ public class CategoryService { if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) { throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다."); } + Long newParent = req.getParentId(); + if (newParent != null) { + if (newParent.equals(id)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다."); + } + // 소분류를 가진 대분류는 소분류로 바꿀 수 없음(3단계 방지) + if (categoryMapper.countChildren(memberId, id) > 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다."); + } + } + validateParent(newParent, c.getType(), memberId); String oldName = c.getName(); c.setName(newName); + c.setParentId(newParent); categoryMapper.update(c); if (!oldName.equals(newName)) { entryMapper.renameCategory(memberId, c.getType(), oldName, newName); @@ -79,6 +108,9 @@ public class CategoryService { @Transactional public void delete(Long id, Long memberId) { mustFind(id, memberId); + if (categoryMapper.countChildren(memberId, id) > 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요."); + } categoryMapper.delete(id, memberId); } diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 23f86d9..60e85c5 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -104,12 +104,16 @@ CREATE TABLE IF NOT EXISTS account_category ( member_id BIGINT NOT NULL COMMENT '소유자 member.id', type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE', name VARCHAR(50) NOT NULL, + parent_id BIGINT NULL COMMENT '대분류 id(account_category.id). NULL=대분류, 값=소분류', sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_category_member_type_name (member_id, type, name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- 대/소분류 계층 추가(기존 DB 마이그레이션) — 소분류 이름은 그대로, parent_id 만 부여 +ALTER TABLE account_category ADD COLUMN IF NOT EXISTS parent_id BIGINT NULL COMMENT '대분류 id. NULL=대분류, 값=소분류'; + -- 예산 (사용자별, 카테고리별) -- fixed=1(고정): base_unit/base_amount 로 일수기준 환산 / fixed=0(비고정): daily/weekly/monthly/yearly 직접 CREATE TABLE IF NOT EXISTS budget ( diff --git a/src/main/resources/mapper/CategoryMapper.xml b/src/main/resources/mapper/CategoryMapper.xml index c64d4bf..c242bcf 100644 --- a/src/main/resources/mapper/CategoryMapper.xml +++ b/src/main/resources/mapper/CategoryMapper.xml @@ -8,23 +8,29 @@ + + +