feat: 기본(디폴트) 분류 — 관리자 설정 + 사용자 불러오기
- default_category 테이블(전체 공용) + 도메인/매퍼/서비스 - 관리자 API /api/admin/default-categories (CRUD·reorder, ADMIN 보호) - 사용자 /account/categories/import: 기존 내역 기반 → 기본분류 복사로 변경 (대/소분류 계층 유지, 같은 이름은 건너뜀; CategoryMapper.findByMemberTypeName 추가) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5f21cc4474
commit
63b3ffea24
@@ -1,21 +1,25 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.AccountCategory;
|
||||
import com.sb.web.account.domain.DefaultCategory;
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.CategoryResponse;
|
||||
import com.sb.web.account.mapper.AccountEntryMapper;
|
||||
import com.sb.web.account.mapper.BudgetMapper;
|
||||
import com.sb.web.account.mapper.CategoryMapper;
|
||||
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||
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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기존 분류 불러오기.
|
||||
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -24,6 +28,7 @@ public class CategoryService {
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final AccountEntryMapper entryMapper;
|
||||
private final BudgetMapper budgetMapper;
|
||||
private final DefaultCategoryMapper defaultCategoryMapper;
|
||||
|
||||
public List<CategoryResponse> list(Long memberId) {
|
||||
return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList();
|
||||
@@ -114,18 +119,42 @@ public class CategoryService {
|
||||
categoryMapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/** 기존 내역에 쓰인 분류명을 분류 목록으로 가져오기 (중복 무시) */
|
||||
/** 관리자가 정의한 기본(디폴트) 분류를 내 분류로 복사 (대/소분류 계층 유지, 같은 이름은 건너뜀) */
|
||||
@Transactional
|
||||
public List<CategoryResponse> importFromEntries(Long memberId) {
|
||||
for (String type : new String[]{"INCOME", "EXPENSE"}) {
|
||||
for (String name : entryMapper.findDistinctCategories(memberId, type)) {
|
||||
categoryMapper.insertIgnore(AccountCategory.builder()
|
||||
.memberId(memberId).type(type).name(name).sortOrder(0).build());
|
||||
public List<CategoryResponse> importDefaults(Long memberId) {
|
||||
List<DefaultCategory> defaults = defaultCategoryMapper.findAll();
|
||||
// 1) 대분류 먼저 생성/확보 → default.id → 내 분류.id 매핑
|
||||
Map<Long, Long> majorMap = new HashMap<>();
|
||||
for (DefaultCategory d : defaults) {
|
||||
if (d.getParentId() != null) {
|
||||
continue;
|
||||
}
|
||||
majorMap.put(d.getId(), ensureCategory(memberId, d.getType(), d.getName(), null));
|
||||
}
|
||||
// 2) 소분류 생성 (매핑된 대분류 아래; 부모 매핑이 없으면 대분류로 취급)
|
||||
for (DefaultCategory d : defaults) {
|
||||
if (d.getParentId() == null) {
|
||||
continue;
|
||||
}
|
||||
ensureCategory(memberId, d.getType(), d.getName(), majorMap.get(d.getParentId()));
|
||||
}
|
||||
return list(memberId);
|
||||
}
|
||||
|
||||
/** 같은 이름 분류가 있으면 그 id, 없으면 생성해서 id 반환 */
|
||||
private Long ensureCategory(Long memberId, String type, String name, Long parentId) {
|
||||
AccountCategory existing = categoryMapper.findByMemberTypeName(memberId, type, name);
|
||||
if (existing != null) {
|
||||
return existing.getId();
|
||||
}
|
||||
Integer max = categoryMapper.maxSortOrder(memberId, type);
|
||||
AccountCategory c = AccountCategory.builder()
|
||||
.memberId(memberId).type(type).name(name).parentId(parentId)
|
||||
.sortOrder(max == null ? 0 : max + 1).build();
|
||||
categoryMapper.insert(c);
|
||||
return c.getId();
|
||||
}
|
||||
|
||||
private AccountCategory mustFind(Long id, Long memberId) {
|
||||
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
|
||||
if (c == null) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import com.sb.web.account.domain.DefaultCategory;
|
||||
import com.sb.web.account.dto.CategoryReorderRequest;
|
||||
import com.sb.web.account.dto.CategoryRequest;
|
||||
import com.sb.web.account.dto.CategoryResponse;
|
||||
import com.sb.web.account.mapper.DefaultCategoryMapper;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 기본(디폴트) 분류 서비스 — 전체 공용 템플릿(관리자 전용 CRUD). 2단계(대/소분류) 제한.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultCategoryService {
|
||||
|
||||
private final DefaultCategoryMapper mapper;
|
||||
|
||||
public List<CategoryResponse> list() {
|
||||
return mapper.findAll().stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CategoryResponse create(CategoryRequest req) {
|
||||
String name = req.getName().trim();
|
||||
if (mapper.countByName(req.getType(), name, null) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||
}
|
||||
validateParent(req.getParentId(), req.getType());
|
||||
Integer max = mapper.maxSortOrder(req.getType());
|
||||
DefaultCategory c = DefaultCategory.builder()
|
||||
.type(req.getType()).name(name).parentId(req.getParentId())
|
||||
.sortOrder(max == null ? 0 : max + 1).build();
|
||||
mapper.insert(c);
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CategoryResponse update(Long id, CategoryRequest req) {
|
||||
DefaultCategory c = mustFind(id);
|
||||
String newName = req.getName().trim();
|
||||
if (mapper.countByName(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, "자기 자신을 대분류로 지정할 수 없습니다.");
|
||||
}
|
||||
if (mapper.countChildren(id) > 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다.");
|
||||
}
|
||||
}
|
||||
validateParent(newParent, c.getType());
|
||||
c.setName(newName);
|
||||
c.setParentId(newParent);
|
||||
mapper.update(c);
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
mustFind(id);
|
||||
if (mapper.countChildren(id) > 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
|
||||
}
|
||||
mapper.delete(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<CategoryResponse> reorder(CategoryReorderRequest req) {
|
||||
int i = 0;
|
||||
for (Long id : req.getIds()) {
|
||||
DefaultCategory c = mapper.findById(id);
|
||||
if (c != null && c.getType().equals(req.getType())) {
|
||||
mapper.updateSortOrder(id, i++);
|
||||
}
|
||||
}
|
||||
return list();
|
||||
}
|
||||
|
||||
private void validateParent(Long parentId, String type) {
|
||||
if (parentId == null) {
|
||||
return;
|
||||
}
|
||||
DefaultCategory parent = mapper.findById(parentId);
|
||||
if (parent == null || !parent.getType().equals(type)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다.");
|
||||
}
|
||||
if (parent.getParentId() != null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계).");
|
||||
}
|
||||
}
|
||||
|
||||
private DefaultCategory mustFind(Long id) {
|
||||
DefaultCategory c = mapper.findById(id);
|
||||
if (c == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "기본 분류를 찾을 수 없습니다.");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private CategoryResponse toResponse(DefaultCategory c) {
|
||||
return CategoryResponse.builder()
|
||||
.id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user