연쇄 통합관리
This commit is contained in:
525
backend-node/src/controllers/cascadingConditionController.ts
Normal file
525
backend-node/src/controllers/cascadingConditionController.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* 조건부 연쇄 (Conditional Cascading) 컨트롤러
|
||||
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// =====================================================
|
||||
// 조건부 연쇄 규칙 CRUD
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 목록 조회
|
||||
*/
|
||||
export const getConditions = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { isActive, relationCode, relationType } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT * FROM cascading_condition
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $${paramIndex++}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isActive) {
|
||||
sql += ` AND is_active = $${paramIndex++}`;
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
// 관계 코드 필터
|
||||
if (relationCode) {
|
||||
sql += ` AND relation_code = $${paramIndex++}`;
|
||||
params.push(relationCode);
|
||||
}
|
||||
|
||||
// 관계 유형 필터 (RELATION / HIERARCHY)
|
||||
if (relationType) {
|
||||
sql += ` AND relation_type = $${paramIndex++}`;
|
||||
params.push(relationType);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY relation_code, priority, condition_name`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 상세 조회
|
||||
*/
|
||||
export const getConditionDetail = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||
const params: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
sql += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
const result = await queryOne(sql, params);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 생성
|
||||
*/
|
||||
export const createCondition = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
relationType = "RELATION",
|
||||
relationCode,
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator = "EQ",
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority = 0,
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
||||
});
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO cascading_condition (
|
||||
relation_type, relation_code, condition_name,
|
||||
condition_field, condition_operator, condition_value,
|
||||
filter_column, filter_values, priority,
|
||||
company_code, is_active, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(insertSql, [
|
||||
relationType,
|
||||
relationCode,
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 생성되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 수정
|
||||
*/
|
||||
export const updateCondition = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const {
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
isActive,
|
||||
} = req.body;
|
||||
|
||||
// 기존 규칙 확인
|
||||
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||
const checkParams: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
checkSql += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await queryOne(checkSql, checkParams);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updateSql = `
|
||||
UPDATE cascading_condition SET
|
||||
condition_name = COALESCE($1, condition_name),
|
||||
condition_field = COALESCE($2, condition_field),
|
||||
condition_operator = COALESCE($3, condition_operator),
|
||||
condition_value = COALESCE($4, condition_value),
|
||||
filter_column = COALESCE($5, filter_column),
|
||||
filter_values = COALESCE($6, filter_values),
|
||||
priority = COALESCE($7, priority),
|
||||
is_active = COALESCE($8, is_active),
|
||||
updated_date = CURRENT_TIMESTAMP
|
||||
WHERE condition_id = $9
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await queryOne(updateSql, [
|
||||
conditionName,
|
||||
conditionField,
|
||||
conditionOperator,
|
||||
conditionValue,
|
||||
filterColumn,
|
||||
filterValues,
|
||||
priority,
|
||||
isActive,
|
||||
Number(conditionId),
|
||||
]);
|
||||
|
||||
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 수정되었습니다.",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연쇄 규칙 삭제
|
||||
*/
|
||||
export const deleteCondition = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { conditionId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
|
||||
const deleteParams: any[] = [Number(conditionId)];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
deleteSql += ` AND company_code = $2`;
|
||||
deleteParams.push(companyCode);
|
||||
}
|
||||
|
||||
deleteSql += ` RETURNING condition_id`;
|
||||
|
||||
const result = await queryOne(deleteSql, deleteParams);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "조건부 연쇄 규칙이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 조건부 필터링 적용 API (실제 사용)
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* 조건에 따른 필터링된 옵션 조회
|
||||
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
||||
*/
|
||||
export const getFilteredOptions = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { relationCode } = req.params;
|
||||
const { conditionFieldValue, parentValue } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 1. 기본 연쇄 관계 정보 조회
|
||||
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
|
||||
const relationParams: any[] = [relationCode];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
relationSql += ` AND company_code = $2`;
|
||||
relationParams.push(companyCode);
|
||||
}
|
||||
|
||||
const relation = await queryOne(relationSql, relationParams);
|
||||
|
||||
if (!relation) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 해당 관계에 적용되는 조건 규칙 조회
|
||||
let conditionSql = `
|
||||
SELECT * FROM cascading_condition
|
||||
WHERE relation_code = $1 AND is_active = 'Y'
|
||||
`;
|
||||
const conditionParams: any[] = [relationCode];
|
||||
let conditionParamIndex = 2;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
|
||||
conditionParams.push(companyCode);
|
||||
}
|
||||
|
||||
conditionSql += ` ORDER BY priority DESC`;
|
||||
|
||||
const conditions = await query(conditionSql, conditionParams);
|
||||
|
||||
// 3. 조건에 맞는 규칙 찾기
|
||||
let matchedCondition: any = null;
|
||||
|
||||
if (conditionFieldValue) {
|
||||
for (const cond of conditions) {
|
||||
const isMatch = evaluateCondition(
|
||||
conditionFieldValue as string,
|
||||
cond.condition_operator,
|
||||
cond.condition_value
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matchedCondition = cond;
|
||||
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 옵션 조회 쿼리 생성
|
||||
let optionsSql = `
|
||||
SELECT
|
||||
${relation.child_value_column} as value,
|
||||
${relation.child_label_column} as label
|
||||
FROM ${relation.child_table}
|
||||
WHERE 1=1
|
||||
`;
|
||||
const optionsParams: any[] = [];
|
||||
let optionsParamIndex = 1;
|
||||
|
||||
// 부모 값 필터 (기본 연쇄)
|
||||
if (parentValue) {
|
||||
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
|
||||
optionsParams.push(parentValue);
|
||||
}
|
||||
|
||||
// 조건부 필터 적용
|
||||
if (matchedCondition) {
|
||||
const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim());
|
||||
const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(",");
|
||||
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
||||
optionsParams.push(...filterValues);
|
||||
optionsParamIndex += filterValues.length;
|
||||
}
|
||||
|
||||
// 멀티테넌시 필터
|
||||
if (companyCode !== "*") {
|
||||
const columnCheck = await queryOne(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[relation.child_table]
|
||||
);
|
||||
|
||||
if (columnCheck) {
|
||||
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||
optionsParams.push(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (relation.child_order_column) {
|
||||
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||
} else {
|
||||
optionsSql += ` ORDER BY ${relation.child_label_column}`;
|
||||
}
|
||||
|
||||
const optionsResult = await query(optionsSql, optionsParams);
|
||||
|
||||
logger.info("조건부 필터링 옵션 조회", {
|
||||
relationCode,
|
||||
conditionFieldValue,
|
||||
parentValue,
|
||||
matchedCondition: matchedCondition?.condition_name,
|
||||
optionCount: optionsResult.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: optionsResult,
|
||||
appliedCondition: matchedCondition
|
||||
? {
|
||||
conditionId: matchedCondition.condition_id,
|
||||
conditionName: matchedCondition.condition_name,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "조건부 필터링 옵션 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건 평가 함수
|
||||
*/
|
||||
function evaluateCondition(
|
||||
actualValue: string,
|
||||
operator: string,
|
||||
expectedValue: string
|
||||
): boolean {
|
||||
const actual = actualValue.toLowerCase().trim();
|
||||
const expected = expectedValue.toLowerCase().trim();
|
||||
|
||||
switch (operator.toUpperCase()) {
|
||||
case "EQ":
|
||||
case "=":
|
||||
case "EQUALS":
|
||||
return actual === expected;
|
||||
|
||||
case "NEQ":
|
||||
case "!=":
|
||||
case "<>":
|
||||
case "NOT_EQUALS":
|
||||
return actual !== expected;
|
||||
|
||||
case "CONTAINS":
|
||||
case "LIKE":
|
||||
return actual.includes(expected);
|
||||
|
||||
case "NOT_CONTAINS":
|
||||
case "NOT_LIKE":
|
||||
return !actual.includes(expected);
|
||||
|
||||
case "STARTS_WITH":
|
||||
return actual.startsWith(expected);
|
||||
|
||||
case "ENDS_WITH":
|
||||
return actual.endsWith(expected);
|
||||
|
||||
case "IN":
|
||||
const inValues = expected.split(",").map((v) => v.trim());
|
||||
return inValues.includes(actual);
|
||||
|
||||
case "NOT_IN":
|
||||
const notInValues = expected.split(",").map((v) => v.trim());
|
||||
return !notInValues.includes(actual);
|
||||
|
||||
case "GT":
|
||||
case ">":
|
||||
return parseFloat(actual) > parseFloat(expected);
|
||||
|
||||
case "GTE":
|
||||
case ">=":
|
||||
return parseFloat(actual) >= parseFloat(expected);
|
||||
|
||||
case "LT":
|
||||
case "<":
|
||||
return parseFloat(actual) < parseFloat(expected);
|
||||
|
||||
case "LTE":
|
||||
case "<=":
|
||||
return parseFloat(actual) <= parseFloat(expected);
|
||||
|
||||
case "IS_NULL":
|
||||
case "NULL":
|
||||
return actual === "" || actual === "null" || actual === "undefined";
|
||||
|
||||
case "IS_NOT_NULL":
|
||||
case "NOT_NULL":
|
||||
return actual !== "" && actual !== "null" && actual !== "undefined";
|
||||
|
||||
default:
|
||||
logger.warn(`알 수 없는 연산자: ${operator}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user