카테고리 기능 구현

This commit is contained in:
kjs
2025-11-05 15:23:57 +09:00
parent f4fd1184cd
commit 573a300a4a
35 changed files with 9577 additions and 131 deletions

View File

@@ -66,6 +66,7 @@ import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -226,6 +227,7 @@ app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@@ -0,0 +1,246 @@
import { Request, Response } from "express";
import tableCategoryValueService from "../services/tableCategoryValueService";
import { logger } from "../utils/logger";
/**
* 테이블의 카테고리 컬럼 목록 조회
*/
export const getCategoryColumns = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName } = req.params;
const columns = await tableCategoryValueService.getCategoryColumns(
tableName,
companyCode
);
return res.json({
success: true,
data: columns,
});
} catch (error: any) {
logger.error(`카테고리 컬럼 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "카테고리 컬럼 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
*/
export const getCategoryValues = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
const menuId = parseInt(req.query.menuId as string, 10);
const includeInactive = req.query.includeInactive === "true";
if (!menuId || isNaN(menuId)) {
return res.status(400).json({
success: false,
message: "menuId 파라미터가 필요합니다",
});
}
const values = await tableCategoryValueService.getCategoryValues(
tableName,
columnName,
menuId,
companyCode,
includeInactive
);
return res.json({
success: true,
data: values,
});
} catch (error: any) {
logger.error(`카테고리 값 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "카테고리 값 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 카테고리 값 추가
*/
export const addCategoryValue = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const value = req.body;
const newValue = await tableCategoryValueService.addCategoryValue(
value,
companyCode,
userId
);
return res.status(201).json({
success: true,
data: newValue,
});
} catch (error: any) {
logger.error(`카테고리 값 추가 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "카테고리 값 추가 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 카테고리 값 수정
*/
export const updateCategoryValue = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const valueId = parseInt(req.params.valueId);
const updates = req.body;
if (isNaN(valueId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 값 ID입니다",
});
}
const updatedValue = await tableCategoryValueService.updateCategoryValue(
valueId,
updates,
companyCode,
userId
);
return res.json({
success: true,
data: updatedValue,
});
} catch (error: any) {
logger.error(`카테고리 값 수정 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "카테고리 값 수정 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 카테고리 값 삭제
*/
export const deleteCategoryValue = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const valueId = parseInt(req.params.valueId);
if (isNaN(valueId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 값 ID입니다",
});
}
await tableCategoryValueService.deleteCategoryValue(
valueId,
companyCode,
userId
);
return res.json({
success: true,
message: "카테고리 값이 삭제되었습니다",
});
} catch (error: any) {
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 카테고리 값 일괄 삭제
*/
export const bulkDeleteCategoryValues = async (
req: Request,
res: Response
) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { valueIds } = req.body;
if (!Array.isArray(valueIds) || valueIds.length === 0) {
return res.status(400).json({
success: false,
message: "삭제할 값 ID 목록이 필요합니다",
});
}
await tableCategoryValueService.bulkDeleteCategoryValues(
valueIds,
companyCode,
userId
);
return res.json({
success: true,
message: `${valueIds.length}개의 카테고리 값이 삭제되었습니다`,
});
} catch (error: any) {
logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "카테고리 값 일괄 삭제 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 카테고리 값 순서 변경
*/
export const reorderCategoryValues = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { orderedValueIds } = req.body;
if (!Array.isArray(orderedValueIds) || orderedValueIds.length === 0) {
return res.status(400).json({
success: false,
message: "순서 정보가 필요합니다",
});
}
await tableCategoryValueService.reorderCategoryValues(
orderedValueIds,
companyCode
);
return res.json({
success: true,
message: "카테고리 값 순서가 변경되었습니다",
});
} catch (error: any) {
logger.error(`카테고리 값 순서 변경 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "카테고리 값 순서 변경 중 오류가 발생했습니다",
error: error.message,
});
}
};

View File

@@ -0,0 +1,50 @@
import { Router } from "express";
import * as tableCategoryValueController from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 테이블의 카테고리 컬럼 목록 조회
router.get(
"/:tableName/columns",
tableCategoryValueController.getCategoryColumns
);
// 카테고리 값 목록 조회
router.get(
"/:tableName/:columnName/values",
tableCategoryValueController.getCategoryValues
);
// 카테고리 값 추가
router.post("/values", tableCategoryValueController.addCategoryValue);
// 카테고리 값 수정
router.put(
"/values/:valueId",
tableCategoryValueController.updateCategoryValue
);
// 카테고리 값 삭제
router.delete(
"/values/:valueId",
tableCategoryValueController.deleteCategoryValue
);
// 카테고리 값 일괄 삭제
router.post(
"/values/bulk-delete",
tableCategoryValueController.bulkDeleteCategoryValues
);
// 카테고리 값 순서 변경
router.post(
"/values/reorder",
tableCategoryValueController.reorderCategoryValues
);
export default router;

View File

@@ -1,3 +1,18 @@
/**
* 동적 데이터 서비스
*
* 주요 특징:
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
*
* 보안:
* - 테이블명은 영문, 숫자, 언더스코어만 허용
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
* - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
*/
import { query, queryOne } from "../database/db";
interface GetTableDataParams {
@@ -17,65 +32,72 @@ interface ServiceResponse<T> {
}
/**
* 안전한 테이블 목록 (화이트리스트)
* SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능
* 접근 금지 테이블 목록 (블랙리스트)
* 시스템 중요 테이블 및 보안상 접근 금지할 테이블
*/
const ALLOWED_TABLES = [
"company_mng",
"user_info",
"dept_info",
"code_info",
"code_category",
"menu_info",
"approval",
"approval_kind",
"board",
"comm_code",
"product_mng",
"part_mng",
"material_mng",
"order_mng_master",
"inventory_mng",
"contract_mgmt",
"project_mgmt",
"screen_definitions",
"screen_layouts",
"layout_standards",
"component_standards",
"web_type_standards",
"button_action_standards",
"template_standards",
"grid_standards",
"style_templates",
"multi_lang_key_master",
"multi_lang_text",
"language_master",
"table_labels",
"column_labels",
"dynamic_form_data",
"work_history", // 작업 이력 테이블
"delivery_status", // 배송 현황 테이블
const BLOCKED_TABLES = [
"pg_catalog",
"pg_statistic",
"pg_database",
"pg_user",
"information_schema",
"session_tokens", // 세션 토큰 테이블
"password_history", // 패스워드 이력
];
/**
* 회사별 필터링이 필요한 테이블 목록
* 테이블 이름 검증 정규식
* SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용
*/
const COMPANY_FILTERED_TABLES = [
"company_mng",
"user_info",
"dept_info",
"approval",
"board",
"product_mng",
"part_mng",
"material_mng",
"order_mng_master",
"inventory_mng",
"contract_mgmt",
"project_mgmt",
];
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
class DataService {
/**
* 테이블 접근 검증 (공통 메서드)
*/
private async validateTableAccess(
tableName: string
): Promise<{ valid: boolean; error?: ServiceResponse<any> }> {
// 1. 테이블명 형식 검증 (SQL 인젝션 방지)
if (!TABLE_NAME_REGEX.test(tableName)) {
return {
valid: false,
error: {
success: false,
message: `유효하지 않은 테이블명입니다: ${tableName}`,
error: "INVALID_TABLE_NAME",
},
};
}
// 2. 블랙리스트 검증
if (BLOCKED_TABLES.includes(tableName)) {
return {
valid: false,
error: {
success: false,
message: `접근이 금지된 테이블입니다: ${tableName}`,
error: "TABLE_ACCESS_DENIED",
},
};
}
// 3. 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
return {
valid: false,
error: {
success: false,
message: `테이블을 찾을 수 없습니다: ${tableName}`,
error: "TABLE_NOT_FOUND",
},
};
}
return { valid: true };
}
/**
* 테이블 데이터 조회
*/
@@ -92,23 +114,10 @@ class DataService {
} = params;
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
}
// 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
return {
success: false,
message: `테이블을 찾을 수 없습니다: ${tableName}`,
error: "TABLE_NOT_FOUND",
};
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// 동적 SQL 쿼리 생성
@@ -119,13 +128,14 @@ class DataService {
// WHERE 조건 생성
const whereConditions: string[] = [];
// 회사별 필터링 추가
if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) {
// 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용
if (userCompany !== "*") {
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
if (hasCompanyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(userCompany);
paramIndex++;
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
}
}
@@ -213,13 +223,10 @@ class DataService {
*/
async getTableColumns(tableName: string): Promise<ServiceResponse<any[]>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
const columns = await this.getTableColumnsSimple(tableName);
@@ -276,6 +283,31 @@ class DataService {
}
}
/**
* 특정 컬럼 존재 여부 확인
*/
private async checkColumnExists(
tableName: string,
columnName: string
): Promise<boolean> {
try {
const result = await query<{ exists: boolean }>(
`SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
)`,
[tableName, columnName]
);
return result[0]?.exists || false;
} catch (error) {
console.error("컬럼 존재 확인 오류:", error);
return false;
}
}
/**
* 테이블 컬럼 정보 조회 (간단 버전)
*/
@@ -324,13 +356,10 @@ class DataService {
id: string | number
): Promise<ServiceResponse<any>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// Primary Key 컬럼 찾기
@@ -383,21 +412,16 @@ class DataService {
leftValue?: string | number
): Promise<ServiceResponse<any[]>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(leftTable)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`,
error: "TABLE_NOT_ALLOWED",
};
// 왼쪽 테이블 접근 검증
const leftValidation = await this.validateTableAccess(leftTable);
if (!leftValidation.valid) {
return leftValidation.error!;
}
if (!ALLOWED_TABLES.includes(rightTable)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`,
error: "TABLE_NOT_ALLOWED",
};
// 오른쪽 테이블 접근 검증
const rightValidation = await this.validateTableAccess(rightTable);
if (!rightValidation.valid) {
return rightValidation.error!;
}
let queryText = `
@@ -440,13 +464,10 @@ class DataService {
data: Record<string, any>
): Promise<ServiceResponse<any>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
const columns = Object.keys(data);
@@ -485,13 +506,10 @@ class DataService {
data: Record<string, any>
): Promise<ServiceResponse<any>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// Primary Key 컬럼 찾기
@@ -554,13 +572,10 @@ class DataService {
id: string | number
): Promise<ServiceResponse<void>> {
try {
// 테이블명 화이트리스트 검증
if (!ALLOWED_TABLES.includes(tableName)) {
return {
success: false,
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
error: "TABLE_NOT_ALLOWED",
};
// 테이블 접근 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// Primary Key 컬럼 찾기

View File

@@ -0,0 +1,497 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import {
TableCategoryValue,
CategoryColumn,
} from "../types/tableCategoryValue";
class TableCategoryValueService {
/**
* 메뉴의 형제 메뉴 ID 목록 조회
* (같은 부모를 가진 메뉴들)
*/
async getSiblingMenuIds(menuId: number): Promise<number[]> {
try {
const pool = getPool();
// 1. 현재 메뉴의 부모 ID 조회 (menu_info는 objid와 parent_obj_id 사용)
const parentQuery = `
SELECT parent_obj_id FROM menu_info WHERE objid = $1
`;
const parentResult = await pool.query(parentQuery, [menuId]);
if (parentResult.rows.length === 0) {
logger.warn(`메뉴 ID ${menuId}를 찾을 수 없습니다`);
return [menuId];
}
const parentId = parentResult.rows[0].parent_obj_id;
// 최상위 메뉴인 경우 (parent_obj_id가 null 또는 0)
if (!parentId || parentId === 0) {
logger.info(`메뉴 ${menuId}는 최상위 메뉴입니다`);
return [menuId];
}
// 2. 같은 부모를 가진 형제 메뉴들 조회
const siblingsQuery = `
SELECT objid FROM menu_info WHERE parent_obj_id = $1
`;
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
const siblingIds = siblingsResult.rows.map((row) => Number(row.objid));
logger.info(`메뉴 ${menuId}의 형제 메뉴 ${siblingIds.length}개 조회`, {
menuId,
parentId,
siblings: siblingIds,
});
return siblingIds;
} catch (error: any) {
logger.error(`형제 메뉴 조회 실패: ${error.message}`);
// 에러 시 현재 메뉴만 반환
return [menuId];
}
}
/**
* 테이블의 카테고리 타입 컬럼 목록 조회
*/
async getCategoryColumns(
tableName: string,
companyCode: string
): Promise<CategoryColumn[]> {
try {
logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode });
const pool = getPool();
const query = `
SELECT
tc.table_name AS "tableName",
tc.column_name AS "columnName",
tc.column_name AS "columnLabel",
COUNT(cv.value_id) AS "valueCount"
FROM table_type_columns tc
LEFT JOIN table_column_category_values cv
ON tc.table_name = cv.table_name
AND tc.column_name = cv.column_name
AND cv.is_active = true
AND (cv.company_code = $2 OR cv.company_code = '*')
WHERE tc.table_name = $1
AND tc.input_type = 'category'
GROUP BY tc.table_name, tc.column_name, tc.display_order
ORDER BY tc.display_order, tc.column_name
`;
const result = await pool.query(query, [tableName, companyCode]);
logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
tableName,
companyCode,
});
return result.rows;
} catch (error: any) {
logger.error(`카테고리 컬럼 조회 실패: ${error.message}`);
throw error;
}
}
/**
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프 적용)
*/
async getCategoryValues(
tableName: string,
columnName: string,
menuId: number,
companyCode: string,
includeInactive: boolean = false
): Promise<TableCategoryValue[]> {
try {
logger.info("카테고리 값 목록 조회", {
tableName,
columnName,
menuId,
companyCode,
includeInactive,
});
// 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함
const siblingMenuIds = await this.getSiblingMenuIds(menuId);
const pool = getPool();
let 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,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
menu_objid AS "menuId",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_objid = ANY($3)
AND (company_code = $4 OR company_code = '*')
`;
const params: any[] = [tableName, columnName, siblingMenuIds, companyCode];
if (!includeInactive) {
query += ` AND is_active = true`;
}
query += ` ORDER BY value_order, value_label`;
const result = await pool.query(query, params);
// 계층 구조로 변환
const values = this.buildHierarchy(result.rows);
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, {
tableName,
columnName,
menuId,
siblingMenuIds,
});
return values;
} catch (error: any) {
logger.error(`카테고리 값 조회 실패: ${error.message}`);
throw error;
}
}
/**
* 카테고리 값 추가
*/
async addCategoryValue(
value: TableCategoryValue,
companyCode: string,
userId: string
): Promise<TableCategoryValue> {
const pool = getPool();
try {
// 중복 코드 체크
const duplicateQuery = `
SELECT value_id
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND value_code = $3
AND (company_code = $4 OR company_code = '*')
`;
const duplicateResult = await pool.query(duplicateQuery, [
value.tableName,
value.columnName,
value.valueCode,
companyCode,
]);
if (duplicateResult.rows.length > 0) {
throw new Error("이미 존재하는 코드입니다");
}
const insertQuery = `
INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon,
is_active, is_default, menu_objid, company_code, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $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,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
menu_objid AS "menuId",
company_code AS "companyCode",
created_at AS "createdAt",
created_by AS "createdBy"
`;
const result = await pool.query(insertQuery, [
value.tableName,
value.columnName,
value.valueCode,
value.valueLabel,
value.valueOrder || 0,
value.parentValueId || null,
value.depth || 1,
value.description || null,
value.color || null,
value.icon || null,
value.isActive !== false,
value.isDefault || false,
value.menuId, // menuId 추가
companyCode,
userId,
]);
logger.info("카테고리 값 추가 완료", {
valueId: result.rows[0].valueId,
tableName: value.tableName,
columnName: value.columnName,
});
return result.rows[0];
} catch (error: any) {
logger.error(`카테고리 값 추가 실패: ${error.message}`);
throw error;
}
}
/**
* 카테고리 값 수정
*/
async updateCategoryValue(
valueId: number,
updates: Partial<TableCategoryValue>,
companyCode: string,
userId: string
): Promise<TableCategoryValue> {
const pool = getPool();
try {
const setClauses: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (updates.valueLabel !== undefined) {
setClauses.push(`value_label = $${paramIndex++}`);
values.push(updates.valueLabel);
}
if (updates.valueOrder !== undefined) {
setClauses.push(`value_order = $${paramIndex++}`);
values.push(updates.valueOrder);
}
if (updates.description !== undefined) {
setClauses.push(`description = $${paramIndex++}`);
values.push(updates.description);
}
if (updates.color !== undefined) {
setClauses.push(`color = $${paramIndex++}`);
values.push(updates.color);
}
if (updates.icon !== undefined) {
setClauses.push(`icon = $${paramIndex++}`);
values.push(updates.icon);
}
if (updates.isActive !== undefined) {
setClauses.push(`is_active = $${paramIndex++}`);
values.push(updates.isActive);
}
if (updates.isDefault !== undefined) {
setClauses.push(`is_default = $${paramIndex++}`);
values.push(updates.isDefault);
}
setClauses.push(`updated_at = NOW()`);
setClauses.push(`updated_by = $${paramIndex++}`);
values.push(userId);
values.push(valueId, companyCode);
const updateQuery = `
UPDATE table_column_category_values
SET ${setClauses.join(", ")}
WHERE value_id = $${paramIndex++}
AND (company_code = $${paramIndex++} OR company_code = '*')
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",
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
menu_objid AS "menuId",
updated_at AS "updatedAt",
updated_by AS "updatedBy"
`;
const result = await pool.query(updateQuery, values);
if (result.rowCount === 0) {
throw new Error("카테고리 값을 찾을 수 없습니다");
}
logger.info("카테고리 값 수정 완료", { valueId, companyCode });
return result.rows[0];
} catch (error: any) {
logger.error(`카테고리 값 수정 실패: ${error.message}`);
throw error;
}
}
/**
* 카테고리 값 삭제 (비활성화)
*/
async deleteCategoryValue(
valueId: number,
companyCode: string,
userId: string
): Promise<void> {
const pool = getPool();
try {
// 하위 값 체크
const checkQuery = `
SELECT COUNT(*) as count
FROM table_column_category_values
WHERE parent_value_id = $1
AND (company_code = $2 OR company_code = '*')
AND is_active = true
`;
const checkResult = await pool.query(checkQuery, [valueId, companyCode]);
if (parseInt(checkResult.rows[0].count) > 0) {
throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
}
// 비활성화
const deleteQuery = `
UPDATE table_column_category_values
SET is_active = false, updated_at = NOW(), updated_by = $3
WHERE value_id = $1
AND (company_code = $2 OR company_code = '*')
`;
await pool.query(deleteQuery, [valueId, companyCode, userId]);
logger.info("카테고리 값 삭제(비활성화) 완료", {
valueId,
companyCode,
});
} catch (error: any) {
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
throw error;
}
}
/**
* 카테고리 값 일괄 삭제
*/
async bulkDeleteCategoryValues(
valueIds: number[],
companyCode: string,
userId: string
): Promise<void> {
const pool = getPool();
try {
const deleteQuery = `
UPDATE table_column_category_values
SET is_active = false, updated_at = NOW(), updated_by = $3
WHERE value_id = ANY($1::int[])
AND (company_code = $2 OR company_code = '*')
`;
await pool.query(deleteQuery, [valueIds, companyCode, userId]);
logger.info("카테고리 값 일괄 삭제 완료", {
count: valueIds.length,
companyCode,
});
} catch (error: any) {
logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`);
throw error;
}
}
/**
* 카테고리 값 순서 변경
*/
async reorderCategoryValues(
orderedValueIds: number[],
companyCode: string
): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
for (let i = 0; i < orderedValueIds.length; i++) {
const updateQuery = `
UPDATE table_column_category_values
SET value_order = $1, updated_at = NOW()
WHERE value_id = $2
AND (company_code = $3 OR company_code = '*')
`;
await client.query(updateQuery, [
i + 1,
orderedValueIds[i],
companyCode,
]);
}
await client.query("COMMIT");
logger.info("카테고리 값 순서 변경 완료", {
count: orderedValueIds.length,
companyCode,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error(`카테고리 값 순서 변경 실패: ${error.message}`);
throw error;
} finally {
client.release();
}
}
/**
* 계층 구조 변환 헬퍼
*/
private buildHierarchy(
values: TableCategoryValue[],
parentId: number | null = null
): TableCategoryValue[] {
return values
.filter((v) => v.parentValueId === parentId)
.map((v) => ({
...v,
children: this.buildHierarchy(values, v.valueId!),
}));
}
}
export default new TableCategoryValueService();

View File

@@ -0,0 +1,48 @@
/**
* 테이블 컬럼별 카테고리 값 타입 정의
*/
export interface TableCategoryValue {
valueId?: number;
tableName: string;
columnName: string;
// 값 정보
valueCode: string;
valueLabel: string;
valueOrder?: number;
// 계층 구조
parentValueId?: number;
depth?: number;
// 추가 정보
description?: string;
color?: string;
icon?: string;
isActive?: boolean;
isDefault?: boolean;
// 하위 항목 (조회 시)
children?: TableCategoryValue[];
// 메뉴 스코프
menuId: number;
// 멀티테넌시
companyCode?: string;
// 메타
createdAt?: string;
updatedAt?: string;
createdBy?: string;
updatedBy?: string;
}
export interface CategoryColumn {
tableName: string;
columnName: string;
columnLabel: string;
valueCount?: number; // 값 개수
}