Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs
2026-03-25 11:03:41 +09:00
19 changed files with 504 additions and 195 deletions

View File

@@ -561,6 +561,34 @@ export class EntityJoinController {
});
}
}
/**
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
try {
const { tableName, columnName } = req.params;
const companyCode = (req as any).user?.companyCode;
const data = await tableManagementService.getColumnDistinctValues(
tableName,
columnName,
companyCode
);
res.status(200).json({
success: true,
data,
});
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error);
res.status(500).json({
success: false,
message: "컬럼 고유값 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
export const entityJoinController = new EntityJoinController();

View File

@@ -55,6 +55,15 @@ router.get(
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
);
/**
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
router.get(
"/tables/:tableName/column-values/:columnName",
entityJoinController.getColumnUniqueValues.bind(entityJoinController)
);
// ========================================
// 🎯 Entity 조인 설정 관리
// ========================================

View File

@@ -211,7 +211,8 @@ class TableCategoryValueService {
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
updated_by AS "updatedBy",
path
FROM category_values
WHERE table_name = $1
AND column_name = $2
@@ -1441,7 +1442,7 @@ class TableCategoryValueService {
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (companyCode === "*") {
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
`;
@@ -1449,7 +1450,7 @@ class TableCategoryValueService {
} else {
const companyIdx = n + 1;
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
AND (company_code = $${companyIdx} OR company_code = '*')
@@ -1460,10 +1461,15 @@ class TableCategoryValueService {
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
const labels: Record<string, string> = {};
for (const row of result.rows) {
if (!labels[row.value_code]) {
labels[row.value_code] = row.value_label;
if (row.path && row.path.includes('/')) {
labels[row.value_code] = row.path.replace(/\//g, ' > ');
} else {
labels[row.value_code] = row.value_label;
}
}
}

View File

@@ -3412,6 +3412,31 @@ export class TableManagementService {
case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`);
break;
case "not_contains":
filterConditions.push(
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "greater_than":
filterConditions.push(
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
);
break;
case "less_than":
filterConditions.push(
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
);
break;
case "greater_or_equal":
filterConditions.push(
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
);
break;
case "less_or_equal":
filterConditions.push(
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
);
break;
}
}
@@ -3428,6 +3453,89 @@ export class TableManagementService {
}
}
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
if (
options.dataFilter &&
options.dataFilter.filterGroups &&
options.dataFilter.filterGroups.length > 0
) {
const groupConditions: string[] = [];
for (const group of options.dataFilter.filterGroups) {
if (!group.conditions || group.conditions.length === 0) continue;
const conditions: string[] = [];
for (const condition of group.conditions) {
const { columnName, operator, value } = condition;
if (!columnName) continue;
const safeCol = `main."${columnName}"`;
switch (operator) {
case "equals":
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
break;
case "not_equals":
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
break;
case "contains":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "not_contains":
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "starts_with":
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
break;
case "ends_with":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
break;
case "greater_than":
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
break;
case "less_than":
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
break;
case "greater_or_equal":
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
break;
case "less_or_equal":
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
break;
case "is_null":
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
break;
case "is_not_null":
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
break;
case "in": {
const inArr = Array.isArray(value) ? value : [String(value)];
if (inArr.length > 0) {
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
conditions.push(`${safeCol}::text IN (${vals})`);
}
break;
}
}
}
if (conditions.length > 0) {
const logic = group.logic === "OR" ? " OR " : " AND ";
groupConditions.push(`(${conditions.join(logic)})`);
}
}
if (groupConditions.length > 0) {
const groupWhere = groupConditions.join(" AND ");
whereClause = whereClause
? `${whereClause} AND ${groupWhere}`
: groupWhere;
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
}
}
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) {
const {
@@ -5391,4 +5499,40 @@ export class TableManagementService {
return [];
}
}
/**
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
*/
async getColumnDistinctValues(
tableName: string,
columnName: string,
companyCode?: string
): Promise<{ value: string; label: string }[]> {
try {
// 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`);
return [];
}
let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`;
const params: any[] = [];
if (companyCode) {
params.push(companyCode);
sql += ` AND "company_code" = $${params.length}`;
}
sql += ` ORDER BY value LIMIT 500`;
const rows = await query<{ value: string }>(sql, params);
return rows.map((row) => ({
value: row.value,
label: row.value,
}));
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error);
return [];
}
}
}