diff --git a/backend-node/src/controllers/cascadingAutoFillController.ts b/backend-node/src/controllers/cascadingAutoFillController.ts index bf033880..4a2fa61f 100644 --- a/backend-node/src/controllers/cascadingAutoFillController.ts +++ b/backend-node/src/controllers/cascadingAutoFillController.ts @@ -3,7 +3,8 @@ * 마스터 선택 시 여러 필드 자동 입력 기능 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 자동 입력 그룹 목록 조회 */ -export const getAutoFillGroups = async (req: Request, res: Response) => { +export const getAutoFillGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; @@ -47,7 +51,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => { const result = await query(sql, params); - logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode }); + logger.info("자동 입력 그룹 목록 조회", { + count: result.length, + companyCode, + }); res.json({ success: true, @@ -66,7 +73,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => { /** * 자동 입력 그룹 상세 조회 (매핑 포함) */ -export const getAutoFillGroupDetail = async (req: Request, res: Response) => { +export const getAutoFillGroupDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -98,7 +108,10 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => { WHERE group_code = $1 AND company_code = $2 ORDER BY sort_order, mapping_id `; - const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]); + const mappingResult = await query(mappingSql, [ + groupCode, + groupResult.company_code, + ]); logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode }); @@ -122,7 +135,9 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => { /** * 그룹 코드 자동 생성 함수 */ -const generateAutoFillGroupCode = async (companyCode: string): Promise => { +const generateAutoFillGroupCode = async ( + companyCode: string +): Promise => { const prefix = "AF"; const result = await queryOne( `SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`, @@ -136,7 +151,10 @@ const generateAutoFillGroupCode = async (companyCode: string): Promise = /** * 자동 입력 그룹 생성 */ -export const createAutoFillGroup = async (req: Request, res: Response) => { +export const createAutoFillGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; @@ -153,7 +171,8 @@ export const createAutoFillGroup = async (req: Request, res: Response) => { if (!groupName || !masterTable || !masterValueColumn) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)", + message: + "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)", }); } @@ -224,7 +243,10 @@ export const createAutoFillGroup = async (req: Request, res: Response) => { /** * 자동 입력 그룹 수정 */ -export const updateAutoFillGroup = async (req: Request, res: Response) => { +export const updateAutoFillGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -333,7 +355,10 @@ export const updateAutoFillGroup = async (req: Request, res: Response) => { /** * 자동 입력 그룹 삭제 */ -export const deleteAutoFillGroup = async (req: Request, res: Response) => { +export const deleteAutoFillGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -382,7 +407,10 @@ export const deleteAutoFillGroup = async (req: Request, res: Response) => { * 마스터 옵션 목록 조회 * 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록 */ -export const getAutoFillMasterOptions = async (req: Request, res: Response) => { +export const getAutoFillMasterOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -436,7 +464,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => { const optionsResult = await query(optionsSql, optionsParams); - logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length }); + logger.info("자동 입력 마스터 옵션 조회", { + groupCode, + count: optionsResult.length, + }); res.json({ success: true, @@ -456,7 +487,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => { * 자동 입력 데이터 조회 * 마스터 값 선택 시 자동으로 입력할 데이터 조회 */ -export const getAutoFillData = async (req: Request, res: Response) => { +export const getAutoFillData = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const { masterValue } = req.query; @@ -535,9 +569,10 @@ export const getAutoFillData = async (req: Request, res: Response) => { for (const mapping of mappings) { const sourceValue = dataResult?.[mapping.source_column]; - const finalValue = sourceValue !== null && sourceValue !== undefined - ? sourceValue - : mapping.default_value; + const finalValue = + sourceValue !== null && sourceValue !== undefined + ? sourceValue + : mapping.default_value; autoFillData[mapping.target_field] = finalValue; mappingInfo.push({ @@ -549,7 +584,11 @@ export const getAutoFillData = async (req: Request, res: Response) => { }); } - logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length }); + logger.info("자동 입력 데이터 조회", { + groupCode, + masterValue, + fieldCount: mappingInfo.length, + }); res.json({ success: true, @@ -565,4 +604,3 @@ export const getAutoFillData = async (req: Request, res: Response) => { }); } }; - diff --git a/backend-node/src/controllers/cascadingConditionController.ts b/backend-node/src/controllers/cascadingConditionController.ts index cf30a725..6cc89319 100644 --- a/backend-node/src/controllers/cascadingConditionController.ts +++ b/backend-node/src/controllers/cascadingConditionController.ts @@ -3,7 +3,8 @@ * 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 조건부 연쇄 규칙 목록 조회 */ -export const getConditions = async (req: Request, res: Response) => { +export const getConditions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive, relationCode, relationType } = req.query; @@ -54,7 +58,10 @@ export const getConditions = async (req: Request, res: Response) => { const result = await query(sql, params); - logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode }); + logger.info("조건부 연쇄 규칙 목록 조회", { + count: result.length, + companyCode, + }); res.json({ success: true, @@ -62,7 +69,7 @@ export const getConditions = async (req: Request, res: Response) => { }); } catch (error: any) { console.error("조건부 연쇄 규칙 목록 조회 실패:", error); - logger.error("조건부 연쇄 규칙 목록 조회 실패", { + logger.error("조건부 연쇄 규칙 목록 조회 실패", { error: error.message, stack: error.stack, }); @@ -77,7 +84,10 @@ export const getConditions = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 상세 조회 */ -export const getConditionDetail = async (req: Request, res: Response) => { +export const getConditionDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { conditionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -118,7 +128,10 @@ export const getConditionDetail = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 생성 */ -export const createCondition = async (req: Request, res: Response) => { +export const createCondition = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { @@ -134,10 +147,18 @@ export const createCondition = async (req: Request, res: Response) => { } = req.body; // 필수 필드 검증 - if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) { + if ( + !relationCode || + !conditionName || + !conditionField || + !conditionValue || + !filterColumn || + !filterValues + ) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)", + message: + "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)", }); } @@ -164,7 +185,11 @@ export const createCondition = async (req: Request, res: Response) => { companyCode, ]); - logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode }); + logger.info("조건부 연쇄 규칙 생성", { + conditionId: result?.condition_id, + relationCode, + companyCode, + }); res.status(201).json({ success: true, @@ -184,7 +209,10 @@ export const createCondition = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 수정 */ -export const updateCondition = async (req: Request, res: Response) => { +export const updateCondition = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { conditionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -264,7 +292,10 @@ export const updateCondition = async (req: Request, res: Response) => { /** * 조건부 연쇄 규칙 삭제 */ -export const deleteCondition = async (req: Request, res: Response) => { +export const deleteCondition = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { conditionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -312,7 +343,10 @@ export const deleteCondition = async (req: Request, res: Response) => { * 조건에 따른 필터링된 옵션 조회 * 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환 */ -export const getFilteredOptions = async (req: Request, res: Response) => { +export const getFilteredOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { relationCode } = req.params; const { conditionFieldValue, parentValue } = req.query; @@ -390,8 +424,12 @@ export const getFilteredOptions = async (req: Request, res: Response) => { // 조건부 필터 적용 if (matchedCondition) { - const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim()); - const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(","); + 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; @@ -522,4 +560,3 @@ function evaluateCondition( return false; } } - diff --git a/backend-node/src/controllers/cascadingHierarchyController.ts b/backend-node/src/controllers/cascadingHierarchyController.ts index 59d243e2..e57efa09 100644 --- a/backend-node/src/controllers/cascadingHierarchyController.ts +++ b/backend-node/src/controllers/cascadingHierarchyController.ts @@ -3,7 +3,8 @@ * 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 계층 그룹 목록 조회 */ -export const getHierarchyGroups = async (req: Request, res: Response) => { +export const getHierarchyGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive, hierarchyType } = req.query; @@ -66,7 +70,10 @@ export const getHierarchyGroups = async (req: Request, res: Response) => { /** * 계층 그룹 상세 조회 (레벨 포함) */ -export const getHierarchyGroupDetail = async (req: Request, res: Response) => { +export const getHierarchyGroupDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -124,7 +131,9 @@ export const getHierarchyGroupDetail = async (req: Request, res: Response) => { /** * 계층 그룹 코드 자동 생성 함수 */ -const generateHierarchyGroupCode = async (companyCode: string): Promise => { +const generateHierarchyGroupCode = async ( + companyCode: string +): Promise => { const prefix = "HG"; const result = await queryOne( `SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`, @@ -138,7 +147,10 @@ const generateHierarchyGroupCode = async (companyCode: string): Promise /** * 계층 그룹 생성 */ -export const createHierarchyGroup = async (req: Request, res: Response) => { +export const createHierarchyGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; @@ -280,7 +292,10 @@ export const createHierarchyGroup = async (req: Request, res: Response) => { /** * 계층 그룹 수정 */ -export const updateHierarchyGroup = async (req: Request, res: Response) => { +export const updateHierarchyGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -364,7 +379,10 @@ export const updateHierarchyGroup = async (req: Request, res: Response) => { /** * 계층 그룹 삭제 */ -export const deleteHierarchyGroup = async (req: Request, res: Response) => { +export const deleteHierarchyGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -423,7 +441,7 @@ export const deleteHierarchyGroup = async (req: Request, res: Response) => { /** * 레벨 추가 */ -export const addLevel = async (req: Request, res: Response) => { +export const addLevel = async (req: AuthenticatedRequest, res: Response) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -506,7 +524,7 @@ export const addLevel = async (req: Request, res: Response) => { /** * 레벨 수정 */ -export const updateLevel = async (req: Request, res: Response) => { +export const updateLevel = async (req: AuthenticatedRequest, res: Response) => { try { const { levelId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -600,7 +618,7 @@ export const updateLevel = async (req: Request, res: Response) => { /** * 레벨 삭제 */ -export const deleteLevel = async (req: Request, res: Response) => { +export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => { try { const { levelId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -647,7 +665,10 @@ export const deleteLevel = async (req: Request, res: Response) => { /** * 특정 레벨의 옵션 조회 */ -export const getLevelOptions = async (req: Request, res: Response) => { +export const getLevelOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { groupCode, levelOrder } = req.params; const { parentValue } = req.query; @@ -749,4 +770,3 @@ export const getLevelOptions = async (req: Request, res: Response) => { }); } }; - diff --git a/backend-node/src/controllers/cascadingMutualExclusionController.ts b/backend-node/src/controllers/cascadingMutualExclusionController.ts index 8714c73b..b1cbeaa6 100644 --- a/backend-node/src/controllers/cascadingMutualExclusionController.ts +++ b/backend-node/src/controllers/cascadingMutualExclusionController.ts @@ -3,7 +3,8 @@ * 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능 */ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; @@ -14,7 +15,10 @@ import logger from "../utils/logger"; /** * 상호 배제 규칙 목록 조회 */ -export const getExclusions = async (req: Request, res: Response) => { +export const getExclusions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; @@ -42,7 +46,10 @@ export const getExclusions = async (req: Request, res: Response) => { const result = await query(sql, params); - logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode }); + logger.info("상호 배제 규칙 목록 조회", { + count: result.length, + companyCode, + }); res.json({ success: true, @@ -61,7 +68,10 @@ export const getExclusions = async (req: Request, res: Response) => { /** * 상호 배제 규칙 상세 조회 */ -export const getExclusionDetail = async (req: Request, res: Response) => { +export const getExclusionDetail = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -116,7 +126,10 @@ const generateExclusionCode = async (companyCode: string): Promise => { /** * 상호 배제 규칙 생성 */ -export const createExclusion = async (req: Request, res: Response) => { +export const createExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { @@ -133,7 +146,8 @@ export const createExclusion = async (req: Request, res: Response) => { if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)", + message: + "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)", }); } @@ -195,7 +209,10 @@ export const createExclusion = async (req: Request, res: Response) => { /** * 상호 배제 규칙 수정 */ -export const updateExclusion = async (req: Request, res: Response) => { +export const updateExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -274,7 +291,10 @@ export const updateExclusion = async (req: Request, res: Response) => { /** * 상호 배제 규칙 삭제 */ -export const deleteExclusion = async (req: Request, res: Response) => { +export const deleteExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionId } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -322,7 +342,10 @@ export const deleteExclusion = async (req: Request, res: Response) => { * 상호 배제 검증 * 선택하려는 값이 다른 필드와 충돌하는지 확인 */ -export const validateExclusion = async (req: Request, res: Response) => { +export const validateExclusion = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionCode } = req.params; const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" } @@ -347,7 +370,9 @@ export const validateExclusion = async (req: Request, res: Response) => { } // 필드명 파싱 - const fields = exclusion.field_names.split(",").map((f: string) => f.trim()); + const fields = exclusion.field_names + .split(",") + .map((f: string) => f.trim()); // 필드 값 수집 const values: string[] = []; @@ -418,7 +443,10 @@ export const validateExclusion = async (req: Request, res: Response) => { * 필드에 대한 배제 옵션 조회 * 다른 필드에서 이미 선택한 값을 제외한 옵션 반환 */ -export const getExcludedOptions = async (req: Request, res: Response) => { +export const getExcludedOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { exclusionCode } = req.params; const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분) @@ -470,9 +498,14 @@ export const getExcludedOptions = async (req: Request, res: Response) => { // 이미 선택된 값 제외 if (selectedValues) { - const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v); + 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(","); + const placeholders = excludeValues + .map((_, i) => `$${optionsParamIndex + i}`) + .join(","); optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`; optionsParams.push(...excludeValues); } @@ -502,4 +535,3 @@ export const getExcludedOptions = async (req: Request, res: Response) => { }); } }; - diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts index 3f7b5cb6..27f03c71 100644 --- a/backend-node/src/controllers/cascadingRelationController.ts +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -1,4 +1,5 @@ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; @@ -7,7 +8,10 @@ const pool = getPool(); /** * 연쇄 관계 목록 조회 */ -export const getCascadingRelations = async (req: Request, res: Response) => { +export const getCascadingRelations = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; @@ -86,7 +90,10 @@ export const getCascadingRelations = async (req: Request, res: Response) => { /** * 연쇄 관계 상세 조회 */ -export const getCascadingRelationById = async (req: Request, res: Response) => { +export const getCascadingRelationById = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -155,7 +162,7 @@ export const getCascadingRelationById = async (req: Request, res: Response) => { * 연쇄 관계 코드로 조회 */ export const getCascadingRelationByCode = async ( - req: Request, + req: AuthenticatedRequest, res: Response ) => { try { @@ -223,7 +230,10 @@ export const getCascadingRelationByCode = async ( /** * 연쇄 관계 생성 */ -export const createCascadingRelation = async (req: Request, res: Response) => { +export const createCascadingRelation = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; @@ -350,7 +360,10 @@ export const createCascadingRelation = async (req: Request, res: Response) => { /** * 연쇄 관계 수정 */ -export const updateCascadingRelation = async (req: Request, res: Response) => { +export const updateCascadingRelation = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -474,7 +487,10 @@ export const updateCascadingRelation = async (req: Request, res: Response) => { /** * 연쇄 관계 삭제 */ -export const deleteCascadingRelation = async (req: Request, res: Response) => { +export const deleteCascadingRelation = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -536,7 +552,10 @@ export const deleteCascadingRelation = async (req: Request, res: Response) => { * 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용) * parent_table에서 전체 옵션을 조회합니다. */ -export const getParentOptions = async (req: Request, res: Response) => { +export const getParentOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { code } = req.params; const companyCode = req.user?.companyCode || "*"; @@ -644,7 +663,10 @@ export const getParentOptions = async (req: Request, res: Response) => { * 연쇄 관계로 자식 옵션 조회 * 실제 연쇄 드롭다운에서 사용하는 API */ -export const getCascadingOptions = async (req: Request, res: Response) => { +export const getCascadingOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { code } = req.params; const { parentValue } = req.query; diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts index 80cb8ccd..e80a44dc 100644 --- a/backend-node/src/controllers/digitalTwinDataController.ts +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -1,43 +1,25 @@ import { Request, Response } from "express"; -import { pool, queryOne } from "../database/db"; import logger from "../utils/logger"; -import { PasswordEncryption } from "../utils/passwordEncryption"; -import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; +import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService"; -// 외부 DB 커넥터를 가져오는 헬퍼 함수 +// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용) export async function getExternalDbConnector(connectionId: number) { - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); + const poolService = ExternalDbConnectionPoolService.getInstance(); - if (!connection) { - throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`); - } - - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, + // 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체) + return { + executeQuery: async (sql: string, params?: any[]) => { + const result = await poolService.executeQuery(connectionId, sql, params); + return { rows: result }; + }, }; - - // DB 커넥터 생성 - return await DatabaseConnectorFactory.createConnector( - connection.db_type || "mariadb", - config, - connectionId - ); } // 동적 계층 구조 데이터 조회 (범용) -export const getHierarchyData = async (req: Request, res: Response): Promise => { +export const getHierarchyData = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, hierarchyConfig } = req.body; @@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise ({ level: l.level, count: l.data.length })), + levelCounts: result.levels.map((l: any) => ({ + level: l.level, + count: l.data.length, + })), }); return res.json({ @@ -112,22 +99,35 @@ export const getHierarchyData = async (req: Request, res: Response): Promise => { +export const getChildrenData = async ( + req: Request, + res: Response +): Promise => { try { - const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; + const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = + req.body; - if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { + if ( + !externalDbConnectionId || + !hierarchyConfig || + !parentLevel || + !parentKey + ) { return res.status(400).json({ success: false, message: "필수 파라미터가 누락되었습니다.", }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); const config = JSON.parse(hierarchyConfig); // 다음 레벨 찾기 - const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); + const nextLevel = config.levels?.find( + (l: any) => l.level === parentLevel + 1 + ); if (!nextLevel) { return res.json({ @@ -168,7 +168,10 @@ export const getChildrenData = async (req: Request, res: Response): Promise => { +export const getWarehouses = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, tableName } = req.query; @@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise => { +export const getAreas = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, warehouseKey, tableName } = req.query; @@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise = }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); const query = ` SELECT * FROM ${tableName} @@ -258,7 +268,10 @@ export const getAreas = async (req: Request, res: Response): Promise = }; // 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 -export const getLocations = async (req: Request, res: Response): Promise => { +export const getLocations = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, areaKey, tableName } = req.query; @@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise => { +export const getMaterials = async ( + req: Request, + res: Response +): Promise => { try { - const { - externalDbConnectionId, - locaKey, + const { + externalDbConnectionId, + locaKey, tableName, keyColumn, locationKeyColumn, - layerColumn + layerColumn, } = req.query; - if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { + if ( + !externalDbConnectionId || + !locaKey || + !tableName || + !locationKeyColumn + ) { return res.status(400).json({ success: false, message: "필수 파라미터가 누락되었습니다.", }); } - const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const connector = await getExternalDbConnector( + Number(externalDbConnectionId) + ); // 동적 쿼리 생성 - const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; + const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ""; const query = ` SELECT * FROM ${tableName} WHERE ${locationKeyColumn} = '${locaKey}' @@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise => { +export const getMaterialCounts = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, locationKeys, tableName } = req.body; @@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise `'${key}'`).join(","); diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index b1e31e3b..d4e8d0cf 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -341,6 +341,64 @@ export const uploadFiles = async ( }); } + // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 + const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true; + + // 🔍 디버깅: 레코드 모드 조건 확인 + console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", { + isRecordMode, + linkedTable, + recordId, + columnName, + finalTargetObjid, + "req.body.isRecordMode": req.body.isRecordMode, + "req.body.linkedTable": req.body.linkedTable, + "req.body.recordId": req.body.recordId, + "req.body.columnName": req.body.columnName, + }); + + if (isRecordMode && linkedTable && recordId && columnName) { + try { + // 해당 레코드의 모든 첨부파일 조회 + const allFiles = await query( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate + FROM attach_file_info + WHERE target_objid = $1 AND status = 'ACTIVE' + ORDER BY regdate DESC`, + [finalTargetObjid] + ); + + // attachments JSONB 형태로 변환 + const attachmentsJson = allFiles.map((f: any) => ({ + objid: f.objid.toString(), + realFileName: f.real_file_name, + fileSize: Number(f.file_size), + fileExt: f.file_ext, + filePath: f.file_path, + regdate: f.regdate?.toISOString(), + })); + + // 해당 테이블의 attachments 컬럼 업데이트 + // 🔒 멀티테넌시: company_code 필터 추가 + await query( + `UPDATE ${linkedTable} + SET ${columnName} = $1::jsonb, updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [JSON.stringify(attachmentsJson), recordId, companyCode] + ); + + console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", { + tableName: linkedTable, + recordId: recordId, + columnName: columnName, + fileCount: attachmentsJson.length, + }); + } catch (updateError) { + // attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리 + console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError); + } + } + res.json({ success: true, message: `${files.length}개 파일 업로드 완료`, @@ -405,6 +463,56 @@ export const deleteFile = async ( ["DELETED", parseInt(objid)] ); + // 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트 + const targetObjid = fileRecord.target_objid; + if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) { + // targetObjid 파싱: tableName:recordId:columnName 형식 + const parts = targetObjid.split(':'); + if (parts.length >= 3) { + const [tableName, recordId, columnName] = parts; + + try { + // 해당 레코드의 남은 첨부파일 조회 + const remainingFiles = await query( + `SELECT objid, real_file_name, file_size, file_ext, file_path, regdate + FROM attach_file_info + WHERE target_objid = $1 AND status = 'ACTIVE' + ORDER BY regdate DESC`, + [targetObjid] + ); + + // attachments JSONB 형태로 변환 + const attachmentsJson = remainingFiles.map((f: any) => ({ + objid: f.objid.toString(), + realFileName: f.real_file_name, + fileSize: Number(f.file_size), + fileExt: f.file_ext, + filePath: f.file_path, + regdate: f.regdate?.toISOString(), + })); + + // 해당 테이블의 attachments 컬럼 업데이트 + // 🔒 멀티테넌시: company_code 필터 추가 + await query( + `UPDATE ${tableName} + SET ${columnName} = $1::jsonb, updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [JSON.stringify(attachmentsJson), recordId, fileRecord.company_code] + ); + + console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", { + tableName, + recordId, + columnName, + remainingFiles: attachmentsJson.length, + }); + } catch (updateError) { + // attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리 + console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError); + } + } + } + res.json({ success: true, message: "파일이 삭제되었습니다.", diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 5d922dd6..de4eb913 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -50,3 +50,4 @@ router.get("/data/:groupCode", getAutoFillData); export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 813dbff1..c2f12782 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -46,3 +46,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions); export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index be37da49..71e6c418 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -62,3 +62,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions); export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 46bbf427..d92d7d72 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -50,3 +50,4 @@ router.get("/options/:exclusionCode", getExcludedOptions); export default router; + diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index c6ab17c6..5ca6b392 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -19,15 +19,21 @@ export class AdminService { // menuType에 따른 WHERE 조건 생성 const menuTypeCondition = - menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1"; - + menuType !== undefined + ? `MENU.MENU_TYPE = ${parseInt(menuType)}` + : "1 = 1"; + // 메뉴 관리 화면인지 좌측 사이드바인지 구분 // includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면 const includeInactive = paramMap.includeInactive === true; const isManagementScreen = includeInactive || menuType === undefined; // 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시 - const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'"; - const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'"; + const statusCondition = isManagementScreen + ? "1 = 1" + : "MENU.STATUS = 'active'"; + const subStatusCondition = isManagementScreen + ? "1 = 1" + : "MENU_SUB.STATUS = 'active'"; // 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만) let authFilter = ""; @@ -35,7 +41,11 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; - if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) { + if ( + menuType !== undefined && + userType !== "SUPER_ADMIN" && + !isManagementScreen + ) { // 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크 const userRoleGroups = await query( ` @@ -56,45 +66,45 @@ export class AdminService { ); if (userType === "COMPANY_ADMIN") { - // 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만 + // 회사 관리자: 권한 그룹 기반 필터링 적용 if (userRoleGroups.length > 0) { const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); - // 루트 메뉴: 회사 코드만 체크 (권한 체크 X) - authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`; + // 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링 + authFilter = ` + AND MENU.COMPANY_CODE IN ($${paramIndex}, '*') + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY($${paramIndex + 1}) + AND rma.read_yn = 'Y' + ) + `; queryParams.push(userCompanyCode); - const companyParamIndex = paramIndex; paramIndex++; - // 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크 + // 하위 메뉴도 권한 체크 unionFilter = ` - AND ( - MENU_SUB.COMPANY_CODE = $${companyParamIndex} - OR ( - MENU_SUB.COMPANY_CODE = '*' - AND EXISTS ( - SELECT 1 - FROM rel_menu_auth rma - WHERE rma.menu_objid = MENU_SUB.OBJID - AND rma.auth_objid = ANY($${paramIndex}) - AND rma.read_yn = 'Y' - ) - ) + AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*') + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU_SUB.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' ) `; queryParams.push(roleObjids); paramIndex++; logger.info( - `✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴` + `✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)` ); } else { - // 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만 - authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; - unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`; - queryParams.push(userCompanyCode); - paramIndex++; - logger.info( - `✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만` + // 권한 그룹이 없는 회사 관리자: 메뉴 없음 + logger.warn( + `⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.` ); + return []; } } else { // 일반 사용자: 권한 그룹 필수 @@ -131,7 +141,11 @@ export class AdminService { return []; } } - } else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) { + } else if ( + menuType !== undefined && + userType === "SUPER_ADMIN" && + !isManagementScreen + ) { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) @@ -167,7 +181,7 @@ export class AdminService { companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; queryParams.push(userCompanyCode); paramIndex++; - + // 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외) if (unionFilter === "") { unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 77593fa1..7ec95626 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -903,6 +903,9 @@ export class DynamicFormService { return `${key} = $${index + 1}::numeric`; } else if (dataType === "boolean") { return `${key} = $${index + 1}::boolean`; + } else if (dataType === "jsonb" || dataType === "json") { + // 🆕 JSONB/JSON 타입은 명시적 캐스팅 + return `${key} = $${index + 1}::jsonb`; } else { // 문자열 타입은 캐스팅 불필요 return `${key} = $${index + 1}`; @@ -910,7 +913,21 @@ export class DynamicFormService { }) .join(", "); - const values: any[] = Object.values(changedFields); + // 🆕 JSONB 타입 값은 JSON 문자열로 변환 + const values: any[] = Object.keys(changedFields).map((key) => { + const value = changedFields[key]; + const dataType = columnTypes[key]; + + // JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환 + if ( + (dataType === "jsonb" || dataType === "json") && + (Array.isArray(value) || + (typeof value === "object" && value !== null)) + ) { + return JSON.stringify(value); + } + return value; + }); values.push(id); // WHERE 조건용 ID 추가 // 🔑 Primary Key 타입에 맞게 캐스팅 @@ -1575,6 +1592,7 @@ export class DynamicFormService { /** * 제어관리 실행 (화면에 설정된 경우) + * 다중 제어를 순서대로 순차 실행 지원 */ private async executeDataflowControlIfConfigured( screenId: number, @@ -1616,105 +1634,67 @@ export class DynamicFormService { hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, + hasFlowControls: + !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 if ( properties?.componentType === "button-primary" && properties?.componentConfig?.action?.type === "save" && - properties?.webTypeConfig?.enableDataflowControl === true && - properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId + properties?.webTypeConfig?.enableDataflowControl === true ) { - controlConfigFound = true; - const diagramId = - properties.webTypeConfig.dataflowConfig.selectedDiagramId; - const relationshipId = - properties.webTypeConfig.dataflowConfig.selectedRelationshipId; + const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; - console.log(`🎯 제어관리 설정 발견:`, { - componentId: layout.component_id, - diagramId, - relationshipId, - triggerType, - }); + // 다중 제어 설정 확인 (flowControls 배열) + const flowControls = dataflowConfig?.flowControls || []; - // 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주) - let controlResult: any; + // flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행 + if (flowControls.length > 0) { + controlConfigFound = true; + console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`); - if (!relationshipId) { - // 노드 플로우 실행 - console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); - const { NodeFlowExecutionService } = await import( - "./nodeFlowExecutionService" + // 순서대로 정렬 + const sortedControls = [...flowControls].sort( + (a: any, b: any) => (a.order || 0) - (b.order || 0) ); - const executionResult = await NodeFlowExecutionService.executeFlow( + // 다중 제어 순차 실행 + await this.executeMultipleFlowControls( + sortedControls, + savedData, + screenId, + tableName, + triggerType, + userId, + companyCode + ); + } else if (dataflowConfig?.selectedDiagramId) { + // 기존 단일 제어 실행 (하위 호환성) + controlConfigFound = true; + const diagramId = dataflowConfig.selectedDiagramId; + const relationshipId = dataflowConfig.selectedRelationshipId; + + console.log(`🎯 단일 제어관리 설정 발견:`, { + componentId: layout.component_id, diagramId, - { - sourceData: [savedData], - dataSourceType: "formData", - buttonId: "save-button", - screenId: screenId, - userId: userId, - companyCode: companyCode, - formData: savedData, - } - ); + relationshipId, + triggerType, + }); - controlResult = { - success: executionResult.success, - message: executionResult.message, - executedActions: executionResult.nodes?.map((node) => ({ - nodeId: node.nodeId, - status: node.status, - duration: node.duration, - })), - errors: executionResult.nodes - ?.filter((node) => node.status === "failed") - .map((node) => node.error || "실행 실패"), - }; - } else { - // 관계 기반 제어관리 실행 - console.log( - `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` + await this.executeSingleFlowControl( + diagramId, + relationshipId, + savedData, + screenId, + tableName, + triggerType, + userId, + companyCode ); - controlResult = - await this.dataflowControlService.executeDataflowControl( - diagramId, - relationshipId, - triggerType, - savedData, - tableName, - userId - ); } - console.log(`🎯 제어관리 실행 결과:`, controlResult); - - if (controlResult.success) { - console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`); - if ( - controlResult.executedActions && - controlResult.executedActions.length > 0 - ) { - console.log(`📊 실행된 액션들:`, controlResult.executedActions); - } - - // 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패) - if (controlResult.errors && controlResult.errors.length > 0) { - console.warn( - `⚠️ 제어관리 실행 중 일부 오류 발생:`, - controlResult.errors - ); - // 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능 - // 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행 - } - } else { - console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); - // 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음 - } - - // 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우) + // 첫 번째 설정된 버튼의 제어관리만 실행 break; } } @@ -1728,6 +1708,218 @@ export class DynamicFormService { } } + /** + * 다중 제어 순차 실행 + */ + private async executeMultipleFlowControls( + flowControls: Array<{ + id: string; + flowId: number; + flowName: string; + executionTiming: string; + order: number; + }>, + savedData: Record, + screenId: number, + tableName: string, + triggerType: "insert" | "update" | "delete", + userId: string, + companyCode: string + ): Promise { + console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}개`); + + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const results: Array<{ + order: number; + flowId: number; + flowName: string; + success: boolean; + message: string; + duration: number; + }> = []; + + for (let i = 0; i < flowControls.length; i++) { + const control = flowControls[i]; + const startTime = Date.now(); + + console.log( + `\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})` + ); + + try { + // 유효하지 않은 flowId 스킵 + if (!control.flowId || control.flowId <= 0) { + console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`); + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: false, + message: "유효하지 않은 flowId", + duration: 0, + }); + continue; + } + + const executionResult = await NodeFlowExecutionService.executeFlow( + control.flowId, + { + sourceData: [savedData], + dataSourceType: "formData", + buttonId: "save-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: savedData, + } + ); + + const duration = Date.now() - startTime; + + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: executionResult.success, + message: executionResult.message, + duration, + }); + + if (executionResult.success) { + console.log( + `✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)` + ); + } else { + console.error( + `❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}` + ); + // 이전 제어 실패 시 다음 제어 실행 중단 + console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`); + break; + } + } catch (error: any) { + const duration = Date.now() - startTime; + console.error( + `❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`, + error + ); + + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: false, + message: error.message || "실행 오류", + duration, + }); + + // 오류 발생 시 다음 제어 실행 중단 + console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`); + break; + } + } + + // 실행 결과 요약 + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); + + console.log(`\n📊 다중 제어 실행 완료:`, { + total: flowControls.length, + executed: results.length, + success: successCount, + failed: failCount, + totalDuration: `${totalDuration}ms`, + }); + } + + /** + * 단일 제어 실행 (기존 로직, 하위 호환성) + */ + private async executeSingleFlowControl( + diagramId: number, + relationshipId: string | null, + savedData: Record, + screenId: number, + tableName: string, + triggerType: "insert" | "update" | "delete", + userId: string, + companyCode: string + ): Promise { + let controlResult: any; + + if (!relationshipId) { + // 노드 플로우 실행 + console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const executionResult = await NodeFlowExecutionService.executeFlow( + diagramId, + { + sourceData: [savedData], + dataSourceType: "formData", + buttonId: "save-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: savedData, + } + ); + + controlResult = { + success: executionResult.success, + message: executionResult.message, + executedActions: executionResult.nodes?.map((node) => ({ + nodeId: node.nodeId, + status: node.status, + duration: node.duration, + })), + errors: executionResult.nodes + ?.filter((node) => node.status === "failed") + .map((node) => node.error || "실행 실패"), + }; + } else { + // 관계 기반 제어관리 실행 + console.log( + `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` + ); + controlResult = await this.dataflowControlService.executeDataflowControl( + diagramId, + relationshipId, + triggerType, + savedData, + tableName, + userId + ); + } + + console.log(`🎯 제어관리 실행 결과:`, controlResult); + + if (controlResult.success) { + console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`); + if ( + controlResult.executedActions && + controlResult.executedActions.length > 0 + ) { + console.log(`📊 실행된 액션들:`, controlResult.executedActions); + } + + if (controlResult.errors && controlResult.errors.length > 0) { + console.warn( + `⚠️ 제어관리 실행 중 일부 오류 발생:`, + controlResult.errors + ); + } + } else { + console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); + } + } + /** * 특정 테이블의 특정 필드 값만 업데이트 * (다른 테이블의 레코드 업데이트 지원) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 280051d0..5557d8b5 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -134,8 +134,8 @@ export class EntityJoinService { `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { - // display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기 - logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`); + // display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지 + logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`); // 참조 테이블의 모든 컬럼 이름 가져오기 const tableColumnsResult = await query<{ column_name: string }>( @@ -148,10 +148,34 @@ export class EntityJoinService { ); if (tableColumnsResult.length > 0) { - displayColumns = tableColumnsResult.map((col) => col.column_name); + const allColumns = tableColumnsResult.map((col) => col.column_name); + + // 🆕 표시용 컬럼 자동 감지 (우선순위 순서) + // 1. *_name 컬럼 (item_name, customer_name 등) + // 2. name 컬럼 + // 3. label 컬럼 + // 4. title 컬럼 + // 5. 참조 컬럼 (referenceColumn) + const nameColumn = allColumns.find( + (col) => col.endsWith("_name") && col !== "company_name" + ); + const simpleNameColumn = allColumns.find((col) => col === "name"); + const labelColumn = allColumns.find( + (col) => col === "label" || col.endsWith("_label") + ); + const titleColumn = allColumns.find((col) => col === "title"); + + // 우선순위에 따라 표시 컬럼 선택 + const displayColumn = + nameColumn || + simpleNameColumn || + labelColumn || + titleColumn || + referenceColumn; + displayColumns = [displayColumn]; + logger.info( - `✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`, - displayColumns.join(", ") + `✅ ${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)` ); } else { // 테이블 컬럼을 못 찾으면 기본값 사용 diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 940787c3..f35150ac 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { lastUsedAt: Date; activeConnections = 0; maxConnections: number; + private isPoolClosed = false; constructor(config: ExternalDbConnection) { this.connectionId = config.id!; @@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { waitForConnections: true, queueLimit: 0, connectTimeout: (config.connection_timeout || 30) * 1000, + // 연결 유지 및 자동 재연결 설정 + enableKeepAlive: true, + keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송 ssl: config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined, }); @@ -153,11 +157,33 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { async query(sql: string, params?: any[]): Promise { this.lastUsedAt = new Date(); + + // 연결 풀이 닫힌 상태인지 확인 + if (this.isPoolClosed) { + throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다."); + } + + try { const [rows] = await this.pool.execute(sql, params); return rows; + } catch (error: any) { + // 연결 닫힘 오류 감지 + if ( + error.message.includes("closed state") || + error.code === "PROTOCOL_CONNECTION_LOST" || + error.code === "ECONNRESET" + ) { + this.isPoolClosed = true; + logger.warn( + `[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})` + ); + } + throw error; + } } async disconnect(): Promise { + this.isPoolClosed = true; await this.pool.end(); logger.info( `[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})` @@ -165,6 +191,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } isHealthy(): boolean { + // 연결 풀이 닫혔으면 비정상 + if (this.isPoolClosed) { + return false; + } return this.activeConnections < this.maxConnections; } } @@ -230,9 +260,11 @@ export class ExternalDbConnectionPoolService { ): Promise { logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`); - // DB 연결 정보 조회 + // DB 연결 정보 조회 (실제 비밀번호 포함) const connectionResult = - await ExternalDbConnectionService.getConnectionById(connectionId); + await ExternalDbConnectionService.getConnectionByIdWithPassword( + connectionId + ); if (!connectionResult.success || !connectionResult.data) { throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`); @@ -296,16 +328,19 @@ export class ExternalDbConnectionPoolService { } /** - * 쿼리 실행 (자동으로 연결 풀 관리) + * 쿼리 실행 (자동으로 연결 풀 관리 + 재시도 로직) */ async executeQuery( connectionId: number, sql: string, - params?: any[] + params?: any[], + retryCount = 0 ): Promise { - const pool = await this.getPool(connectionId); + const MAX_RETRIES = 2; try { + const pool = await this.getPool(connectionId); + logger.debug( `📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...` ); @@ -314,7 +349,29 @@ export class ExternalDbConnectionPoolService { `✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건` ); return result; - } catch (error) { + } catch (error: any) { + // 연결 끊김 오류인 경우 재시도 + const isConnectionError = + error.message?.includes("closed state") || + error.message?.includes("연결 풀이 닫힌 상태") || + error.code === "PROTOCOL_CONNECTION_LOST" || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT"; + + if (isConnectionError && retryCount < MAX_RETRIES) { + logger.warn( + `🔄 연결 오류 감지, 재시도 중... (${retryCount + 1}/${MAX_RETRIES}) (ID: ${connectionId})` + ); + + // 기존 풀 제거 후 새로 생성 + await this.removePool(connectionId); + + // 잠시 대기 후 재시도 + await new Promise((resolve) => setTimeout(resolve, 500)); + + return this.executeQuery(connectionId, sql, params, retryCount + 1); + } + logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error); throw error; } diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 461cd8d2..6f481198 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -3596,7 +3596,7 @@ export class NodeFlowExecutionService { // 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정 let accountId = nodeAccountId || smtpConfigId; if (!accountId) { - const accounts = await mailAccountFileService.getAccounts(); + const accounts = await mailAccountFileService.getAllAccounts(); const activeAccount = accounts.find( (acc: any) => acc.status === "active" ); @@ -4216,7 +4216,7 @@ export class NodeFlowExecutionService { return this.evaluateFunction(func, sourceRow, targetRow, resultValues); case "condition": - return this.evaluateCondition( + return this.evaluateCaseCondition( condition, sourceRow, targetRow, @@ -4393,7 +4393,7 @@ export class NodeFlowExecutionService { /** * 조건 평가 (CASE WHEN ... THEN ... ELSE) */ - private static evaluateCondition( + private static evaluateCaseCondition( condition: any, sourceRow: any, targetRow: any, diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 781a9498..9a8623a0 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -798,7 +798,12 @@ export class TableManagementService { ); // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 - await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode); + await this.syncScreenLayoutsInputType( + tableName, + columnName, + inputType, + companyCode + ); // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; @@ -928,7 +933,11 @@ export class TableManagementService { `UPDATE screen_layouts SET properties = $1, component_type = $2 WHERE layout_id = $3`, - [JSON.stringify(updatedProperties), newComponentType, layout.layout_id] + [ + JSON.stringify(updatedProperties), + newComponentType, + layout.layout_id, + ] ); logger.info( @@ -1299,18 +1308,30 @@ export class TableManagementService { try { // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + // 날짜 타입이면 날짜 범위로 처리 - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } - + // 그 외 타입이면 다중선택(IN 조건)으로 처리 - const multiValues = value.split("|").filter((v: string) => v.trim() !== ""); + const multiValues = value + .split("|") + .filter((v: string) => v.trim() !== ""); if (multiValues.length > 0) { - const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", "); - logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`); + const placeholders = multiValues + .map((_: string, idx: number) => `$${paramIndex + idx}`) + .join(", "); + logger.info( + `🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})` + ); return { whereClause: `${columnName}::text IN (${placeholders})`, values: multiValues, @@ -1320,10 +1341,20 @@ export class TableManagementService { } // 🔧 날짜 범위 객체 {from, to} 체크 - if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) { + if ( + typeof value === "object" && + value !== null && + ("from" in value || "to" in value) + ) { // 날짜 범위 객체는 그대로 전달 - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } } @@ -1356,9 +1387,10 @@ export class TableManagementService { // 컬럼 타입 정보 조회 const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, - `webType=${columnInfo?.webType || 'NULL'}`, - `inputType=${columnInfo?.inputType || 'NULL'}`, + logger.info( + `🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, + `webType=${columnInfo?.webType || "NULL"}`, + `inputType=${columnInfo?.inputType || "NULL"}`, `actualValue=${JSON.stringify(actualValue)}`, `operator=${operator}` ); @@ -1464,16 +1496,20 @@ export class TableManagementService { // 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD") if (typeof value === "string" && value.includes("|")) { const [fromStr, toStr] = value.split("|"); - + if (fromStr && fromStr.trim() !== "") { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(fromStr.trim()); paramCount++; } if (toStr && toStr.trim() !== "") { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(toStr.trim()); paramCount++; } @@ -1482,17 +1518,21 @@ export class TableManagementService { else if (typeof value === "object" && value !== null) { if (value.from) { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(value.from); paramCount++; } if (value.to) { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(value.to); paramCount++; } - } + } // 단일 날짜 검색 else if (typeof value === "string" && value.trim() !== "") { conditions.push(`${columnName}::date = $${paramIndex}::date`); @@ -1658,9 +1698,11 @@ export class TableManagementService { paramCount: 0, }; } - + // IN 절로 여러 값 검색 - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + const placeholders = value + .map((_, idx) => `$${paramIndex + idx}`) + .join(", "); return { whereClause: `${columnName} IN (${placeholders})`, values: value, @@ -1776,20 +1818,25 @@ export class TableManagementService { [tableName, columnName] ); - logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, { - found: !!result, - web_type: result?.web_type, - input_type: result?.input_type, - }); + logger.info( + `🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, + { + found: !!result, + web_type: result?.web_type, + input_type: result?.input_type, + } + ); if (!result) { - logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`); + logger.warn( + `⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}` + ); return null; } // web_type이 없으면 input_type을 사용 (레거시 호환) const webType = result.web_type || result.input_type || ""; - + const columnInfo = { webType: webType, inputType: result.input_type || "", @@ -1799,7 +1846,9 @@ export class TableManagementService { displayColumn: result.display_column || undefined, }; - logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`); + logger.info( + `✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}` + ); return columnInfo; } catch (error) { logger.error( @@ -1913,6 +1962,15 @@ export class TableManagementService { continue; } + // 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외 + // Entity 조인 조회에서만 처리됨 + if (column.includes(".")) { + logger.info( + `🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)` + ); + continue; + } + // 안전한 컬럼명 검증 (SQL 인젝션 방지) const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); @@ -2741,7 +2799,11 @@ export class TableManagementService { WHERE "${referenceColumn}" IS NOT NULL`; // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외) - if (filterColumn && filterValue !== undefined && filterValue !== null) { + if ( + filterColumn && + filterValue !== undefined && + filterValue !== null + ) { excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`; } @@ -2934,16 +2996,22 @@ export class TableManagementService { }), ]; + // 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식 + const hasJoinTableSearch = + options.search && + Object.keys(options.search).some((key) => key.includes(".")); + const hasEntitySearch = options.search && - Object.keys(options.search).some((key) => + (Object.keys(options.search).some((key) => allEntityColumns.includes(key) - ); + ) || + hasJoinTableSearch); if (hasEntitySearch) { const entitySearchKeys = options.search - ? Object.keys(options.search).filter((key) => - allEntityColumns.includes(key) + ? Object.keys(options.search).filter( + (key) => allEntityColumns.includes(key) || key.includes(".") ) : []; logger.info( @@ -2988,47 +3056,113 @@ export class TableManagementService { if (options.search) { for (const [key, value] of Object.entries(options.search)) { + // 검색값 추출 (객체 형태일 수 있음) + let searchValue = value; + if ( + typeof value === "object" && + value !== null && + "value" in value + ) { + searchValue = value.value; + } + + // 빈 값이면 스킵 + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null || + searchValue === undefined + ) { + continue; + } + + const safeValue = String(searchValue).replace(/'/g, "''"); + + // 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name) + if (key.includes(".")) { + const [refTable, refColumn] = key.split("."); + + // aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식) + let foundAlias: string | undefined; + for (const [aliasKey, alias] of aliasMap.entries()) { + if (aliasKey.startsWith(`${refTable}:`)) { + foundAlias = alias; + break; + } + } + + if (foundAlias) { + whereConditions.push( + `${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (${refTable}.${refColumn})`); + logger.info( + `🎯 조인 테이블 검색: ${key} → ${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})` + ); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음` + ); + } + continue; + } + const joinConfig = joinConfigs.find( (config) => config.aliasColumn === key ); if (joinConfig) { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 - const alias = aliasMap.get(joinConfig.referenceTable); + const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; + const alias = aliasMap.get(aliasKey); whereConditions.push( - `${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'` + `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` ); entitySearchColumns.push( `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` ); logger.info( - `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (별칭: ${alias})` + `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` ); } else if (key === "writer_dept_code") { // writer_dept_code: user_info.dept_code에서 검색 - const userAlias = aliasMap.get("user_info"); - whereConditions.push( - `${userAlias}.dept_code ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (user_info.dept_code)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${value}%' (별칭: ${userAlias})` + const userAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("user_info:") ); + const userAlias = userAliasKey + ? aliasMap.get(userAliasKey) + : undefined; + if (userAlias) { + whereConditions.push( + `${userAlias}.dept_code ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (user_info.dept_code)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})` + ); + } } else if (key === "company_code_status") { // company_code_status: company_info.status에서 검색 - const companyAlias = aliasMap.get("company_info"); - whereConditions.push( - `${companyAlias}.status ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (company_info.status)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${value}%' (별칭: ${companyAlias})` + const companyAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("company_info:") ); + const companyAlias = companyAliasKey + ? aliasMap.get(companyAliasKey) + : undefined; + if (companyAlias) { + whereConditions.push( + `${companyAlias}.status ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (company_info.status)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})` + ); + } } else { // 일반 컬럼인 경우: 메인 테이블에서 검색 - whereConditions.push(`main.${key} ILIKE '%${value}%'`); + whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); logger.info( - `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${value}%'` + `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` ); } } @@ -3168,6 +3302,59 @@ export class TableManagementService { } try { + // 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name) + if (columnName.includes(".")) { + const [refTable, refColumn] = columnName.split("."); + + // 검색값 추출 + let searchValue = value; + if (typeof value === "object" && value !== null && "value" in value) { + searchValue = value.value; + } + + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null + ) { + continue; + } + + // 🔍 column_labels에서 해당 엔티티 설정 찾기 + // 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info) + const entityColumnResult = await query<{ + column_name: string; + reference_table: string; + reference_column: string; + }>( + `SELECT column_name, reference_table, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [tableName, refTable] + ); + + if (entityColumnResult.length > 0) { + // 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자) + const joinAlias = refTable.substring(0, 3); + + // 조인 테이블 컬럼으로 검색 조건 생성 + const safeValue = String(searchValue).replace(/'/g, "''"); + const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`; + + logger.info(`🔍 조인 테이블 검색 조건: ${condition}`); + conditions.push(condition); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음` + ); + } + + continue; + } + // 고급 검색 조건 구성 const searchCondition = await this.buildAdvancedSearchCondition( tableName, @@ -4282,7 +4469,10 @@ export class TableManagementService { ); return result.length > 0; } catch (error) { - logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error); + logger.error( + `컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, + error + ); return false; } } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index e80a1a61..985d730a 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -582,3 +582,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 2ef68524..285dc6ba 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -355,3 +355,4 @@ - [ ] 부모 화면에서 모달로 데이터가 전달되는가? - [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? + diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index f556dae2..8510d627 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -21,6 +21,7 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감 import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션 import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리 import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신 +import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈 function ScreenViewPage() { const params = useParams(); @@ -307,10 +308,7 @@ function ScreenViewPage() { return ( -
+
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && (
@@ -358,7 +356,6 @@ function ScreenViewPage() { return isButton; }); - topLevelComponents.forEach((component) => { const isButton = (component.type === "component" && @@ -799,7 +796,9 @@ function ScreenViewPageWrapper() { return ( - + + + ); diff --git a/frontend/components/admin/RoleDetailManagement.tsx b/frontend/components/admin/RoleDetailManagement.tsx index 8f3d8fbb..27a6c07d 100644 --- a/frontend/components/admin/RoleDetailManagement.tsx +++ b/frontend/components/admin/RoleDetailManagement.tsx @@ -9,6 +9,7 @@ import { useRouter } from "next/navigation"; import { AlertCircle } from "lucide-react"; import { DualListBox } from "@/components/common/DualListBox"; import { MenuPermissionsTable } from "./MenuPermissionsTable"; +import { useMenu } from "@/contexts/MenuContext"; interface RoleDetailManagementProps { roleId: string; @@ -25,6 +26,7 @@ interface RoleDetailManagementProps { export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { const { user: currentUser } = useAuth(); const router = useRouter(); + const { refreshMenus } = useMenu(); const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; @@ -178,6 +180,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { if (response.success) { alert("멤버가 성공적으로 저장되었습니다."); loadMembers(); // 새로고침 + + // 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음) + await refreshMenus(); } else { alert(response.message || "멤버 저장에 실패했습니다."); } @@ -187,7 +192,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { } finally { setIsSavingMembers(false); } - }, [roleGroup, selectedUsers, loadMembers]); + }, [roleGroup, selectedUsers, loadMembers, refreshMenus]); // 메뉴 권한 저장 핸들러 const handleSavePermissions = useCallback(async () => { @@ -200,6 +205,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { if (response.success) { alert("메뉴 권한이 성공적으로 저장되었습니다."); loadMenuPermissions(); // 새로고침 + + // 사이드바 메뉴 새로고침 (권한 변경 즉시 반영) + await refreshMenus(); } else { alert(response.message || "메뉴 권한 저장에 실패했습니다."); } @@ -209,7 +217,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) { } finally { setIsSavingPermissions(false); } - }, [roleGroup, menuPermissions, loadMenuPermissions]); + }, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]); if (isLoading) { return ( diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 51f3bf7b..31287e1e 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -390,9 +390,11 @@ export interface RowDetailPopupConfig { // 추가 데이터 조회 설정 additionalQuery?: { enabled: boolean; + queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리) tableName: string; // 조회할 테이블명 (예: vehicles) matchColumn: string; // 매칭할 컬럼 (예: id) sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일) + customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용) // 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시) displayColumns?: DisplayColumnConfig[]; }; diff --git a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx index b10057cf..a7186d50 100644 --- a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx @@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW checked={popupConfig.additionalQuery?.enabled || false} onCheckedChange={(enabled) => updatePopupConfig({ - additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" }, + additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" }, }) } aria-label="추가 데이터 조회 활성화" @@ -167,116 +167,230 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW {popupConfig.additionalQuery?.enabled && (
+ {/* 조회 모드 선택 */}
- - + + - updatePopupConfig({ - additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value }, - }) - } - placeholder="id" - className="mt-1 h-8 text-xs" - /> -
-
- - - updatePopupConfig({ - additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, - }) - } - placeholder="비워두면 매칭 컬럼과 동일" - className="mt-1 h-8 text-xs" - /> + > + + + + + 테이블 조회 + 커스텀 쿼리 + +
- {/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */} + {/* 테이블 조회 모드 */} + {(popupConfig.additionalQuery?.queryMode || "table") === "table" && ( + <> +
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value }, + }) + } + placeholder="vehicles" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value }, + }) + } + placeholder="id" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, + }) + } + placeholder="비워두면 매칭 컬럼과 동일" + className="mt-1 h-8 text-xs" + /> +
+ + )} + + {/* 커스텀 쿼리 모드 */} + {popupConfig.additionalQuery?.queryMode === "custom" && ( + <> +
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, + }) + } + placeholder="id" + className="mt-1 h-8 text-xs" + /> +

쿼리에서 사용할 파라미터 컬럼

+
+
+ +