feat: 카테고리 값 삭제 기능 개선 및 하위 카테고리 수집 로직 추가

- 카테고리 값 삭제 시, 자기 자신과 모든 하위 카테고리 ID를 재귀적으로 수집하는 기능을 추가하였습니다.
- 삭제 대상 카테고리 값 수집 완료 후, 하위 카테고리부터 역순으로 삭제하는 로직을 구현하였습니다.
- 관련된 로그 메시지를 추가하여 삭제 과정과 결과를 기록하도록 하였습니다.
- 화면 관리 기능에서 하위 항목 개수를 계산하는 로직을 개선하여 사용자에게 더 정확한 정보를 제공하도록 하였습니다.
This commit is contained in:
kjs
2026-01-27 10:06:40 +09:00
parent 589f5b9222
commit 64cc5c6772
6 changed files with 248 additions and 82 deletions

View File

@@ -403,6 +403,33 @@ class CategoryTreeService {
}
}
/**
* 모든 하위 카테고리 값 ID 재귀 수집
*/
private async collectAllChildValueIds(
companyCode: string,
valueId: number
): Promise<number[]> {
const pool = getPool();
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
const query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM category_values_test
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
UNION ALL
SELECT cv.value_id
FROM category_values_test cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2 OR cv.company_code = '*'
)
SELECT value_id FROM category_tree
`;
const result = await pool.query(query, [valueId, companyCode]);
return result.rows.map(row => row.value_id);
}
/**
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
*/
@@ -410,20 +437,33 @@ class CategoryTreeService {
const pool = getPool();
try {
const query = `
DELETE FROM category_values_test
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
RETURNING value_id
`;
// 1. 모든 하위 카테고리 ID 수집
const childValueIds = await this.collectAllChildValueIds(companyCode, valueId);
const allValueIds = [valueId, ...childValueIds];
logger.info("삭제 대상 카테고리 값 수집 완료", {
valueId,
childCount: childValueIds.length,
totalCount: allValueIds.length,
});
const result = await pool.query(query, [companyCode, valueId]);
if (result.rowCount && result.rowCount > 0) {
logger.info("카테고리 값 삭제 완료", { valueId });
return true;
// 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
const reversedIds = [...allValueIds].reverse();
for (const id of reversedIds) {
await pool.query(
`DELETE FROM category_values_test WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
[companyCode, id]
);
}
return false;
logger.info("카테고리 값 삭제 완료", {
valueId,
deletedCount: allValueIds.length,
deletedChildCount: childValueIds.length,
});
return true;
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });

View File

@@ -619,7 +619,55 @@ class TableCategoryValueService {
}
/**
* 카테고리 값 삭제 (물리적 삭제)
* 모든 하위 카테고리 값 ID 재귀 수집
*/
private async collectAllChildValueIds(
valueId: number,
companyCode: string
): Promise<number[]> {
const pool = getPool();
const allChildIds: number[] = [];
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1
UNION ALL
SELECT cv.value_id
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
)
SELECT value_id FROM category_tree
`;
params = [valueId];
} else {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values
WHERE parent_value_id = $1 AND company_code = $2
UNION ALL
SELECT cv.value_id
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2
)
SELECT value_id FROM category_tree
`;
params = [valueId, companyCode];
}
const result = await pool.query(query, params);
result.rows.forEach(row => allChildIds.push(row.value_id));
return allChildIds;
}
/**
* 카테고리 값 삭제 (하위 카테고리 포함 물리적 삭제)
*/
async deleteCategoryValue(
valueId: number,
@@ -629,82 +677,74 @@ class TableCategoryValueService {
const pool = getPool();
try {
// 1. 사용 여부 확인
const usage = await this.checkCategoryValueUsage(valueId, companyCode);
// 1. 자기 자신 + 모든 하위 카테고리 ID 수집
const childValueIds = await this.collectAllChildValueIds(valueId, companyCode);
const allValueIds = [valueId, ...childValueIds];
logger.info("삭제 대상 카테고리 값 수집 완료", {
valueId,
childCount: childValueIds.length,
totalCount: allValueIds.length,
});
if (usage.isUsed) {
let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n";
errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
// 2. 모든 대상 항목의 사용 여부 확인
for (const id of allValueIds) {
const usage = await this.checkCategoryValueUsage(id, companyCode);
if (usage.usedInTables.length > 0) {
const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
if (usage.isUsed) {
// 사용 중인 항목 정보 조회
let labelQuery: string;
let labelParams: any[];
if (companyCode === "*") {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`;
labelParams = [id];
} else {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
labelParams = [id, companyCode];
}
const labelResult = await pool.query(labelQuery, labelParams);
const valueLabel = labelResult.rows[0]?.value_label || `ID:${id}`;
let errorMessage = `카테고리 "${valueLabel}"을(를) 삭제할 수 없습니다.\n`;
errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
if (usage.usedInTables.length > 0) {
const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
}
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
throw new Error(errorMessage);
}
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
throw new Error(errorMessage);
}
// 2. 하위 값 체크 (멀티테넌시 적용)
let checkQuery: string;
let checkParams: any[];
// 3. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allValueIds].reverse();
if (companyCode === "*") {
// 최고 관리자: 모든 하위 값 체크
checkQuery = `
SELECT COUNT(*) as count
FROM table_column_category_values
WHERE parent_value_id = $1
`;
checkParams = [valueId];
} else {
// 일반 회사: 자신의 하위 값만 체크
checkQuery = `
SELECT COUNT(*) as count
FROM table_column_category_values
WHERE parent_value_id = $1
AND company_code = $2
`;
checkParams = [valueId, companyCode];
}
const checkResult = await pool.query(checkQuery, checkParams);
if (parseInt(checkResult.rows[0].count) > 0) {
throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
}
// 3. 물리적 삭제 (멀티테넌시 적용)
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 삭제 가능
deleteQuery = `
DELETE FROM table_column_category_values
WHERE value_id = $1
`;
deleteParams = [valueId];
} else {
// 일반 회사: 자신의 카테고리 값만 삭제 가능
deleteQuery = `
DELETE FROM table_column_category_values
WHERE value_id = $1
AND company_code = $2
`;
deleteParams = [valueId, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
if (result.rowCount === 0) {
throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다");
for (const id of reversedIds) {
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`;
deleteParams = [id];
} else {
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
deleteParams = [id, companyCode];
}
await pool.query(deleteQuery, deleteParams);
}
logger.info("카테고리 값 삭제 완료", {
valueId,
companyCode,
deletedCount: allValueIds.length,
deletedChildCount: childValueIds.length,
});
} catch (error: any) {
logger.error(`카테고리 값 삭제 실패: ${error.message}`);