연쇄 통합관리

This commit is contained in:
kjs
2025-12-10 15:59:04 +09:00
parent c71b958a05
commit 08575c296e
25 changed files with 8136 additions and 872 deletions

View File

@@ -1256,8 +1256,17 @@ export async function updateMenu(
}
}
const requestCompanyCode =
menuData.companyCode || menuData.company_code || currentMenu.company_code;
let requestCompanyCode =
menuData.companyCode || menuData.company_code;
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
if (
requestCompanyCode === "none" ||
requestCompanyCode === "" ||
!requestCompanyCode
) {
requestCompanyCode = currentMenu.company_code;
}
// company_code 변경 시도하는 경우 권한 체크
if (requestCompanyCode !== currentMenu.company_code) {

View File

@@ -0,0 +1,568 @@
/**
* 자동 입력 (Auto-Fill) 컨트롤러
* 마스터 선택 시 여러 필드 자동 입력 기능
*/
import { Request, Response } from "express";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 자동 입력 그룹 CRUD
// =====================================================
/**
* 자동 입력 그룹 목록 조회
*/
export const getAutoFillGroups = async (req: Request, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let sql = `
SELECT
g.*,
COUNT(m.mapping_id) as mapping_count
FROM cascading_auto_fill_group g
LEFT JOIN cascading_auto_fill_mapping m
ON g.group_code = m.group_code AND g.company_code = m.company_code
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND g.company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND g.is_active = $${paramIndex++}`;
params.push(isActive);
}
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
const result = await query(sql, params);
logger.info("자동 입력 그룹 목록 조회", { count: result.length, 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 getAutoFillGroupDetail = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `
SELECT * FROM cascading_auto_fill_group
WHERE group_code = $1
`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const groupResult = await queryOne(groupSql, groupParams);
if (!groupResult) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order, mapping_id
`;
const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]);
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
res.json({
success: true,
data: {
...groupResult,
mappings: mappingResult,
},
});
} catch (error: any) {
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* 그룹 코드 자동 생성 함수
*/
const generateAutoFillGroupCode = async (companyCode: string): Promise<string> => {
const prefix = "AF";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
* 자동 입력 그룹 생성
*/
export const createAutoFillGroup = async (req: Request, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
mappings = [],
} = req.body;
// 필수 필드 검증
if (!groupName || !masterTable || !masterValueColumn) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
});
}
// 그룹 코드 자동 생성
const groupCode = await generateAutoFillGroupCode(companyCode);
// 그룹 생성
const insertGroupSql = `
INSERT INTO cascading_auto_fill_group (
group_code, group_name, description,
master_table, master_value_column, master_label_column,
company_code, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const groupResult = await queryOne(insertGroupSql, [
groupCode,
groupName,
description || null,
masterTable,
masterValueColumn,
masterLabelColumn || null,
companyCode,
]);
// 매핑 생성
if (mappings.length > 0) {
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`INSERT INTO cascading_auto_fill_mapping (
group_code, company_code, source_column, target_field, target_label,
is_editable, is_required, default_value, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
companyCode,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
res.status(201).json({
success: true,
message: "자동 입력 그룹이 생성되었습니다.",
data: groupResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
* 자동 입력 그룹 수정
*/
export const updateAutoFillGroup = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
mappings,
} = req.body;
// 기존 그룹 확인
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
const checkParams: any[] = [groupCode];
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_auto_fill_group SET
group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
master_table = COALESCE($3, master_table),
master_value_column = COALESCE($4, master_value_column),
master_label_column = COALESCE($5, master_label_column),
is_active = COALESCE($6, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE group_code = $7 AND company_code = $8
RETURNING *
`;
const updateResult = await queryOne(updateSql, [
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
groupCode,
existing.company_code,
]);
// 매핑 업데이트 (전체 교체 방식)
if (mappings !== undefined) {
// 기존 매핑 삭제
await query(
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
[groupCode, existing.company_code]
);
// 새 매핑 추가
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`INSERT INTO cascading_auto_fill_mapping (
group_code, company_code, source_column, target_field, target_label,
is_editable, is_required, default_value, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
existing.company_code,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 수정되었습니다.",
data: updateResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
* 자동 입력 그룹 삭제
*/
export const deleteAutoFillGroup = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
const deleteParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING group_code`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 자동 입력 데이터 조회 (실제 사용)
// =====================================================
/**
* 마스터 옵션 목록 조회
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
*/
export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 마스터 테이블에서 옵션 조회
const labelColumn = group.master_label_column || group.master_value_column;
let optionsSql = `
SELECT
${group.master_value_column} as value,
${labelColumn} as label
FROM ${group.master_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
if (companyCode !== "*") {
// company_code 컬럼 존재 여부 확인
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${paramIndex++}`;
optionsParams.push(companyCode);
}
}
optionsSql += ` ORDER BY ${labelColumn}`;
const optionsResult = await query(optionsSql, optionsParams);
logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length });
res.json({
success: true,
data: optionsResult,
});
} catch (error: any) {
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* 자동 입력 데이터 조회
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
*/
export const getAutoFillData = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const { masterValue } = req.query;
const companyCode = req.user?.companyCode || "*";
if (!masterValue) {
return res.status(400).json({
success: false,
message: "masterValue 파라미터가 필요합니다.",
});
}
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order
`;
const mappings = await query(mappingSql, [groupCode, group.company_code]);
if (mappings.length === 0) {
return res.json({
success: true,
data: {},
mappings: [],
});
}
// 마스터 테이블에서 데이터 조회
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
let dataSql = `
SELECT ${sourceColumns}
FROM ${group.master_table}
WHERE ${group.master_value_column} = $1
`;
const dataParams: any[] = [masterValue];
let paramIndex = 2;
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
dataSql += ` AND company_code = $${paramIndex++}`;
dataParams.push(companyCode);
}
}
const dataResult = await queryOne(dataSql, dataParams);
// 결과를 target_field 기준으로 변환
const autoFillData: Record<string, any> = {};
const mappingInfo: any[] = [];
for (const mapping of mappings) {
const sourceValue = dataResult?.[mapping.source_column];
const finalValue = sourceValue !== null && sourceValue !== undefined
? sourceValue
: mapping.default_value;
autoFillData[mapping.target_field] = finalValue;
mappingInfo.push({
targetField: mapping.target_field,
targetLabel: mapping.target_label,
value: finalValue,
isEditable: mapping.is_editable === "Y",
isRequired: mapping.is_required === "Y",
});
}
logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length });
res.json({
success: true,
data: autoFillData,
mappings: mappingInfo,
});
} catch (error: any) {
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 데이터 조회에 실패했습니다.",
error: error.message,
});
}
};

View 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;
}
}

View File

@@ -0,0 +1,752 @@
/**
* 다단계 계층 (Hierarchy) 컨트롤러
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
*/
import { Request, Response } from "express";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 계층 그룹 CRUD
// =====================================================
/**
* 계층 그룹 목록 조회
*/
export const getHierarchyGroups = async (req: Request, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, hierarchyType } = req.query;
let sql = `
SELECT g.*,
(SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count
FROM cascading_hierarchy_group g
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*") {
sql += ` AND g.company_code = $${paramIndex++}`;
params.push(companyCode);
}
if (isActive) {
sql += ` AND g.is_active = $${paramIndex++}`;
params.push(isActive);
}
if (hierarchyType) {
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
params.push(hierarchyType);
}
sql += ` ORDER BY g.group_name`;
const result = await query(sql, params);
logger.info("계층 그룹 목록 조회", { count: result.length, 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 getHierarchyGroupDetail = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 조회
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
// 레벨 조회
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
const levelParams: any[] = [groupCode];
if (companyCode !== "*") {
levelSql += ` AND company_code = $2`;
levelParams.push(companyCode);
}
levelSql += ` ORDER BY level_order`;
const levels = await query(levelSql, levelParams);
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
res.json({
success: true,
data: {
...group,
levels: levels,
},
});
} catch (error: any) {
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* 계층 그룹 코드 자동 생성 함수
*/
const generateHierarchyGroupCode = async (companyCode: string): Promise<string> => {
const prefix = "HG";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
* 계층 그룹 생성
*/
export const createHierarchyGroup = async (req: Request, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
hierarchyType = "MULTI_TABLE",
maxLevels,
isFixedLevels = "Y",
// Self-reference 설정
selfRefTable,
selfRefIdColumn,
selfRefParentColumn,
selfRefValueColumn,
selfRefLabelColumn,
selfRefLevelColumn,
selfRefOrderColumn,
// BOM 설정
bomTable,
bomParentColumn,
bomChildColumn,
bomItemTable,
bomItemIdColumn,
bomItemLabelColumn,
bomQtyColumn,
bomLevelColumn,
// 메시지
emptyMessage,
noOptionsMessage,
loadingMessage,
// 레벨 (MULTI_TABLE 타입인 경우)
levels = [],
} = req.body;
// 필수 필드 검증
if (!groupName || !hierarchyType) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
});
}
// 그룹 코드 자동 생성
const groupCode = await generateHierarchyGroupCode(companyCode);
// 그룹 생성
const insertGroupSql = `
INSERT INTO cascading_hierarchy_group (
group_code, group_name, description, hierarchy_type,
max_levels, is_fixed_levels,
self_ref_table, self_ref_id_column, self_ref_parent_column,
self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column,
bom_table, bom_parent_column, bom_child_column,
bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column,
empty_message, no_options_message, loading_message,
company_code, is_active, created_by, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
RETURNING *
`;
const group = await queryOne(insertGroupSql, [
groupCode,
groupName,
description || null,
hierarchyType,
maxLevels || null,
isFixedLevels,
selfRefTable || null,
selfRefIdColumn || null,
selfRefParentColumn || null,
selfRefValueColumn || null,
selfRefLabelColumn || null,
selfRefLevelColumn || null,
selfRefOrderColumn || null,
bomTable || null,
bomParentColumn || null,
bomChildColumn || null,
bomItemTable || null,
bomItemIdColumn || null,
bomItemLabelColumn || null,
bomQtyColumn || null,
bomLevelColumn || null,
emptyMessage || "선택해주세요",
noOptionsMessage || "옵션이 없습니다",
loadingMessage || "로딩 중...",
companyCode,
userId,
]);
// 레벨 생성 (MULTI_TABLE 타입인 경우)
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
for (const level of levels) {
await query(
`INSERT INTO cascading_hierarchy_level (
group_code, company_code, level_order, level_name, level_code,
table_name, value_column, label_column, parent_key_column,
filter_column, filter_value, order_column, order_direction,
placeholder, is_required, is_searchable, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
[
groupCode,
companyCode,
level.levelOrder,
level.levelName,
level.levelCode || null,
level.tableName,
level.valueColumn,
level.labelColumn,
level.parentKeyColumn || null,
level.filterColumn || null,
level.filterValue || null,
level.orderColumn || null,
level.orderDirection || "ASC",
level.placeholder || `${level.levelName} 선택`,
level.isRequired || "Y",
level.isSearchable || "N",
]
);
}
}
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
res.status(201).json({
success: true,
message: "계층 그룹이 생성되었습니다.",
data: group,
});
} catch (error: any) {
logger.error("계층 그룹 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
* 계층 그룹 수정
*/
export const updateHierarchyGroup = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
maxLevels,
isFixedLevels,
emptyMessage,
noOptionsMessage,
loadingMessage,
isActive,
} = req.body;
// 기존 그룹 확인
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
const checkParams: any[] = [groupCode];
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_hierarchy_group SET
group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
max_levels = COALESCE($3, max_levels),
is_fixed_levels = COALESCE($4, is_fixed_levels),
empty_message = COALESCE($5, empty_message),
no_options_message = COALESCE($6, no_options_message),
loading_message = COALESCE($7, loading_message),
is_active = COALESCE($8, is_active),
updated_by = $9,
updated_date = CURRENT_TIMESTAMP
WHERE group_code = $10 AND company_code = $11
RETURNING *
`;
const result = await queryOne(updateSql, [
groupName,
description,
maxLevels,
isFixedLevels,
emptyMessage,
noOptionsMessage,
loadingMessage,
isActive,
userId,
groupCode,
existing.company_code,
]);
logger.info("계층 그룹 수정", { groupCode, 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 deleteHierarchyGroup = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 레벨 먼저 삭제
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
const levelParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteLevelsSql += ` AND company_code = $2`;
levelParams.push(companyCode);
}
await query(deleteLevelsSql, levelParams);
// 그룹 삭제
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteGroupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
deleteGroupSql += ` RETURNING group_code`;
const result = await queryOne(deleteGroupSql, groupParams);
if (!result) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
logger.info("계층 그룹 삭제", { groupCode, companyCode });
res.json({
success: true,
message: "계층 그룹이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("계층 그룹 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 계층 레벨 관리
// =====================================================
/**
* 레벨 추가
*/
export const addLevel = async (req: Request, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
levelOrder,
levelName,
levelCode,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection = "ASC",
placeholder,
isRequired = "Y",
isSearchable = "N",
} = req.body;
// 그룹 존재 확인
const groupCheck = await queryOne(
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
[groupCode, companyCode]
);
if (!groupCheck) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
const insertSql = `
INSERT INTO cascading_hierarchy_level (
group_code, company_code, level_order, level_name, level_code,
table_name, value_column, label_column, parent_key_column,
filter_column, filter_value, order_column, order_direction,
placeholder, is_required, is_searchable, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
groupCode,
groupCheck.company_code,
levelOrder,
levelName,
levelCode || null,
tableName,
valueColumn,
labelColumn,
parentKeyColumn || null,
filterColumn || null,
filterValue || null,
orderColumn || null,
orderDirection,
placeholder || `${levelName} 선택`,
isRequired,
isSearchable,
]);
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
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 updateLevel = async (req: Request, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
levelName,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection,
placeholder,
isRequired,
isSearchable,
isActive,
} = req.body;
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
const checkParams: any[] = [Number(levelId)];
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_hierarchy_level SET
level_name = COALESCE($1, level_name),
table_name = COALESCE($2, table_name),
value_column = COALESCE($3, value_column),
label_column = COALESCE($4, label_column),
parent_key_column = COALESCE($5, parent_key_column),
filter_column = COALESCE($6, filter_column),
filter_value = COALESCE($7, filter_value),
order_column = COALESCE($8, order_column),
order_direction = COALESCE($9, order_direction),
placeholder = COALESCE($10, placeholder),
is_required = COALESCE($11, is_required),
is_searchable = COALESCE($12, is_searchable),
is_active = COALESCE($13, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE level_id = $14
RETURNING *
`;
const result = await queryOne(updateSql, [
levelName,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection,
placeholder,
isRequired,
isSearchable,
isActive,
Number(levelId),
]);
logger.info("계층 레벨 수정", { levelId });
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 deleteLevel = async (req: Request, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
const deleteParams: any[] = [Number(levelId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING level_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
logger.info("계층 레벨 삭제", { levelId });
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 getLevelOptions = async (req: Request, res: Response) => {
try {
const { groupCode, levelOrder } = req.params;
const { parentValue } = req.query;
const companyCode = req.user?.companyCode || "*";
// 레벨 정보 조회
let levelSql = `
SELECT l.*, g.hierarchy_type
FROM cascading_hierarchy_level l
JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code
WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y'
`;
const levelParams: any[] = [groupCode, Number(levelOrder)];
if (companyCode !== "*") {
levelSql += ` AND l.company_code = $3`;
levelParams.push(companyCode);
}
const level = await queryOne(levelSql, levelParams);
if (!level) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
// 옵션 조회
let optionsSql = `
SELECT
${level.value_column} as value,
${level.label_column} as label
FROM ${level.table_name}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 부모 값 필터 (레벨 2 이상)
if (level.parent_key_column && parentValue) {
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
optionsParams.push(parentValue);
}
// 고정 필터
if (level.filter_column && level.filter_value) {
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
optionsParams.push(level.filter_value);
}
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[level.table_name]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 정렬
if (level.order_column) {
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
} else {
optionsSql += ` ORDER BY ${level.label_column}`;
}
const optionsResult = await query(optionsSql, optionsParams);
logger.info("계층 레벨 옵션 조회", {
groupCode,
levelOrder,
parentValue,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
levelInfo: {
levelId: level.level_id,
levelName: level.level_name,
placeholder: level.placeholder,
isRequired: level.is_required,
isSearchable: level.is_searchable,
},
});
} catch (error: any) {
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "옵션 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@@ -0,0 +1,505 @@
/**
* 상호 배제 (Mutual Exclusion) 컨트롤러
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
*/
import { Request, Response } from "express";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 상호 배제 규칙 CRUD
// =====================================================
/**
* 상호 배제 규칙 목록 조회
*/
export const getExclusions = async (req: Request, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let sql = `
SELECT * FROM cascading_mutual_exclusion
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);
}
sql += ` ORDER BY exclusion_name`;
const result = await query(sql, params);
logger.info("상호 배제 규칙 목록 조회", { count: result.length, 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 getExclusionDetail = async (req: Request, res: Response) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const params: any[] = [Number(exclusionId)];
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("상호 배제 규칙 상세 조회", { exclusionId, 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,
});
}
};
/**
* 배제 코드 자동 생성 함수
*/
const generateExclusionCode = async (companyCode: string): Promise<string> => {
const prefix = "EX";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
* 상호 배제 규칙 생성
*/
export const createExclusion = async (req: Request, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
exclusionName,
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
sourceTable,
valueColumn,
labelColumn,
exclusionType = "SAME_VALUE",
errorMessage = "동일한 값을 선택할 수 없습니다",
} = req.body;
// 필수 필드 검증
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
});
}
// 배제 코드 자동 생성
const exclusionCode = await generateExclusionCode(companyCode);
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
const existingCheck = await queryOne(
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
[exclusionCode, companyCode]
);
if (existingCheck) {
return res.status(409).json({
success: false,
message: "이미 존재하는 배제 코드입니다.",
});
}
const insertSql = `
INSERT INTO cascading_mutual_exclusion (
exclusion_code, exclusion_name, field_names,
source_table, value_column, label_column,
exclusion_type, error_message,
company_code, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
exclusionCode,
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn || null,
exclusionType,
errorMessage,
companyCode,
]);
logger.info("상호 배제 규칙 생성", { exclusionCode, 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 updateExclusion = async (req: Request, res: Response) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn,
exclusionType,
errorMessage,
isActive,
} = req.body;
// 기존 규칙 확인
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const checkParams: any[] = [Number(exclusionId)];
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_mutual_exclusion SET
exclusion_name = COALESCE($1, exclusion_name),
field_names = COALESCE($2, field_names),
source_table = COALESCE($3, source_table),
value_column = COALESCE($4, value_column),
label_column = COALESCE($5, label_column),
exclusion_type = COALESCE($6, exclusion_type),
error_message = COALESCE($7, error_message),
is_active = COALESCE($8, is_active)
WHERE exclusion_id = $9
RETURNING *
`;
const result = await queryOne(updateSql, [
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn,
exclusionType,
errorMessage,
isActive,
Number(exclusionId),
]);
logger.info("상호 배제 규칙 수정", { exclusionId, 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 deleteExclusion = async (req: Request, res: Response) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const deleteParams: any[] = [Number(exclusionId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING exclusion_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
logger.info("상호 배제 규칙 삭제", { exclusionId, 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 validateExclusion = async (req: Request, res: Response) => {
try {
const { exclusionCode } = req.params;
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
const companyCode = req.user?.companyCode || "*";
// 배제 규칙 조회
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
const exclusionParams: any[] = [exclusionCode];
if (companyCode !== "*") {
exclusionSql += ` AND company_code = $2`;
exclusionParams.push(companyCode);
}
const exclusion = await queryOne(exclusionSql, exclusionParams);
if (!exclusion) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
// 필드명 파싱
const fields = exclusion.field_names.split(",").map((f: string) => f.trim());
// 필드 값 수집
const values: string[] = [];
for (const field of fields) {
if (fieldValues[field]) {
values.push(fieldValues[field]);
}
}
// 상호 배제 검증
let isValid = true;
let errorMessage = null;
let conflictingFields: string[] = [];
if (exclusion.exclusion_type === "SAME_VALUE") {
// 같은 값이 있는지 확인
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
isValid = false;
errorMessage = exclusion.error_message;
// 충돌하는 필드 찾기
const valueCounts: Record<string, string[]> = {};
for (const field of fields) {
const val = fieldValues[field];
if (val) {
if (!valueCounts[val]) {
valueCounts[val] = [];
}
valueCounts[val].push(field);
}
}
for (const [, fieldList] of Object.entries(valueCounts)) {
if (fieldList.length > 1) {
conflictingFields = fieldList;
break;
}
}
}
}
logger.info("상호 배제 검증", {
exclusionCode,
isValid,
fieldValues,
});
res.json({
success: true,
data: {
isValid,
errorMessage: isValid ? null : errorMessage,
conflictingFields,
},
});
} catch (error: any) {
logger.error("상호 배제 검증 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 검증에 실패했습니다.",
error: error.message,
});
}
};
/**
* 필드에 대한 배제 옵션 조회
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
*/
export const getExcludedOptions = async (req: Request, res: Response) => {
try {
const { exclusionCode } = req.params;
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
const companyCode = req.user?.companyCode || "*";
// 배제 규칙 조회
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
const exclusionParams: any[] = [exclusionCode];
if (companyCode !== "*") {
exclusionSql += ` AND company_code = $2`;
exclusionParams.push(companyCode);
}
const exclusion = await queryOne(exclusionSql, exclusionParams);
if (!exclusion) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
// 옵션 조회
const labelColumn = exclusion.label_column || exclusion.value_column;
let optionsSql = `
SELECT
${exclusion.value_column} as value,
${labelColumn} as label
FROM ${exclusion.source_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[exclusion.source_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 이미 선택된 값 제외
if (selectedValues) {
const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v);
if (excludeValues.length > 0) {
const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(",");
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
optionsParams.push(...excludeValues);
}
}
optionsSql += ` ORDER BY ${labelColumn}`;
const optionsResult = await query(optionsSql, optionsParams);
logger.info("상호 배제 옵션 조회", {
exclusionCode,
currentField,
excludedCount: (selectedValues as string)?.split(",").length || 0,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
});
} catch (error: any) {
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};