514 lines
15 KiB
TypeScript
514 lines
15 KiB
TypeScript
/**
|
|
* 카테고리 트리 서비스 (테스트용)
|
|
* - 트리 구조 지원 (최대 3단계: 대분류/중분류/소분류)
|
|
*/
|
|
|
|
import { getPool } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
|
|
// 카테고리 값 타입
|
|
export interface CategoryValue {
|
|
valueId: number;
|
|
tableName: string;
|
|
columnName: string;
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
valueOrder: number;
|
|
parentValueId: number | null;
|
|
depth: number;
|
|
path: string | null;
|
|
description: string | null;
|
|
color: string | null;
|
|
icon: string | null;
|
|
isActive: boolean;
|
|
isDefault: boolean;
|
|
companyCode: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
createdBy: string | null;
|
|
updatedBy: string | null;
|
|
children?: CategoryValue[];
|
|
}
|
|
|
|
// 카테고리 값 생성 입력
|
|
export interface CreateCategoryValueInput {
|
|
tableName: string;
|
|
columnName: string;
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
valueOrder?: number;
|
|
parentValueId?: number | null;
|
|
description?: string;
|
|
color?: string;
|
|
icon?: string;
|
|
isActive?: boolean;
|
|
isDefault?: boolean;
|
|
}
|
|
|
|
// 카테고리 값 수정 입력
|
|
export interface UpdateCategoryValueInput {
|
|
valueCode?: string;
|
|
valueLabel?: string;
|
|
valueOrder?: number;
|
|
parentValueId?: number | null;
|
|
description?: string;
|
|
color?: string;
|
|
icon?: string;
|
|
isActive?: boolean;
|
|
isDefault?: boolean;
|
|
}
|
|
|
|
class CategoryTreeService {
|
|
/**
|
|
* 카테고리 값 목록 조회 (트리 구조로 반환)
|
|
*/
|
|
async getCategoryTree(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
logger.info("카테고리 트리 조회 시작", { companyCode, tableName, columnName });
|
|
|
|
const query = `
|
|
SELECT
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
path,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt",
|
|
created_by AS "createdBy",
|
|
updated_by AS "updatedBy"
|
|
FROM category_values_test
|
|
WHERE (company_code = $1 OR company_code = '*')
|
|
AND table_name = $2
|
|
AND column_name = $3
|
|
ORDER BY depth ASC, value_order ASC, value_label ASC
|
|
`;
|
|
|
|
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
|
const flatList = result.rows as CategoryValue[];
|
|
|
|
const tree = this.buildTree(flatList);
|
|
|
|
logger.info("카테고리 트리 조회 완료", {
|
|
tableName,
|
|
columnName,
|
|
totalCount: flatList.length,
|
|
rootCount: tree.length,
|
|
});
|
|
|
|
return tree;
|
|
} catch (error: unknown) {
|
|
const err = error as Error;
|
|
logger.error("카테고리 트리 조회 실패", { error: err.message, tableName, columnName });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 목록 조회 (플랫 리스트)
|
|
*/
|
|
async getCategoryList(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
path,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt"
|
|
FROM category_values_test
|
|
WHERE (company_code = $1 OR company_code = '*')
|
|
AND table_name = $2
|
|
AND column_name = $3
|
|
ORDER BY depth ASC, value_order ASC, value_label ASC
|
|
`;
|
|
|
|
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
|
return result.rows as CategoryValue[];
|
|
} catch (error: unknown) {
|
|
const err = error as Error;
|
|
logger.error("카테고리 목록 조회 실패", { error: err.message });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 단일 조회
|
|
*/
|
|
async getCategoryValue(companyCode: string, valueId: number): Promise<CategoryValue | null> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
path,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt"
|
|
FROM category_values_test
|
|
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
|
`;
|
|
|
|
const result = await pool.query(query, [companyCode, valueId]);
|
|
return result.rows[0] || null;
|
|
} catch (error: unknown) {
|
|
const err = error as Error;
|
|
logger.error("카테고리 값 조회 실패", { error: err.message, valueId });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 생성
|
|
*/
|
|
async createCategoryValue(companyCode: string, input: CreateCategoryValueInput, createdBy?: string): Promise<CategoryValue> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
// depth 계산
|
|
let depth = 1;
|
|
let path = input.valueLabel;
|
|
|
|
if (input.parentValueId) {
|
|
const parent = await this.getCategoryValue(companyCode, input.parentValueId);
|
|
if (parent) {
|
|
depth = parent.depth + 1;
|
|
path = parent.path ? `${parent.path}/${input.valueLabel}` : input.valueLabel;
|
|
|
|
if (depth > 3) {
|
|
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
|
|
}
|
|
}
|
|
}
|
|
|
|
const query = `
|
|
INSERT INTO category_values_test (
|
|
table_name, column_name, value_code, value_label, value_order,
|
|
parent_value_id, depth, path, description, color, icon,
|
|
is_active, is_default, company_code, created_by, updated_by
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
|
|
)
|
|
RETURNING
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
path,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt"
|
|
`;
|
|
|
|
const params = [
|
|
input.tableName,
|
|
input.columnName,
|
|
input.valueCode,
|
|
input.valueLabel,
|
|
input.valueOrder ?? 0,
|
|
input.parentValueId ?? null,
|
|
depth,
|
|
path,
|
|
input.description ?? null,
|
|
input.color ?? null,
|
|
input.icon ?? null,
|
|
input.isActive ?? true,
|
|
input.isDefault ?? false,
|
|
companyCode,
|
|
createdBy ?? null,
|
|
];
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
logger.info("카테고리 값 생성 완료", {
|
|
valueId: result.rows[0].valueId,
|
|
valueLabel: input.valueLabel,
|
|
depth,
|
|
});
|
|
|
|
return result.rows[0];
|
|
} catch (error: unknown) {
|
|
const err = error as Error;
|
|
logger.error("카테고리 값 생성 실패", { error: err.message, input });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 수정
|
|
*/
|
|
async updateCategoryValue(
|
|
companyCode: string,
|
|
valueId: number,
|
|
input: UpdateCategoryValueInput,
|
|
updatedBy?: string
|
|
): Promise<CategoryValue | null> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const current = await this.getCategoryValue(companyCode, valueId);
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
|
|
let newPath = current.path;
|
|
let newDepth = current.depth;
|
|
|
|
if (input.valueLabel && input.valueLabel !== current.valueLabel) {
|
|
if (current.parentValueId) {
|
|
const parent = await this.getCategoryValue(companyCode, current.parentValueId);
|
|
if (parent && parent.path) {
|
|
newPath = `${parent.path}/${input.valueLabel}`;
|
|
} else {
|
|
newPath = input.valueLabel;
|
|
}
|
|
} else {
|
|
newPath = input.valueLabel;
|
|
}
|
|
}
|
|
|
|
if (input.parentValueId !== undefined && input.parentValueId !== current.parentValueId) {
|
|
if (input.parentValueId) {
|
|
const newParent = await this.getCategoryValue(companyCode, input.parentValueId);
|
|
if (newParent) {
|
|
newDepth = newParent.depth + 1;
|
|
const label = input.valueLabel ?? current.valueLabel;
|
|
newPath = newParent.path ? `${newParent.path}/${label}` : label;
|
|
|
|
if (newDepth > 3) {
|
|
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
|
|
}
|
|
}
|
|
} else {
|
|
newDepth = 1;
|
|
newPath = input.valueLabel ?? current.valueLabel;
|
|
}
|
|
}
|
|
|
|
const query = `
|
|
UPDATE category_values_test
|
|
SET
|
|
value_code = COALESCE($3, value_code),
|
|
value_label = COALESCE($4, value_label),
|
|
value_order = COALESCE($5, value_order),
|
|
parent_value_id = $6,
|
|
depth = $7,
|
|
path = $8,
|
|
description = COALESCE($9, description),
|
|
color = COALESCE($10, color),
|
|
icon = COALESCE($11, icon),
|
|
is_active = COALESCE($12, is_active),
|
|
is_default = COALESCE($13, is_default),
|
|
updated_at = NOW(),
|
|
updated_by = $14
|
|
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
|
RETURNING
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
path,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt"
|
|
`;
|
|
|
|
const params = [
|
|
companyCode,
|
|
valueId,
|
|
input.valueCode ?? null,
|
|
input.valueLabel ?? null,
|
|
input.valueOrder ?? null,
|
|
input.parentValueId !== undefined ? input.parentValueId : current.parentValueId,
|
|
newDepth,
|
|
newPath,
|
|
input.description ?? null,
|
|
input.color ?? null,
|
|
input.icon ?? null,
|
|
input.isActive ?? null,
|
|
input.isDefault ?? null,
|
|
updatedBy ?? null,
|
|
];
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
if (input.valueLabel || input.parentValueId !== undefined) {
|
|
await this.updateChildrenPaths(companyCode, valueId, newPath || "");
|
|
}
|
|
|
|
logger.info("카테고리 값 수정 완료", { valueId });
|
|
|
|
return result.rows[0] || null;
|
|
} catch (error: unknown) {
|
|
const err = error as Error;
|
|
logger.error("카테고리 값 수정 실패", { error: err.message, valueId });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
|
|
*/
|
|
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
|
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
|
|
`;
|
|
|
|
const result = await pool.query(query, [companyCode, valueId]);
|
|
|
|
if (result.rowCount && result.rowCount > 0) {
|
|
logger.info("카테고리 값 삭제 완료", { valueId });
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch (error: unknown) {
|
|
const err = error as Error;
|
|
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 하위 항목들의 path 업데이트
|
|
*/
|
|
private async updateChildrenPaths(companyCode: string, parentValueId: number, parentPath: string): Promise<void> {
|
|
const pool = getPool();
|
|
|
|
const query = `
|
|
SELECT value_id, value_label
|
|
FROM category_values_test
|
|
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
|
|
`;
|
|
|
|
const result = await pool.query(query, [companyCode, parentValueId]);
|
|
|
|
for (const child of result.rows) {
|
|
const newPath = `${parentPath}/${child.value_label}`;
|
|
|
|
await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
|
|
newPath,
|
|
child.value_id,
|
|
]);
|
|
|
|
await this.updateChildrenPaths(companyCode, child.value_id, newPath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플랫 리스트를 트리 구조로 변환
|
|
*/
|
|
private buildTree(flatList: CategoryValue[]): CategoryValue[] {
|
|
const map = new Map<number, CategoryValue>();
|
|
const roots: CategoryValue[] = [];
|
|
|
|
for (const item of flatList) {
|
|
map.set(item.valueId, { ...item, children: [] });
|
|
}
|
|
|
|
for (const item of flatList) {
|
|
const node = map.get(item.valueId)!;
|
|
|
|
if (item.parentValueId && map.has(item.parentValueId)) {
|
|
const parent = map.get(item.parentValueId)!;
|
|
parent.children = parent.children || [];
|
|
parent.children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
}
|
|
|
|
return roots;
|
|
}
|
|
|
|
/**
|
|
* 테이블의 카테고리 컬럼 목록 조회
|
|
*/
|
|
async getCategoryColumns(companyCode: string, tableName: string): Promise<{ columnName: string; columnLabel: string }[]> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const query = `
|
|
SELECT DISTINCT column_name AS "columnName", column_label AS "columnLabel"
|
|
FROM table_type_columns
|
|
WHERE table_name = $1
|
|
AND input_type = 'category'
|
|
AND (company_code = $2 OR company_code = '*')
|
|
ORDER BY column_name
|
|
`;
|
|
|
|
const result = await pool.query(query, [tableName, companyCode]);
|
|
return result.rows;
|
|
} catch (error: unknown) {
|
|
const err = error as Error;
|
|
logger.error("카테고리 컬럼 목록 조회 실패", { error: err.message, tableName });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const categoryTreeService = new CategoryTreeService();
|
|
|