- 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:
@@ -61,10 +61,10 @@ public class CategoryController {
|
||||
return categoryService.reorder(current.getId(), req.getType(), req.getIds());
|
||||
}
|
||||
|
||||
/** 기존 내역의 분류를 목록으로 가져오기 */
|
||||
/** 관리자가 정의한 기본 분류를 내 분류로 불러오기 */
|
||||
@PostMapping("/import")
|
||||
public List<CategoryResponse> importFromEntries(
|
||||
public List<CategoryResponse> importDefaults(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return categoryService.importFromEntries(current.getId());
|
||||
return categoryService.importDefaults(current.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.sb.web.account.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자는 자기 분류로 복사해 사용.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DefaultCategory implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String type; // INCOME / EXPENSE
|
||||
private String name;
|
||||
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -16,6 +16,11 @@ public interface CategoryMapper {
|
||||
|
||||
AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/** 이름으로 조회 (기본분류 불러오기 중복 확인용) */
|
||||
AccountCategory findByMemberTypeName(@Param("memberId") Long memberId,
|
||||
@Param("type") String type,
|
||||
@Param("name") String name);
|
||||
|
||||
/** 해당 대분류(parent_id)에 속한 소분류 개수 */
|
||||
int countChildren(@Param("memberId") Long memberId, @Param("parentId") Long parentId);
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.sb.web.account.mapper;
|
||||
|
||||
import com.sb.web.account.domain.DefaultCategory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 기본(디폴트) 분류 매퍼 — 전체 공용(소유자 격리 없음).
|
||||
*/
|
||||
@Mapper
|
||||
public interface DefaultCategoryMapper {
|
||||
|
||||
List<DefaultCategory> findAll();
|
||||
|
||||
DefaultCategory findById(@Param("id") Long id);
|
||||
|
||||
int countChildren(@Param("parentId") Long parentId);
|
||||
|
||||
int countByName(@Param("type") String type, @Param("name") String name, @Param("excludeId") Long excludeId);
|
||||
|
||||
int insert(DefaultCategory category);
|
||||
|
||||
int update(DefaultCategory category);
|
||||
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int updateSortOrder(@Param("id") Long id, @Param("sortOrder") int sortOrder);
|
||||
|
||||
Integer maxSortOrder(@Param("type") String type);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.sb.web.admin.controller;
|
||||
|
||||
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.service.DefaultCategoryService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 관리자 기본(디폴트) 분류 관리 API. (/api/admin/** — AdminInterceptor 로 ADMIN 권한 필요)
|
||||
* GET /default-categories 기본 분류 목록
|
||||
* POST /default-categories 생성(대/소분류)
|
||||
* PUT /default-categories/{id} 수정(이름·대분류)
|
||||
* DELETE /default-categories/{id} 삭제
|
||||
* PUT /default-categories/reorder 순서 변경
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/default-categories")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminCategoryController {
|
||||
|
||||
private final DefaultCategoryService service;
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryResponse> list() {
|
||||
return service.list();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CategoryResponse> create(@Valid @RequestBody CategoryRequest req) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public CategoryResponse update(@PathVariable Long id, @Valid @RequestBody CategoryRequest req) {
|
||||
return service.update(id, req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/reorder")
|
||||
public List<CategoryResponse> reorder(@RequestBody CategoryReorderRequest req) {
|
||||
return service.reorder(req);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,18 @@ CREATE TABLE IF NOT EXISTS account_category (
|
||||
-- 대/소분류 계층 추가(기존 DB 마이그레이션) — 소분류 이름은 그대로, parent_id 만 부여
|
||||
ALTER TABLE account_category ADD COLUMN IF NOT EXISTS parent_id BIGINT NULL COMMENT '대분류 id. NULL=대분류, 값=소분류';
|
||||
|
||||
-- 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자가 '기본 분류 불러오기'로 자기 분류에 복사.
|
||||
CREATE TABLE IF NOT EXISTS default_category (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE',
|
||||
name VARCHAR(50) NOT NULL,
|
||||
parent_id BIGINT NULL COMMENT '대분류 id(default_category.id). NULL=대분류, 값=소분류',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_default_category_type_name (type, name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 예산 (사용자별, 카테고리별)
|
||||
-- fixed=1(고정): base_unit/base_amount 로 일수기준 환산 / fixed=0(비고정): daily/weekly/monthly/yearly 직접
|
||||
CREATE TABLE IF NOT EXISTS budget (
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
WHERE member_id = #{memberId} AND parent_id = #{parentId}
|
||||
</select>
|
||||
|
||||
<select id="findByMemberTypeName" resultMap="categoryResultMap">
|
||||
SELECT id, member_id, type, name, parent_id, sort_order, created_at
|
||||
FROM account_category
|
||||
WHERE member_id = #{memberId} AND type = #{type} AND name = #{name}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="countByName" resultType="int">
|
||||
SELECT COUNT(*) FROM account_category
|
||||
WHERE member_id = #{memberId} AND type = #{type} AND name = #{name}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.sb.web.account.mapper.DefaultCategoryMapper">
|
||||
|
||||
<resultMap id="defaultCategoryResultMap" type="com.sb.web.account.domain.DefaultCategory">
|
||||
<id property="id" column="id"/>
|
||||
<result property="type" column="type"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="parentId" column="parent_id"/>
|
||||
<result property="sortOrder" column="sort_order"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findAll" resultMap="defaultCategoryResultMap">
|
||||
SELECT id, type, name, parent_id, sort_order, created_at
|
||||
FROM default_category
|
||||
ORDER BY type, sort_order, name
|
||||
</select>
|
||||
|
||||
<select id="findById" resultMap="defaultCategoryResultMap">
|
||||
SELECT id, type, name, parent_id, sort_order, created_at
|
||||
FROM default_category
|
||||
WHERE id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="countChildren" resultType="int">
|
||||
SELECT COUNT(*) FROM default_category WHERE parent_id = #{parentId}
|
||||
</select>
|
||||
|
||||
<select id="countByName" resultType="int">
|
||||
SELECT COUNT(*) FROM default_category
|
||||
WHERE type = #{type} AND name = #{name}
|
||||
<if test="excludeId != null">AND id != #{excludeId}</if>
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="com.sb.web.account.domain.DefaultCategory"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO default_category (type, name, parent_id, sort_order, created_at)
|
||||
VALUES (#{type}, #{name}, #{parentId}, #{sortOrder}, NOW())
|
||||
</insert>
|
||||
|
||||
<update id="update" parameterType="com.sb.web.account.domain.DefaultCategory">
|
||||
UPDATE default_category SET name = #{name}, parent_id = #{parentId}
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<delete id="delete">
|
||||
DELETE FROM default_category WHERE id = #{id}
|
||||
</delete>
|
||||
|
||||
<update id="updateSortOrder">
|
||||
UPDATE default_category SET sort_order = #{sortOrder} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="maxSortOrder" resultType="java.lang.Integer">
|
||||
SELECT MAX(sort_order) FROM default_category WHERE type = #{type}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user