; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin
2026-01-16 10:19:05 +09:00
18 changed files with 1714 additions and 3099 deletions

View File

@@ -30,7 +30,6 @@ export class EntityJoinController {
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@@ -50,9 +49,6 @@ export class EntityJoinController {
// search가 문자열인 경우 JSON 파싱
searchConditions =
typeof search === "string" ? JSON.parse(search) : search;
// 🔍 디버그: 파싱된 검색 조건 로깅
logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2));
} catch (error) {
logger.warn("검색 조건 파싱 오류:", error);
searchConditions = {};
@@ -155,24 +151,6 @@ export class EntityJoinController {
}
}
// 🆕 중복 제거 설정 처리
let parsedDeduplication: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
} | undefined = undefined;
if (deduplication) {
try {
parsedDeduplication =
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
} catch (error) {
logger.warn("중복 제거 설정 파싱 오류:", error);
parsedDeduplication = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins(
tableName,
{
@@ -190,26 +168,13 @@ export class EntityJoinController {
screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
}
);
// 🆕 중복 제거 처리 (결과 데이터에 적용)
let finalData = result;
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
const originalCount = result.data.length;
finalData = {
...result,
data: this.deduplicateData(result.data, parsedDeduplication),
};
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}`);
}
res.status(200).json({
success: true,
message: "Entity 조인 데이터 조회 성공",
data: finalData,
data: result,
});
} catch (error) {
logger.error("Entity 조인 데이터 조회 실패", error);
@@ -584,98 +549,6 @@ export class EntityJoinController {
});
}
}
/**
* 중복 데이터 제거 (메모리 내 처리)
*/
private deduplicateData(
data: any[],
config: {
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): any[] {
if (!data || data.length === 0) return data;
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(row);
}
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal > bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal < bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "base_price":
// base_price가 true인 행 선택
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
break;
case "current_date":
// 오늘 날짜 기준 유효 기간 내 행 선택
const today = new Date().toISOString().split("T")[0];
selectedRow = rows.find((r) => {
const startDate = r.start_date;
const endDate = r.end_date;
if (!startDate) return true;
if (startDate <= today && (!endDate || endDate >= today)) return true;
return false;
}) || rows[0];
break;
default:
selectedRow = rows[0];
}
if (selectedRow) {
result.push(selectedRow);
}
}
return result;
}
}
export const entityJoinController = new EntityJoinController();

View File

@@ -1,23 +1,18 @@
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { MultiLangService } from "../services/multilangService";
import { AuthenticatedRequest } from "../types/auth";
// pool 인스턴스 가져오기
const pool = getPool();
// 다국어 서비스 인스턴스
const multiLangService = new MultiLangService();
// ============================================================
// 화면 그룹 (screen_groups) CRUD
// ============================================================
// 화면 그룹 목록 조회
export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
export const getScreenGroups = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const { page = 1, size = 20, searchTerm } = req.query;
const offset = (parseInt(page as string) - 1) * parseInt(size as string);
@@ -89,10 +84,10 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
};
// 화면 그룹 상세 조회
export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
export const getScreenGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
let query = `
SELECT sg.*,
@@ -135,10 +130,10 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) =
};
// 화면 그룹 생성
export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
export const createScreenGroup = async (req: Request, res: Response) => {
try {
const userCompanyCode = req.user!.companyCode;
const userId = req.user!.userId;
const userCompanyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body;
if (!group_name || !group_code) {
@@ -196,47 +191,6 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response
// 업데이트된 데이터 반환
const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]);
// 다국어 카테고리 자동 생성 (그룹 경로 기반)
try {
// 그룹 경로 조회 (상위 그룹 → 현재 그룹)
const groupPathResult = await pool.query(
`WITH RECURSIVE group_path AS (
SELECT id, parent_group_id, group_name, group_level, 1 as depth
FROM screen_groups
WHERE id = $1
UNION ALL
SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1
FROM screen_groups g
INNER JOIN group_path gp ON g.id = gp.parent_group_id
WHERE g.parent_group_id IS NOT NULL
)
SELECT group_name FROM group_path
ORDER BY depth DESC`,
[newGroupId]
);
const groupPath = groupPathResult.rows.map((r: any) => r.group_name);
// 회사 이름 조회
let companyName = "공통";
if (finalCompanyCode !== "*") {
const companyResult = await pool.query(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[finalCompanyCode]
);
if (companyResult.rows.length > 0) {
companyName = companyResult.rows[0].company_name;
}
}
// 다국어 카테고리 생성
await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath);
logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode });
} catch (multilangError: any) {
// 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리
logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message);
}
logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id });
res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." });
@@ -250,10 +204,10 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response
};
// 화면 그룹 수정
export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
export const updateScreenGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userCompanyCode = req.user!.companyCode;
const userCompanyCode = (req.user as any).companyCode;
const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body;
// 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지
@@ -339,10 +293,10 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response
};
// 화면 그룹 삭제
export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
export const deleteScreenGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_groups WHERE id = $1`;
const params: any[] = [id];
@@ -375,10 +329,10 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
// ============================================================
// 그룹에 화면 추가
export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => {
export const addScreenToGroup = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const { group_id, screen_id, screen_role, display_order, is_default } = req.body;
if (!group_id || !screen_id) {
@@ -415,10 +369,10 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response)
};
// 그룹에서 화면 제거
export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => {
export const removeScreenFromGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_group_screens WHERE id = $1`;
const params: any[] = [id];
@@ -446,10 +400,10 @@ export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Resp
};
// 그룹 내 화면 순서/역할 수정
export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => {
export const updateScreenInGroup = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const { screen_role, display_order, is_default } = req.body;
let query = `
@@ -485,9 +439,9 @@ export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Respon
// ============================================================
// 화면 필드 조인 목록 조회
export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => {
export const getFieldJoins = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const { screen_id } = req.query;
let query = `
@@ -526,10 +480,10 @@ export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) =>
};
// 화면 필드 조인 생성
export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
export const createFieldJoin = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const {
screen_id, layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
@@ -567,10 +521,10 @@ export const createFieldJoin = async (req: AuthenticatedRequest, res: Response)
};
// 화면 필드 조인 수정
export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
export const updateFieldJoin = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const {
layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
@@ -612,10 +566,10 @@ export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response)
};
// 화면 필드 조인 삭제
export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
export const deleteFieldJoin = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_field_joins WHERE id = $1`;
const params: any[] = [id];
@@ -646,9 +600,9 @@ export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response)
// ============================================================
// 데이터 흐름 목록 조회
export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => {
export const getDataFlows = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const { group_id, source_screen_id } = req.query;
let query = `
@@ -696,10 +650,10 @@ export const getDataFlows = async (req: AuthenticatedRequest, res: Response) =>
};
// 데이터 흐름 생성
export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => {
export const createDataFlow = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const {
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping, flow_type, flow_label, condition_expression, is_active
@@ -735,10 +689,10 @@ export const createDataFlow = async (req: AuthenticatedRequest, res: Response) =
};
// 데이터 흐름 수정
export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => {
export const updateDataFlow = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const {
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping, flow_type, flow_label, condition_expression, is_active
@@ -778,10 +732,10 @@ export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) =
};
// 데이터 흐름 삭제
export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => {
export const deleteDataFlow = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_data_flows WHERE id = $1`;
const params: any[] = [id];
@@ -812,9 +766,9 @@ export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) =
// ============================================================
// 화면-테이블 관계 목록 조회
export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => {
export const getTableRelations = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const { screen_id, group_id } = req.query;
let query = `
@@ -861,10 +815,10 @@ export const getTableRelations = async (req: AuthenticatedRequest, res: Response
};
// 화면-테이블 관계 생성
export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => {
export const createTableRelation = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const companyCode = (req.user as any).companyCode;
const userId = (req.user as any).userId;
const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
if (!screen_id || !table_name) {
@@ -894,10 +848,10 @@ export const createTableRelation = async (req: AuthenticatedRequest, res: Respon
};
// 화면-테이블 관계 수정
export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => {
export const updateTableRelation = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
let query = `
@@ -929,10 +883,10 @@ export const updateTableRelation = async (req: AuthenticatedRequest, res: Respon
};
// 화면-테이블 관계 삭제
export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => {
export const deleteTableRelation = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const companyCode = (req.user as any).companyCode;
let query = `DELETE FROM screen_table_relations WHERE id = $1`;
const params: any[] = [id];

View File

@@ -804,12 +804,6 @@ export async function getTableData(
}
}
// 🆕 최종 검색 조건 로그
logger.info(
`🔍 최종 검색 조건 (enhancedSearch):`,
JSON.stringify(enhancedSearch)
);
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page),
@@ -893,10 +887,7 @@ export async function addTableData(
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(
tableName,
"company_code"
);
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
@@ -906,10 +897,7 @@ export async function addTableData(
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
const userId = req.user?.userId;
if (userId && !data.writer) {
const hasWriterColumn = await tableManagementService.hasColumn(
tableName,
"writer"
);
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
if (hasWriterColumn) {
data.writer = userId;
logger.info(`writer 자동 추가 - ${userId}`);
@@ -917,25 +905,13 @@ export async function addTableData(
}
// 데이터 추가
const result = await tableManagementService.addTableData(tableName, data);
await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
// 무시된 컬럼이 있으면 경고 정보 포함
const response: ApiResponse<{
skippedColumns?: string[];
savedColumns?: string[];
}> = {
const response: ApiResponse<null> = {
success: true,
message:
result.skippedColumns.length > 0
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
: "테이블 데이터를 성공적으로 추가했습니다.",
data: {
skippedColumns:
result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
savedColumns: result.savedColumns,
},
message: "테이블 데이터를 성공적으로 추가했습니다.",
};
res.status(201).json(response);
@@ -1663,10 +1639,10 @@ export async function toggleLogTable(
/**
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
*
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회
*
*
* 예시:
* - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정
* - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속)
@@ -1679,10 +1655,7 @@ export async function getCategoryColumnsByMenu(
const { menuObjid } = req.params;
const companyCode = req.user?.companyCode;
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", {
menuObjid,
companyCode,
});
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
if (!menuObjid) {
res.status(400).json({
@@ -1708,11 +1681,8 @@ export async function getCategoryColumnsByMenu(
if (mappingTableExists) {
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
logger.info(
"🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)",
{ menuObjid, companyCode }
);
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
const ancestorMenuQuery = `
WITH RECURSIVE menu_hierarchy AS (
@@ -1734,21 +1704,17 @@ export async function getCategoryColumnsByMenu(
ARRAY_AGG(menu_name_kor) as menu_names
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [
parseInt(menuObjid),
]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [
parseInt(menuObjid),
];
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
ancestorMenuNames,
hierarchyDepth: ancestorMenuObjids.length,
hierarchyDepth: ancestorMenuObjids.length
});
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
const columnsQuery = `
SELECT DISTINCT
@@ -1778,31 +1744,20 @@ export async function getCategoryColumnsByMenu(
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [
companyCode,
ancestorMenuObjids,
]);
logger.info(
"✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)",
{
rowCount: columnsResult.rows.length,
columns: columnsResult.rows.map(
(r: any) => `${r.tableName}.${r.columnName}`
),
}
);
columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
rowCount: columnsResult.rows.length,
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
});
} else {
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", {
menuObjid,
companyCode,
});
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
// 형제 메뉴들이 사용하는 테이블 조회
const tablesQuery = `
SELECT DISTINCT sd.table_name
@@ -1812,17 +1767,11 @@ export async function getCategoryColumnsByMenu(
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [
siblingObjids,
companyCode,
]);
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", {
tableNames,
count: tableNames.length,
});
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
res.json({
@@ -1832,7 +1781,7 @@ export async function getCategoryColumnsByMenu(
});
return;
}
const columnsQuery = `
SELECT
ttc.table_name AS "tableName",
@@ -1857,15 +1806,13 @@ export async function getCategoryColumnsByMenu(
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", {
rowCount: columnsResult.rows.length,
});
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
}
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length,
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length
});
res.json({
@@ -1890,9 +1837,9 @@ export async function getCategoryColumnsByMenu(
/**
* 범용 다중 테이블 저장 API
*
*
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
*
*
* 요청 본문:
* {
* mainTable: { tableName: string, primaryKeyColumn: string },
@@ -1962,29 +1909,23 @@ export async function multiTableSave(
}
let mainResult: any;
if (isUpdate && pkValue) {
// UPDATE
const updateColumns = Object.keys(mainData)
.filter((col) => col !== pkColumn)
.filter(col => col !== pkColumn)
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainData)
.filter((col) => col !== pkColumn)
.map((col) => mainData[col]);
.filter(col => col !== pkColumn)
.map(col => mainData[col]);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(
`
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`,
[mainTableName]
);
const updatedAtClause =
hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0
? ", updated_at = NOW()"
: "";
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateQuery = `
UPDATE "${mainTableName}"
@@ -1993,43 +1934,29 @@ export async function multiTableSave(
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
RETURNING *
`;
const updateParams =
companyCode !== "*"
? [...updateValues, pkValue, companyCode]
: [...updateValues, pkValue];
logger.info("메인 테이블 UPDATE:", {
query: updateQuery,
paramsCount: updateParams.length,
});
const updateParams = companyCode !== "*"
? [...updateValues, pkValue, companyCode]
: [...updateValues, pkValue];
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
mainResult = await client.query(updateQuery, updateParams);
} else {
// INSERT
const columns = Object.keys(mainData)
.map((col) => `"${col}"`)
.join(", ");
const placeholders = Object.keys(mainData)
.map((_, idx) => `$${idx + 1}`)
.join(", ");
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
const values = Object.values(mainData);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(
`
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`,
[mainTableName]
);
const updatedAtClause =
hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0
? ", updated_at = NOW()"
: "";
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateSetClause = Object.keys(mainData)
.filter((col) => col !== pkColumn)
.map((col) => `"${col}" = EXCLUDED."${col}"`)
.filter(col => col !== pkColumn)
.map(col => `"${col}" = EXCLUDED."${col}"`)
.join(", ");
const insertQuery = `
@@ -2040,10 +1967,7 @@ export async function multiTableSave(
RETURNING *
`;
logger.info("메인 테이블 INSERT/UPSERT:", {
query: insertQuery,
paramsCount: values.length,
});
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
mainResult = await client.query(insertQuery, values);
}
@@ -2062,15 +1986,12 @@ export async function multiTableSave(
const { tableName, linkColumn, items, options } = subTableConfig;
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
const hasSaveMainAsFirst =
options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
logger.info(
`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`
);
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
continue;
}
@@ -2083,20 +2004,15 @@ export async function multiTableSave(
// 기존 데이터 삭제 옵션
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
const deleteQuery =
options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
const deleteParams =
options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, {
deleteQuery,
deleteParams,
});
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
await client.query(deleteQuery, deleteParams);
}
@@ -2109,12 +2025,7 @@ export async function multiTableSave(
linkColumn,
mainDataKeys: Object.keys(mainData),
});
if (
options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0 &&
linkColumn?.subColumn
) {
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
const mainSubItem: Record<string, any> = {
[linkColumn.subColumn]: savedPkValue,
};
@@ -2128,8 +2039,7 @@ export async function multiTableSave(
// 메인 마커 설정
if (options.mainMarkerColumn) {
mainSubItem[options.mainMarkerColumn] =
options.mainMarkerValue ?? true;
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
}
// company_code 추가
@@ -2152,30 +2062,20 @@ export async function multiTableSave(
if (companyCode !== "*") {
checkParams.push(companyCode);
}
const existingResult = await client.query(checkQuery, checkParams);
if (existingResult.rows.length > 0) {
// UPDATE
const updateColumns = Object.keys(mainSubItem)
.filter(
(col) =>
col !== linkColumn.subColumn &&
col !== options.mainMarkerColumn &&
col !== "company_code"
)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainSubItem)
.filter(
(col) =>
col !== linkColumn.subColumn &&
col !== options.mainMarkerColumn &&
col !== "company_code"
)
.map((col) => mainSubItem[col]);
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map(col => mainSubItem[col]);
if (updateColumns) {
const updateQuery = `
UPDATE "${tableName}"
@@ -2194,26 +2094,14 @@ export async function multiTableSave(
}
const updateResult = await client.query(updateQuery, updateParams);
subTableResults.push({
tableName,
type: "main",
data: updateResult.rows[0],
});
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
} else {
subTableResults.push({
tableName,
type: "main",
data: existingResult.rows[0],
});
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
}
} else {
// INSERT
const mainSubColumns = Object.keys(mainSubItem)
.map((col) => `"${col}"`)
.join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem)
.map((_, idx) => `$${idx + 1}`)
.join(", ");
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
const mainSubValues = Object.values(mainSubItem);
const insertQuery = `
@@ -2223,11 +2111,7 @@ export async function multiTableSave(
`;
const insertResult = await client.query(insertQuery, mainSubValues);
subTableResults.push({
tableName,
type: "main",
data: insertResult.rows[0],
});
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
}
}
@@ -2243,12 +2127,8 @@ export async function multiTableSave(
item.company_code = companyCode;
}
const subColumns = Object.keys(item)
.map((col) => `"${col}"`)
.join(", ");
const subPlaceholders = Object.keys(item)
.map((_, idx) => `$${idx + 1}`)
.join(", ");
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
const subValues = Object.values(item);
const subInsertQuery = `
@@ -2257,16 +2137,9 @@ export async function multiTableSave(
RETURNING *
`;
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, {
subInsertQuery,
subValuesCount: subValues.length,
});
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
const subResult = await client.query(subInsertQuery, subValues);
subTableResults.push({
tableName,
type: "sub",
data: subResult.rows[0],
});
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
}
logger.info(`서브 테이블 ${tableName} 저장 완료`);
@@ -2307,11 +2180,8 @@ export async function multiTableSave(
}
/**
* 두 테이블 간 엔티티 관계 자동 감지
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
* 두 테이블 간 엔티티 관계 조회
* column_labels의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
@@ -2320,54 +2190,93 @@ export async function getTableEntityRelations(
try {
const { leftTable, rightTable } = req.query;
logger.info(
`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`
);
if (!leftTable || !rightTable) {
const response: ApiResponse<null> = {
res.status(400).json({
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
});
return;
}
const tableManagementService = new TableManagementService();
const relations = await tableManagementService.detectTableEntityRelations(
String(leftTable),
String(rightTable)
);
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
// 두 테이블의 컬럼 라벨 정보 조회
const columnLabelsQuery = `
SELECT
table_name,
column_name,
column_label,
web_type,
detail_settings
FROM column_labels
WHERE table_name IN ($1, $2)
AND web_type IN ('entity', 'category')
`;
const response: ApiResponse<any> = {
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
// 관계 분석
const relations: Array<{
fromTable: string;
fromColumn: string;
toTable: string;
toColumn: string;
relationType: string;
}> = [];
for (const row of result) {
try {
const detailSettings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings)
: row.detail_settings;
if (detailSettings && detailSettings.referenceTable) {
const refTable = detailSettings.referenceTable;
const refColumn = detailSettings.referenceColumn || "id";
// leftTable과 rightTable 간의 관계인지 확인
if (
(row.table_name === leftTable && refTable === rightTable) ||
(row.table_name === rightTable && refTable === leftTable)
) {
relations.push({
fromTable: row.table_name,
fromColumn: row.column_name,
toTable: refTable,
toColumn: refColumn,
relationType: row.web_type,
});
}
}
} catch (parseError) {
logger.warn("detail_settings 파싱 오류:", {
table: row.table_name,
column: row.column_name,
error: parseError
});
}
}
logger.info("테이블 엔티티 관계 조회 완료", {
leftTable,
rightTable,
relationsCount: relations.length
});
res.json({
success: true,
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
data: {
leftTable: String(leftTable),
rightTable: String(rightTable),
leftTable,
rightTable,
relations,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
});
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
error: {
code: "ENTITY_RELATIONS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
message: "테이블 엔티티 관계 조회에 실패했습니다.",
error: error.message,
});
}
}