563 lines
15 KiB
TypeScript
563 lines
15 KiB
TypeScript
/**
|
|
* 조건부 연쇄 (Conditional Cascading) 컨트롤러
|
|
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
|
*/
|
|
|
|
import { Response } from "express";
|
|
import { AuthenticatedRequest } from "../types/auth";
|
|
import { query, queryOne } from "../database/db";
|
|
import logger from "../utils/logger";
|
|
|
|
// =====================================================
|
|
// 조건부 연쇄 규칙 CRUD
|
|
// =====================================================
|
|
|
|
/**
|
|
* 조건부 연쇄 규칙 목록 조회
|
|
*/
|
|
export const getConditions = async (
|
|
req: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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;
|
|
}
|
|
}
|