538 lines
14 KiB
TypeScript
538 lines
14 KiB
TypeScript
/**
|
|
* 상호 배제 (Mutual Exclusion) 컨트롤러
|
|
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
|
*/
|
|
|
|
import { Response } from "express";
|
|
import { AuthenticatedRequest } from "../types/auth";
|
|
import { query, queryOne } from "../database/db";
|
|
import logger from "../utils/logger";
|
|
|
|
// =====================================================
|
|
// 상호 배제 규칙 CRUD
|
|
// =====================================================
|
|
|
|
/**
|
|
* 상호 배제 규칙 목록 조회
|
|
*/
|
|
export const getExclusions = async (
|
|
req: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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: AuthenticatedRequest,
|
|
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,
|
|
});
|
|
}
|
|
};
|