- parent_id 추가(CREATE + ALTER IF NOT EXISTS 멱등 마이그레이션) - 도메인/DTO/매퍼에 parentId 노출, 2단계 검증(대분류 아래만 소분류) - 자식 있는 대분류는 소분류화·삭제 차단(countChildren) - 통계/예산/내역은 소분류 이름 기준 그대로(무변경) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ public class AccountCategory implements Serializable {
|
|||||||
private Long memberId;
|
private Long memberId;
|
||||||
private String type; // INCOME / EXPENSE
|
private String type; // INCOME / EXPENSE
|
||||||
private String name;
|
private String name;
|
||||||
|
private Long parentId; // 대분류 id (NULL=대분류, 값=소분류)
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ public class CategoryRequest {
|
|||||||
@NotBlank(message = "분류 이름을 입력하세요.")
|
@NotBlank(message = "분류 이름을 입력하세요.")
|
||||||
@Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.")
|
@Size(max = 50, message = "분류 이름은 50자 이내여야 합니다.")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
/** 대분류 id. null=대분류로 생성, 값=해당 대분류의 소분류로 생성/이동 */
|
||||||
|
private Long parentId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ public class CategoryResponse {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String type;
|
private String type;
|
||||||
private String name;
|
private String name;
|
||||||
|
private Long parentId; // 대분류 id (null=대분류)
|
||||||
|
|
||||||
public static CategoryResponse from(AccountCategory c) {
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public interface CategoryMapper {
|
|||||||
|
|
||||||
AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
|
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,
|
int countByName(@Param("memberId") Long memberId,
|
||||||
@Param("type") String type,
|
@Param("type") String type,
|
||||||
@Param("name") String name,
|
@Param("name") String name,
|
||||||
|
|||||||
@@ -35,14 +35,30 @@ public class CategoryService {
|
|||||||
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
|
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
||||||
}
|
}
|
||||||
|
validateParent(req.getParentId(), req.getType(), memberId);
|
||||||
Integer max = categoryMapper.maxSortOrder(memberId, req.getType());
|
Integer max = categoryMapper.maxSortOrder(memberId, req.getType());
|
||||||
int order = (max == null ? 0 : max + 1);
|
int order = (max == null ? 0 : max + 1);
|
||||||
AccountCategory c = AccountCategory.builder()
|
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);
|
categoryMapper.insert(c);
|
||||||
return CategoryResponse.from(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 재설정 (본인·동일 타입만) */
|
/** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public List<CategoryResponse> reorder(Long memberId, String type, List<Long> ids) {
|
public List<CategoryResponse> reorder(Long memberId, String type, List<Long> ids) {
|
||||||
@@ -56,7 +72,8 @@ public class CategoryService {
|
|||||||
return list(memberId);
|
return list(memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 이름만 변경 가능. 변경 시 기존 내역·예산의 분류명도 함께 갱신 */
|
/** 이름 변경 + 대분류 이동. 이름 변경 시 기존 내역·예산의 분류명도 함께 갱신.
|
||||||
|
* 주의: parentId 는 항상 현재값(또는 새 대분류)을 보내야 한다(미전송 시 대분류로 풀림). */
|
||||||
@Transactional
|
@Transactional
|
||||||
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
|
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
|
||||||
AccountCategory c = mustFind(id, memberId);
|
AccountCategory c = mustFind(id, memberId);
|
||||||
@@ -64,8 +81,20 @@ public class CategoryService {
|
|||||||
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
|
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
|
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();
|
String oldName = c.getName();
|
||||||
c.setName(newName);
|
c.setName(newName);
|
||||||
|
c.setParentId(newParent);
|
||||||
categoryMapper.update(c);
|
categoryMapper.update(c);
|
||||||
if (!oldName.equals(newName)) {
|
if (!oldName.equals(newName)) {
|
||||||
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
|
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
|
||||||
@@ -79,6 +108,9 @@ public class CategoryService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id, Long memberId) {
|
public void delete(Long id, Long memberId) {
|
||||||
mustFind(id, memberId);
|
mustFind(id, memberId);
|
||||||
|
if (categoryMapper.countChildren(memberId, id) > 0) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요.");
|
||||||
|
}
|
||||||
categoryMapper.delete(id, memberId);
|
categoryMapper.delete(id, memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,12 +104,16 @@ CREATE TABLE IF NOT EXISTS account_category (
|
|||||||
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
|
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
|
||||||
type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE',
|
type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE',
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
|
parent_id BIGINT NULL COMMENT '대분류 id(account_category.id). NULL=대분류, 값=소분류',
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_category_member_type_name (member_id, type, name)
|
UNIQUE KEY uk_category_member_type_name (member_id, type, name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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 직접
|
-- fixed=1(고정): base_unit/base_amount 로 일수기준 환산 / fixed=0(비고정): daily/weekly/monthly/yearly 직접
|
||||||
CREATE TABLE IF NOT EXISTS budget (
|
CREATE TABLE IF NOT EXISTS budget (
|
||||||
|
|||||||
@@ -8,23 +8,29 @@
|
|||||||
<result property="memberId" column="member_id"/>
|
<result property="memberId" column="member_id"/>
|
||||||
<result property="type" column="type"/>
|
<result property="type" column="type"/>
|
||||||
<result property="name" column="name"/>
|
<result property="name" column="name"/>
|
||||||
|
<result property="parentId" column="parent_id"/>
|
||||||
<result property="sortOrder" column="sort_order"/>
|
<result property="sortOrder" column="sort_order"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByMember" parameterType="long" resultMap="categoryResultMap">
|
<select id="findByMember" parameterType="long" resultMap="categoryResultMap">
|
||||||
SELECT id, member_id, type, name, sort_order, created_at
|
SELECT id, member_id, type, name, parent_id, sort_order, created_at
|
||||||
FROM account_category
|
FROM account_category
|
||||||
WHERE member_id = #{memberId}
|
WHERE member_id = #{memberId}
|
||||||
ORDER BY type, sort_order, name
|
ORDER BY type, sort_order, name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findByIdAndMember" resultMap="categoryResultMap">
|
<select id="findByIdAndMember" resultMap="categoryResultMap">
|
||||||
SELECT id, member_id, type, name, sort_order, created_at
|
SELECT id, member_id, type, name, parent_id, sort_order, created_at
|
||||||
FROM account_category
|
FROM account_category
|
||||||
WHERE id = #{id} AND member_id = #{memberId}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="countChildren" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM account_category
|
||||||
|
WHERE member_id = #{memberId} AND parent_id = #{parentId}
|
||||||
|
</select>
|
||||||
|
|
||||||
<select id="countByName" resultType="int">
|
<select id="countByName" resultType="int">
|
||||||
SELECT COUNT(*) FROM account_category
|
SELECT COUNT(*) FROM account_category
|
||||||
WHERE member_id = #{memberId} AND type = #{type} AND name = #{name}
|
WHERE member_id = #{memberId} AND type = #{type} AND name = #{name}
|
||||||
@@ -33,17 +39,17 @@
|
|||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.account.domain.AccountCategory"
|
<insert id="insert" parameterType="com.sb.web.account.domain.AccountCategory"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO account_category (member_id, type, name, sort_order, created_at)
|
INSERT INTO account_category (member_id, type, name, parent_id, sort_order, created_at)
|
||||||
VALUES (#{memberId}, #{type}, #{name}, #{sortOrder}, NOW())
|
VALUES (#{memberId}, #{type}, #{name}, #{parentId}, #{sortOrder}, NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<insert id="insertIgnore" parameterType="com.sb.web.account.domain.AccountCategory">
|
<insert id="insertIgnore" parameterType="com.sb.web.account.domain.AccountCategory">
|
||||||
INSERT IGNORE INTO account_category (member_id, type, name, sort_order, created_at)
|
INSERT IGNORE INTO account_category (member_id, type, name, parent_id, sort_order, created_at)
|
||||||
VALUES (#{memberId}, #{type}, #{name}, 0, NOW())
|
VALUES (#{memberId}, #{type}, #{name}, #{parentId}, 0, NOW())
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.sb.web.account.domain.AccountCategory">
|
<update id="update" parameterType="com.sb.web.account.domain.AccountCategory">
|
||||||
UPDATE account_category SET name = #{name}
|
UPDATE account_category SET name = #{name}, parent_id = #{parentId}
|
||||||
WHERE id = #{id} AND member_id = #{memberId}
|
WHERE id = #{id} AND member_id = #{memberId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user