Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal

This commit is contained in:
kjs
2026-01-15 09:22:31 +09:00
194 changed files with 52224 additions and 4678 deletions

View File

@@ -553,10 +553,24 @@ export const setUserLocale = async (
const { locale } = req.body;
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
if (!locale) {
res.status(400).json({
success: false,
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
message: "로케일이 필요합니다.",
});
return;
}
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
success: false,
message: `유효하지 않은 로케일입니다: ${locale}`,
});
return;
}
@@ -1165,6 +1179,33 @@ export async function saveMenu(
logger.info("메뉴 저장 성공", { savedMenu });
// 다국어 메뉴 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
// 메뉴 경로 조회 및 카테고리 생성
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
logger.info("메뉴 다국어 카테고리 생성 완료", {
menuObjId: savedMenu.objid.toString(),
menuPath,
});
} catch (categoryError) {
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
menuObjId: savedMenu.objid.toString(),
error: categoryError,
});
}
const response: ApiResponse<any> = {
success: true,
message: "메뉴가 성공적으로 저장되었습니다.",
@@ -2649,6 +2690,24 @@ export const createCompany = async (
});
}
// 다국어 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
await multilangService.ensureCompanyCategory(
createdCompany.company_code,
createdCompany.company_name
);
logger.info("회사 다국어 카테고리 생성 완료", {
companyCode: createdCompany.company_code,
});
} catch (categoryError) {
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
companyCode: createdCompany.company_code,
error: categoryError,
});
}
logger.info("회사 등록 성공", {
companyCode: createdCompany.company_code,
companyName: createdCompany.company_name,
@@ -3058,6 +3117,23 @@ export const updateProfile = async (
}
if (locale !== undefined) {
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
result: false,
error: {
code: "INVALID_LOCALE",
details: `유효하지 않은 로케일입니다: ${locale}`,
},
});
return;
}
updateFields.push(`locale = $${paramIndex}`);
updateValues.push(locale);
paramIndex++;

View File

@@ -282,3 +282,175 @@ export async function previewCodeMerge(
}
}
/**
* 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경
* 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경
*/
export async function mergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue, newValue } = req.body;
const companyCode = req.user?.companyCode;
try {
// 입력값 검증
if (!oldValue || !newValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
// 같은 값으로 병합 시도 방지
if (oldValue === newValue) {
res.status(400).json({
success: false,
message: "기존 값과 새 값이 동일합니다.",
});
return;
}
logger.info("값 기반 코드 병합 시작", {
oldValue,
newValue,
companyCode,
userId: req.user?.userId,
});
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM merge_code_by_value($1, $2, $3)",
[oldValue, newValue, companyCode]
);
// 결과 처리
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = affectedData.reduce(
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
0
);
logger.info("값 기반 코드 병합 완료", {
oldValue,
newValue,
affectedTablesCount: affectedData.length,
totalRowsUpdated: totalRows,
});
res.json({
success: true,
message: `코드 병합 완료: ${oldValue}${newValue}`,
data: {
oldValue,
newValue,
affectedData: affectedData.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
rowsUpdated: parseInt(row.out_rows_updated),
})),
totalRowsUpdated: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 실패:", {
error: error.message,
stack: error.stack,
oldValue,
newValue,
});
res.status(500).json({
success: false,
message: "코드 병합 중 오류가 발생했습니다.",
error: {
code: "CODE_MERGE_BY_VALUE_ERROR",
details: error.message,
},
});
}
}
/**
* 값 기반 코드 병합 미리보기
* 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회
*/
export async function previewMergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue } = req.body;
const companyCode = req.user?.companyCode;
try {
if (!oldValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM preview_merge_code_by_value($1, $2)",
[oldValue, companyCode]
);
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = preview.reduce(
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
0
);
logger.info("값 기반 코드 병합 미리보기 완료", {
tablesCount: preview.length,
totalRows,
});
res.json({
success: true,
message: "코드 병합 미리보기 완료",
data: {
oldValue,
preview: preview.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
affectedRows: parseInt(row.out_affected_rows),
})),
totalAffectedRows: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
error: {
code: "PREVIEW_BY_VALUE_ERROR",
details: error.message,
},
});
}
}

View File

@@ -231,7 +231,7 @@ export const deleteFormData = async (
try {
const { id } = req.params;
const { companyCode, userId } = req.user as any;
const { tableName } = req.body;
const { tableName, screenId } = req.body;
if (!tableName) {
return res.status(400).json({
@@ -240,7 +240,16 @@ export const deleteFormData = async (
});
}
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
await dynamicFormService.deleteFormData(
id,
tableName,
companyCode,
userId,
parsedScreenId // screenId 추가 (제어관리 실행용)
);
res.json({
success: true,

View File

@@ -30,6 +30,7 @@ export class EntityJoinController {
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@@ -49,6 +50,9 @@ 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 = {};
@@ -66,11 +70,23 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField];
if (userValue) {
searchConditions[filterColumn] = userValue;
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn,
userValue,
finalCompanyCode,
tableName,
});
}
@@ -139,6 +155,24 @@ 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,
{
@@ -156,13 +190,26 @@ 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: result,
data: finalData,
});
} catch (error) {
logger.error("Entity 조인 데이터 조회 실패", error);
@@ -537,6 +584,98 @@ 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

@@ -202,14 +202,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
}
// 추가 필터 조건 (존재하는 컬럼만)
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
// 특수 키 형식: column__operator (예: division__in, name__like)
const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) {
if (existingColumns.has(key)) {
whereConditions.push(`${key} = $${paramIndex}`);
params.push(value);
paramIndex++;
} else {
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
// 특수 키 형식 파싱: column__operator
let columnName = key;
let operator = "=";
if (key.includes("__")) {
const parts = key.split("__");
columnName = parts[0];
operator = parts[1] || "=";
}
if (!existingColumns.has(columnName)) {
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
continue;
}
// 연산자별 WHERE 조건 생성
switch (operator) {
case "=":
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "!=":
whereConditions.push(`"${columnName}" != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">":
whereConditions.push(`"${columnName}" > $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<":
whereConditions.push(`"${columnName}" < $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">=":
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<=":
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in":
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (inValues.length > 0) {
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" IN (${placeholders})`);
params.push(...inValues);
paramIndex += inValues.length;
}
break;
case "notIn":
// NOT IN 연산자
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (notInValues.length > 0) {
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
params.push(...notInValues);
paramIndex += notInValues.length;
}
break;
case "like":
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
default:
// 알 수 없는 연산자는 등호로 처리
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
}
}

View File

@@ -0,0 +1,208 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import excelMappingService from "../services/excelMappingService";
import { logger } from "../utils/logger";
/**
* 엑셀 컬럼 구조로 매핑 템플릿 조회
* POST /api/excel-mapping/find
*/
export async function findMappingByColumns(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
res.status(400).json({
success: false,
message: "tableName과 excelColumns(배열)가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 조회 요청", {
tableName,
excelColumns,
companyCode,
userId: req.user?.userId,
});
const template = await excelMappingService.findMappingByColumns(
tableName,
excelColumns,
companyCode
);
if (template) {
res.json({
success: true,
data: template,
message: "기존 매핑 템플릿을 찾았습니다.",
});
} else {
res.json({
success: true,
data: null,
message: "일치하는 매핑 템플릿이 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 매핑 템플릿 저장 (UPSERT)
* POST /api/excel-mapping/save
*/
export async function saveMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns, columnMappings } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!tableName || !excelColumns || !columnMappings) {
res.status(400).json({
success: false,
message: "tableName, excelColumns, columnMappings가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 저장 요청", {
tableName,
excelColumns,
columnMappings,
companyCode,
userId,
});
const template = await excelMappingService.saveMappingTemplate(
tableName,
excelColumns,
columnMappings,
companyCode,
userId
);
res.json({
success: true,
data: template,
message: "매핑 템플릿이 저장되었습니다.",
});
} catch (error: any) {
logger.error("매핑 템플릿 저장 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 테이블의 매핑 템플릿 목록 조회
* GET /api/excel-mapping/list/:tableName
*/
export async function getMappingTemplates(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!tableName) {
res.status(400).json({
success: false,
message: "tableName이 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 목록 조회 요청", {
tableName,
companyCode,
});
const templates = await excelMappingService.getMappingTemplates(
tableName,
companyCode
);
res.json({
success: true,
data: templates,
});
} catch (error: any) {
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 매핑 템플릿 삭제
* DELETE /api/excel-mapping/:id
*/
export async function deleteMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!id) {
res.status(400).json({
success: false,
message: "id가 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 삭제 요청", {
id,
companyCode,
});
const deleted = await excelMappingService.deleteMappingTemplate(
parseInt(id),
companyCode
);
if (deleted) {
res.json({
success: true,
message: "매핑 템플릿이 삭제되었습니다.",
});
} else {
res.status(404).json({
success: false,
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

@@ -10,7 +10,10 @@ import {
SaveLangTextsRequest,
GetUserTextParams,
BatchTranslationRequest,
GenerateKeyRequest,
CreateOverrideKeyRequest,
ApiResponse,
LangCategory,
} from "../types/multilang";
/**
@@ -187,7 +190,7 @@ export const getLangKeys = async (
res: Response
): Promise<void> => {
try {
const { companyCode, menuCode, keyType, searchText } = req.query;
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
logger.info("다국어 키 목록 조회 요청", {
query: req.query,
user: req.user,
@@ -199,6 +202,7 @@ export const getLangKeys = async (
menuCode: menuCode as string,
keyType: keyType as string,
searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
});
const response: ApiResponse<any[]> = {
@@ -630,6 +634,391 @@ export const deleteLanguage = async (
}
};
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
* GET /api/multilang/categories
* 카테고리 목록 조회 API (트리 구조)
*/
export const getCategories = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.info("카테고리 목록 조회 요청", { user: req.user });
const multiLangService = new MultiLangService();
const categories = await multiLangService.getCategories();
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 목록 조회 성공",
data: categories,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId
* 카테고리 상세 조회 API
*/
export const getCategoryById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const category = await multiLangService.getCategoryById(parseInt(categoryId));
if (!category) {
res.status(404).json({
success: false,
message: "카테고리를 찾을 수 없습니다.",
error: {
code: "CATEGORY_NOT_FOUND",
details: `Category ID ${categoryId} not found`,
},
});
return;
}
const response: ApiResponse<LangCategory> = {
success: true,
message: "카테고리 상세 조회 성공",
data: category,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_DETAIL_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId/path
* 카테고리 경로 조회 API (부모 포함)
*/
export const getCategoryPath = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 경로 조회 성공",
data: path,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 경로 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_PATH_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
// =====================================================
// 자동 생성 및 오버라이드 관련 API
// =====================================================
/**
* POST /api/multilang/keys/generate
* 키 자동 생성 API
*/
export const generateKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const generateData: GenerateKeyRequest = req.body;
logger.info("키 자동 생성 요청", { generateData, user: req.user });
// 필수 입력값 검증
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
res.status(400).json({
success: false,
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode, categoryId, and keyMeaning are required",
},
});
return;
}
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
res.status(403).json({
success: false,
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Only super admin can create common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 키만 생성 가능
if (generateData.companyCode !== "*" &&
req.user?.companyCode !== "*" &&
generateData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.generateKey({
...generateData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("키 자동 생성 실패:", error);
res.status(500).json({
success: false,
message: "키 자동 생성 중 오류가 발생했습니다.",
error: {
code: "KEY_GENERATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/preview
* 키 미리보기 API
*/
export const previewKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId, keyMeaning, companyCode } = req.body;
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
if (!categoryId || !keyMeaning || !companyCode) {
res.status(400).json({
success: false,
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "categoryId, keyMeaning, and companyCode are required",
},
});
return;
}
const multiLangService = new MultiLangService();
const preview = await multiLangService.previewGeneratedKey(
parseInt(categoryId),
keyMeaning,
companyCode
);
const response: ApiResponse<{
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}> = {
success: true,
message: "키 미리보기 성공",
data: preview,
};
res.status(200).json(response);
} catch (error) {
logger.error("키 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "키 미리보기 중 오류가 발생했습니다.",
error: {
code: "KEY_PREVIEW_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/override
* 오버라이드 키 생성 API
*/
export const createOverrideKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const overrideData: CreateOverrideKeyRequest = req.body;
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
// 필수 입력값 검증
if (!overrideData.companyCode || !overrideData.baseKeyId) {
res.status(400).json({
success: false,
message: "회사 코드와 원본 키 ID는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode and baseKeyId are required",
},
});
return;
}
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
if (overrideData.companyCode === "*") {
res.status(400).json({
success: false,
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
error: {
code: "INVALID_OVERRIDE",
details: "Cannot create override for common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
if (req.user?.companyCode !== "*" &&
overrideData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.createOverrideKey({
...overrideData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "오버라이드 키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("오버라이드 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEY_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/keys/overrides/:companyCode
* 회사별 오버라이드 키 목록 조회 API
*/
export const getOverrideKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot view override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keys = await multiLangService.getOverrideKeys(companyCode);
const response: ApiResponse<any[]> = {
success: true,
message: "오버라이드 키 목록 조회 성공",
data: keys,
};
res.status(200).json(response);
} catch (error) {
logger.error("오버라이드 키 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEYS_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/batch
* 다국어 텍스트 배치 조회 API
@@ -710,3 +1099,86 @@ export const getBatchTranslations = async (
});
}
};
/**
* POST /api/multilang/screen-labels
* 화면 라벨 다국어 키 자동 생성 API
*/
export const generateScreenLabelKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, menuObjId, labels } = req.body;
logger.info("화면 라벨 다국어 키 생성 요청", {
screenId,
menuObjId,
labelCount: labels?.length,
user: req.user,
});
// 필수 파라미터 검증
if (!screenId) {
res.status(400).json({
success: false,
message: "screenId는 필수입니다.",
error: { code: "MISSING_SCREEN_ID" },
});
return;
}
if (!labels || !Array.isArray(labels) || labels.length === 0) {
res.status(400).json({
success: false,
message: "labels 배열이 필요합니다.",
error: { code: "MISSING_LABELS" },
});
return;
}
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
const { queryOne } = await import("../database/db");
const screenInfo = await queryOne<{ company_code: string }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
const multiLangService = new MultiLangService();
const results = await multiLangService.generateScreenLabelKeys({
screenId: Number(screenId),
companyCode,
companyName,
menuObjId,
labels,
});
const response: ApiResponse<typeof results> = {
success: true,
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
data: results,
};
res.status(200).json(response);
} catch (error) {
logger.error("화면 라벨 다국어 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};

View File

@@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
logger.info("코드 할당 요청", { ruleId, companyCode });
try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) {
logger.error("코드 할당 실패", { error: error.message });
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -780,13 +780,25 @@ export async function getTableData(
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField];
if (userValue) {
enhancedSearch[filterColumn] = userValue;
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn,
userField,
userValue,
userValue: finalCompanyCode,
tableName,
});
} else {
@@ -797,6 +809,12 @@ export async function getTableData(
}
}
// 🆕 최종 검색 조건 로그
logger.info(
`🔍 최종 검색 조건 (enhancedSearch):`,
JSON.stringify(enhancedSearch)
);
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page),
@@ -880,7 +898,10 @@ 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}`);
@@ -890,7 +911,10 @@ 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}`);
@@ -898,13 +922,25 @@ export async function addTableData(
}
// 데이터 추가
await tableManagementService.addTableData(tableName, data);
const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
const response: ApiResponse<null> = {
// 무시된 컬럼이 있으면 경고 정보 포함
const response: ApiResponse<{
skippedColumns?: string[];
savedColumns?: string[];
}> = {
success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.",
message:
result.skippedColumns.length > 0
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
: "테이블 데이터를 성공적으로 추가했습니다.",
data: {
skippedColumns:
result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
savedColumns: result.savedColumns,
},
};
res.status(201).json(response);
@@ -1632,10 +1668,10 @@ export async function toggleLogTable(
/**
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
*
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회
*
*
* 예시:
* - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정
* - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속)
@@ -1648,7 +1684,10 @@ 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({
@@ -1674,8 +1713,11 @@ 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 (
@@ -1697,17 +1739,21 @@ 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
@@ -1737,20 +1783,31 @@ 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
@@ -1760,11 +1817,17 @@ 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({
@@ -1774,7 +1837,7 @@ export async function getCategoryColumnsByMenu(
});
return;
}
const columnsQuery = `
SELECT
ttc.table_name AS "tableName",
@@ -1799,13 +1862,15 @@ 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({
@@ -1830,9 +1895,9 @@ export async function getCategoryColumnsByMenu(
/**
* 범용 다중 테이블 저장 API
*
*
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
*
*
* 요청 본문:
* {
* mainTable: { tableName: string, primaryKeyColumn: string },
@@ -1902,23 +1967,29 @@ 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}"
@@ -1927,29 +1998,43 @@ 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 = `
@@ -1960,7 +2045,10 @@ 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);
}
@@ -1979,12 +2067,15 @@ 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;
}
@@ -1997,15 +2088,20 @@ 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 deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
const deleteQuery =
options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
const deleteParams =
options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, {
deleteQuery,
deleteParams,
});
await client.query(deleteQuery, deleteParams);
}
@@ -2018,7 +2114,12 @@ 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,
};
@@ -2032,7 +2133,8 @@ export async function multiTableSave(
// 메인 마커 설정
if (options.mainMarkerColumn) {
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
mainSubItem[options.mainMarkerColumn] =
options.mainMarkerValue ?? true;
}
// company_code 추가
@@ -2055,20 +2157,30 @@ 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}"
@@ -2087,14 +2199,26 @@ 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 = `
@@ -2104,7 +2228,11 @@ 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],
});
}
}
@@ -2120,8 +2248,12 @@ 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 = `
@@ -2130,9 +2262,16 @@ 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} 저장 완료`);
@@ -2172,3 +2311,68 @@ export async function multiTableSave(
}
}
/**
* 두 테이블 간의 엔티티 관계 자동 감지
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { leftTable, rightTable } = req.query;
logger.info(
`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`
);
if (!leftTable || !rightTable) {
const response: ApiResponse<null> = {
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(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
const response: ApiResponse<any> = {
success: true,
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
data: {
leftTable: String(leftTable),
rightTable: String(rightTable),
relations,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
error: {
code: "ENTITY_RELATIONS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}