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
+22 -2
View File
@@ -42,6 +42,7 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
@@ -1043,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2370,6 +2372,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@@ -3214,6 +3217,16 @@
"@types/node": "*"
}
},
"node_modules/@types/bwip-js": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
@@ -3463,6 +3476,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3699,6 +3713,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -3916,6 +3931,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4442,6 +4458,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -5652,6 +5669,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -7414,6 +7432,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -8383,7 +8402,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -9272,6 +9290,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -10122,7 +10141,6 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -10931,6 +10949,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -11036,6 +11055,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+1
View File
@@ -56,6 +56,7 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
+2
View File
@@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
@@ -222,6 +223,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes);
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
@@ -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++;
@@ -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,
},
});
}
}
@@ -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,
@@ -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();
@@ -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;
}
}
@@ -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,
});
}
}
@@ -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",
},
});
}
};
@@ -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
@@ -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);
}
}
+18 -2
View File
@@ -3,6 +3,8 @@ import {
mergeCodeAllTables,
getTablesWithColumn,
previewCodeMerge,
mergeCodeByValue,
previewMergeCodeByValue,
} from "../controllers/codeMergeController";
import { authenticateToken } from "../middleware/authMiddleware";
@@ -13,7 +15,7 @@ router.use(authenticateToken);
/**
* POST /api/code-merge/merge-all-tables
* 코드 병합 실행 (모든 관련 테이블에 적용)
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
* Body: { columnName, oldValue, newValue }
*/
router.post("/merge-all-tables", mergeCodeAllTables);
@@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
/**
* POST /api/code-merge/preview
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
* 코드 병합 미리보기 (같은 컬럼명 기준)
* Body: { columnName, oldValue }
*/
router.post("/preview", previewCodeMerge);
/**
* POST /api/code-merge/merge-by-value
* 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경)
* Body: { oldValue, newValue }
*/
router.post("/merge-by-value", mergeCodeByValue);
/**
* POST /api/code-merge/preview-by-value
* 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색)
* Body: { oldValue }
*/
router.post("/preview-by-value", previewMergeCodeByValue);
export default router;
+256 -2
View File
@@ -1,10 +1,262 @@
import express from "express";
import { dataService } from "../services/dataService";
import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
const router = express.Router();
// ================================
// 마스터-디테일 엑셀 API
// ================================
/**
* 마스터-디테일 관계 정보 조회
* GET /api/data/master-detail/relation/:screenId
*/
router.get(
"/master-detail/relation/:screenId",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId } = req.params;
if (!screenId || isNaN(parseInt(screenId))) {
return res.status(400).json({
success: false,
message: "유효한 screenId가 필요합니다.",
});
}
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.json({
success: true,
data: null,
message: "마스터-디테일 구조가 아닙니다.",
});
}
console.log(`✅ 마스터-디테일 관계 발견:`, {
masterTable: relation.masterTable,
detailTable: relation.detailTable,
joinKey: relation.masterKeyColumn,
});
return res.json({
success: true,
data: relation,
});
} catch (error: any) {
console.error("마스터-디테일 관계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* 마스터-디테일 엑셀 다운로드 데이터 조회
* POST /api/data/master-detail/download
*/
router.post(
"/master-detail/download",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, filters } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!screenId) {
return res.status(400).json({
success: false,
message: "screenId가 필요합니다.",
});
}
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. JOIN 데이터 조회
const data = await masterDetailExcelService.getJoinedData(
relation,
companyCode,
filters
);
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}`);
return res.json({
success: true,
data,
});
} catch (error: any) {
console.error("마스터-디테일 다운로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* 마스터-디테일 엑셀 업로드
* POST /api/data/master-detail/upload
*/
router.post(
"/master-detail/upload",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, data } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!screenId || !data || !Array.isArray(data)) {
return res.status(400).json({
success: false,
message: "screenId와 data 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. 데이터 업로드
const result = await masterDetailExcelService.uploadJoinedData(
relation,
data,
companyCode,
userId
);
console.log(`✅ 마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* 마스터-디테일 간단 모드 엑셀 업로드
* - 마스터 정보는 UI에서 선택
* - 디테일 정보만 엑셀에서 업로드
* - 채번 규칙을 통해 마스터 키 자동 생성
*
* POST /api/data/master-detail/upload-simple
*/
router.post(
"/master-detail/upload-simple",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
if (!screenId || !detailData || !Array.isArray(detailData)) {
return res.status(400).json({
success: false,
message: "screenId와 detailData 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
console.log(` 마스터 필드 값:`, masterFieldValues);
console.log(` 채번 규칙 ID:`, numberingRuleId);
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}` : afterUploadFlowId || "없음");
// 업로드 실행
const result = await masterDetailExcelService.uploadSimple(
parseInt(screenId),
detailData,
masterFieldValues || {},
numberingRuleId,
companyCode,
userId,
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
afterUploadFlows // 업로드 후 제어 실행 (다중)
);
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
// ================================
// 기존 데이터 API
// ================================
/**
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
@@ -698,6 +950,7 @@ router.post(
try {
const { tableName } = req.params;
const filterConditions = req.body;
const userCompany = req.user?.companyCode;
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
@@ -706,11 +959,12 @@ router.post(
});
}
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
const result = await dataService.deleteGroupRecords(
tableName,
filterConditions
filterConditions,
userCompany // 회사 코드 전달
);
if (!result.success) {
@@ -0,0 +1,25 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
findMappingByColumns,
saveMappingTemplate,
getMappingTemplates,
deleteMappingTemplate,
} from "../controllers/excelMappingController";
const router = Router();
// 엑셀 컬럼 구조로 매핑 템플릿 조회
router.post("/find", authenticateToken, findMappingByColumns);
// 매핑 템플릿 저장 (UPSERT)
router.post("/save", authenticateToken, saveMappingTemplate);
// 테이블의 매핑 템플릿 목록 조회
router.get("/list/:tableName", authenticateToken, getMappingTemplates);
// 매핑 템플릿 삭제
router.delete("/:id", authenticateToken, deleteMappingTemplate);
export default router;
@@ -21,6 +21,20 @@ import {
getUserText,
getLangText,
getBatchTranslations,
// 카테고리 관리 API
getCategories,
getCategoryById,
getCategoryPath,
// 자동 생성 및 오버라이드 API
generateKey,
previewKey,
createOverrideKey,
getOverrideKeys,
// 화면 라벨 다국어 API
generateScreenLabelKeys,
} from "../controllers/multilangController";
const router = express.Router();
@@ -51,4 +65,18 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
// 카테고리 관리 API
router.get("/categories", getCategories); // 카테고리 트리 조회
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
// 자동 생성 및 오버라이드 API
router.post("/keys/generate", generateKey); // 키 자동 생성
router.post("/keys/preview", previewKey); // 키 미리보기
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
// 화면 라벨 다국어 자동 생성 API
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
export default router;
@@ -25,6 +25,7 @@ import {
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
} from "../controllers/tableManagementController";
const router = express.Router();
@@ -38,6 +39,15 @@ router.use(authenticateToken);
*/
router.get("/tables", getTableList);
/**
* 두 테이블 간 엔티티 관계 조회
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
*/
router.get("/tables/entity-relations", getTableEntityRelations);
/**
* 테이블 컬럼 정보 조회
* GET /api/table-management/tables/:tableName/columns
+18
View File
@@ -65,6 +65,13 @@ export class AdminService {
}
);
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요
logger.info(
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
/* [원본 코드 - 권한 그룹 체크]
if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) {
@@ -141,6 +148,7 @@ export class AdminService {
return [];
}
}
*/
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
@@ -412,6 +420,15 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요
logger.info(
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
authFilter = "";
unionFilter = "";
/* [원본 코드 - getUserMenuList 권한 그룹 체크]
if (userType === "SUPER_ADMIN") {
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
@@ -471,6 +488,7 @@ export class AdminService {
return [];
}
}
*/
// 2. 회사별 필터링 조건 생성
let companyFilter = "";
+43 -3
View File
@@ -1189,6 +1189,13 @@ class DataService {
[tableName]
);
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
pkColumns: pkResult.map((r) => r.attname),
pkCount: pkResult.length,
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
inputIdType: typeof id,
});
let whereClauses: string[] = [];
let params: any[] = [];
@@ -1216,17 +1223,31 @@ class DataService {
params.push(typeof id === "object" ? id[pkColumn] : id);
}
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params);
// 삭제된 행이 없으면 실패 처리
if (result.length === 0) {
console.warn(
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
{ whereClauses, params }
);
return {
success: false,
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
console.log(
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
);
return {
success: true,
data: result[0], // 삭제된 레코드 정보 반환
};
} catch (error) {
console.error(`레코드 삭제 오류 (${tableName}):`, error);
@@ -1240,10 +1261,14 @@ class DataService {
/**
* ( )
* @param tableName
* @param filterConditions
* @param userCompany ( )
*/
async deleteGroupRecords(
tableName: string,
filterConditions: Record<string, any>
filterConditions: Record<string, any>,
userCompany?: string
): Promise<ServiceResponse<{ deleted: number }>> {
try {
const validation = await this.validateTableAccess(tableName);
@@ -1255,6 +1280,7 @@ class DataService {
const whereValues: any[] = [];
let paramIndex = 1;
// 사용자 필터 조건 추가
for (const [key, value] of Object.entries(filterConditions)) {
whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value);
@@ -1269,10 +1295,24 @@ class DataService {
};
}
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
if (hasCompanyCode && userCompany && userCompany !== "*") {
whereConditions.push(`"company_code" = $${paramIndex}`);
whereValues.push(userCompany);
paramIndex++;
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
}
const whereClause = whereConditions.join(" AND ");
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
console.log(`🗑️ 그룹 삭제:`, {
tableName,
conditions: filterConditions,
userCompany,
whereClause,
});
const result = await pool.query(deleteQuery, whereValues);
+47 -11
View File
@@ -1,6 +1,7 @@
import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
import tableCategoryValueService from "./tableCategoryValueService";
export interface FormDataResult {
id: number;
@@ -427,6 +428,24 @@ export class DynamicFormService {
dataToInsert,
});
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
const companyCodeForCategory = company_code || "*";
const { convertedData: categoryConvertedData, conversions } =
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
tableName,
companyCodeForCategory,
dataToInsert
);
if (conversions.length > 0) {
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}`, conversions);
// 변환된 데이터로 교체
Object.assign(dataToInsert, categoryConvertedData);
} else {
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
}
// 테이블 컬럼 정보 조회하여 타입 변환 적용
console.log("🔍 테이블 컬럼 정보 조회 중...");
const columnInfo = await this.getTableColumnInfo(tableName);
@@ -1173,12 +1192,18 @@ export class DynamicFormService {
/**
* ( )
* @param id ID
* @param tableName
* @param companyCode
* @param userId ID
* @param screenId ID ( , )
*/
async deleteFormData(
id: string | number,
tableName: string,
companyCode?: string,
userId?: string
userId?: string,
screenId?: number
): Promise<void> {
try {
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
@@ -1291,14 +1316,19 @@ export class DynamicFormService {
const recordCompanyCode =
deletedRecord?.company_code || companyCode || "*";
await this.executeDataflowControlIfConfigured(
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
tableName,
deletedRecord,
"delete",
userId || "system",
recordCompanyCode
);
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
if (screenId && screenId > 0) {
await this.executeDataflowControlIfConfigured(
screenId,
tableName,
deletedRecord,
"delete",
userId || "system",
recordCompanyCode
);
} else {
console.log("️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
}
}
} catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError);
@@ -1643,10 +1673,16 @@ export class DynamicFormService {
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
});
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
const buttonActionType = properties?.componentConfig?.action?.type;
const isMatchingAction =
(triggerType === "delete" && buttonActionType === "delete") ||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
if (
properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" &&
isMatchingAction &&
properties?.webTypeConfig?.enableDataflowControl === true
) {
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
@@ -0,0 +1,283 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import crypto from "crypto";
export interface ExcelMappingTemplate {
id?: number;
tableName: string;
excelColumns: string[];
excelColumnsHash: string;
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
companyCode: string;
createdDate?: Date;
updatedDate?: Date;
}
class ExcelMappingService {
/**
*
* MD5
*/
generateColumnsHash(columns: string[]): string {
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
const sortedColumns = [...columns].sort();
const columnsString = sortedColumns.join("|");
return crypto.createHash("md5").update(columnsString).digest("hex");
}
/**
* 릿
*
*/
async findMappingByColumns(
tableName: string,
excelColumns: string[],
companyCode: string
): Promise<ExcelMappingTemplate | null> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 조회", {
tableName,
excelColumns,
hash,
companyCode,
});
const pool = getPool();
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
ORDER BY updated_date DESC
LIMIT 1
`;
params = [tableName, hash];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
AND (company_code = $3 OR company_code = '*')
ORDER BY
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
updated_date DESC
LIMIT 1
`;
params = [tableName, hash, companyCode];
}
const result = await pool.query(query, params);
if (result.rows.length > 0) {
logger.info("기존 매핑 템플릿 발견", {
id: result.rows[0].id,
tableName,
});
return result.rows[0];
}
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
return null;
} catch (error: any) {
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿 (UPSERT)
* ++ ,
*/
async saveMappingTemplate(
tableName: string,
excelColumns: string[],
columnMappings: Record<string, string | null>,
companyCode: string,
userId?: string
): Promise<ExcelMappingTemplate> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
tableName,
excelColumns,
hash,
columnMappings,
companyCode,
});
const pool = getPool();
const query = `
INSERT INTO excel_mapping_template (
table_name,
excel_columns,
excel_columns_hash,
column_mappings,
company_code,
created_date,
updated_date
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (table_name, excel_columns_hash, company_code)
DO UPDATE SET
column_mappings = EXCLUDED.column_mappings,
updated_date = NOW()
RETURNING
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
`;
const result = await pool.query(query, [
tableName,
excelColumns,
hash,
JSON.stringify(columnMappings),
companyCode,
]);
logger.info("매핑 템플릿 저장 완료", {
id: result.rows[0].id,
tableName,
hash,
});
return result.rows[0];
} catch (error: any) {
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async getMappingTemplates(
tableName: string,
companyCode: string
): Promise<ExcelMappingTemplate[]> {
try {
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
ORDER BY updated_date DESC
`;
params = [tableName];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND (company_code = $2 OR company_code = '*')
ORDER BY updated_date DESC
`;
params = [tableName, companyCode];
}
const result = await pool.query(query, params);
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
return result.rows;
} catch (error: any) {
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async deleteMappingTemplate(
id: number,
companyCode: string
): Promise<boolean> {
try {
logger.info("매핑 템플릿 삭제", { id, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
params = [id];
} else {
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
params = [id, companyCode];
}
const result = await pool.query(query, params);
if (result.rowCount && result.rowCount > 0) {
logger.info("매핑 템플릿 삭제 완료", { id });
return true;
}
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
return false;
} catch (error: any) {
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
throw error;
}
}
}
export default new ExcelMappingService();
@@ -0,0 +1,908 @@
/**
* -
*
* -
* / JOIN .
*/
import { query, queryOne, transaction, getPool } from "../database/db";
import { logger } from "../utils/logger";
// ================================
// 인터페이스 정의
// ================================
/**
* -
*/
export interface MasterDetailRelation {
masterTable: string;
detailTable: string;
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
masterColumns: ColumnInfo[];
detailColumns: ColumnInfo[];
}
/**
*
*/
export interface ColumnInfo {
name: string;
label: string;
inputType: string;
isFromMaster: boolean;
}
/**
*
*/
export interface SplitPanelConfig {
leftPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
};
rightPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
relation?: {
type: string;
foreignKey?: string;
leftColumn?: string;
// 복합키 지원 (새로운 방식)
keys?: Array<{
leftColumn: string;
rightColumn: string;
}>;
};
};
}
/**
*
*/
export interface ExcelDownloadData {
headers: string[]; // 컬럼 라벨들
columns: string[]; // 컬럼명들
data: Record<string, any>[];
masterColumns: string[]; // 마스터 컬럼 목록
detailColumns: string[]; // 디테일 컬럼 목록
joinKey: string; // 조인 키
}
/**
*
*/
export interface ExcelUploadResult {
success: boolean;
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailDeleted: number;
errors: string[];
}
// ================================
// 서비스 클래스
// ================================
class MasterDetailExcelService {
/**
* ID로
*/
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
try {
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
const result = await queryOne<any>(
`SELECT properties->>'componentConfig' as config
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties->>'componentType' = 'split-panel-layout'
LIMIT 1`,
[screenId]
);
if (!result || !result.config) {
logger.info(`분할 패널 없음: screenId=${screenId}`);
return null;
}
const config = typeof result.config === "string"
? JSON.parse(result.config)
: result.config;
logger.info(`분할 패널 설정 발견:`, {
leftTable: config.leftPanel?.tableName,
rightTable: config.rightPanel?.tableName,
relation: config.rightPanel?.relation,
});
return {
leftPanel: config.leftPanel,
rightPanel: config.rightPanel,
};
} catch (error: any) {
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
return null;
}
}
/**
* column_labels에서 Entity
*
*/
async getEntityRelation(
detailTable: string,
masterTable: string
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
try {
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
const result = await queryOne<any>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
LIMIT 1`,
[detailTable, masterTable]
);
if (!result) {
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
return null;
}
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
return {
detailFkColumn: result.column_name,
masterKeyColumn: result.reference_column,
};
} catch (error: any) {
logger.error(`Entity 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
*
*/
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
try {
const result = await query<any>(
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
const labelMap = new Map<string, string>();
for (const row of result) {
labelMap.set(row.column_name, row.column_label || row.column_name);
}
return labelMap;
} catch (error: any) {
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
return new Map();
}
}
/**
* -
*/
async getMasterDetailRelation(
screenId: number
): Promise<MasterDetailRelation | null> {
try {
// 1. 분할 패널 설정 조회
const splitPanel = await this.getSplitPanelConfig(screenId);
if (!splitPanel) {
return null;
}
const masterTable = splitPanel.leftPanel.tableName;
const detailTable = splitPanel.rightPanel.tableName;
if (!masterTable || !detailTable) {
logger.warn("마스터 또는 디테일 테이블명 없음");
return null;
}
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
let masterKeyColumn: string | undefined;
let detailFkColumn: string | undefined;
const relationKeys = splitPanel.rightPanel.relation?.keys;
if (relationKeys && relationKeys.length > 0) {
// keys 배열에서 첫 번째 키 사용
masterKeyColumn = relationKeys[0].leftColumn;
detailFkColumn = relationKeys[0].rightColumn;
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
} else {
// 하위 호환성: 기존 leftColumn/foreignKey 사용
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
}
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) {
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
if (entityRelation) {
masterKeyColumn = entityRelation.masterKeyColumn;
detailFkColumn = entityRelation.detailFkColumn;
}
}
if (!masterKeyColumn || !detailFkColumn) {
logger.warn("조인 키 정보를 찾을 수 없음");
return null;
}
// 4. 컬럼 라벨 정보 조회
const masterLabels = await this.getColumnLabels(masterTable);
const detailLabels = await this.getColumnLabels(detailTable);
// 5. 마스터 컬럼 정보 구성
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
name: col.name,
label: masterLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: true,
}));
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
.map(col => ({
name: col.name,
label: detailLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: false,
}));
logger.info(`마스터-디테일 관계 구성 완료:`, {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumnCount: masterColumns.length,
detailColumnCount: detailColumns.length,
});
return {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumns,
detailColumns,
};
} catch (error: any) {
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
* - JOIN ( )
*/
async getJoinedData(
relation: MasterDetailRelation,
companyCode: string,
filters?: Record<string, any>
): Promise<ExcelDownloadData> {
try {
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 조인 컬럼과 일반 컬럼 분리
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
const entityJoins: Array<{
refTable: string;
refColumn: string;
sourceColumn: string;
alias: string;
displayColumn: string;
}> = [];
// SELECT 절 구성
const selectParts: string[] = [];
let aliasIndex = 0;
// 마스터 컬럼 처리
for (const col of masterColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
// FK를 못 찾으면 NULL로 처리
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`m."${col.name}"`);
}
}
// 디테일 컬럼 처리
for (const col of detailColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`d."${col.name}"`);
}
}
const selectClause = selectParts.join(", ");
// 엔티티 조인 절 구성
const entityJoinClauses = entityJoins.map(ej =>
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
).join("\n ");
// WHERE 절 구성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (최고 관리자 제외)
if (companyCode && companyCode !== "*") {
whereConditions.push(`m.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 추가 필터 적용
if (filters) {
for (const [key, value] of Object.entries(filters)) {
if (value !== undefined && value !== null && value !== "") {
// 조인 컬럼인지 확인
if (key.includes(".")) continue;
// 마스터 테이블 컬럼인지 확인
const isMasterCol = masterColumns.some(c => c.name === key);
const tableAlias = isMasterCol ? "m" : "d";
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
params.push(value);
paramIndex++;
}
}
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// JOIN 쿼리 실행
const sql = `
SELECT ${selectClause}
FROM "${masterTable}" m
LEFT JOIN "${detailTable}" d
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
AND m.company_code = d.company_code
${entityJoinClauses}
${whereClause}
ORDER BY m."${masterKeyColumn}", d.id
`;
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
const data = await query<any>(sql, params);
// 헤더 및 컬럼 정보 구성
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}`);
return {
headers,
columns,
data,
masterColumns: masterColumns.map(c => c.name),
detailColumns: detailColumns.map(c => c.name),
joinKey: masterKeyColumn,
};
} catch (error: any) {
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
throw error;
}
}
/**
* FK
*/
private async findForeignKeyColumn(
sourceTable: string,
referenceTable: string
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
try {
const result = await query<{ column_name: string; reference_column: string }>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND reference_table = $2
AND input_type = 'entity'
LIMIT 1`,
[sourceTable, referenceTable]
);
if (result.length > 0) {
return {
sourceColumn: result[0].column_name,
referenceColumn: result[0].reference_column,
};
}
return null;
} catch (error) {
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
return null;
}
}
/**
* - ( )
*
* :
* 1.
* 2. UPSERT
* 3.
* 4. INSERT
*/
async uploadJoinedData(
relation: MasterDetailRelation,
data: Record<string, any>[],
companyCode: string,
userId?: string
): Promise<ExcelUploadResult> {
const result: ExcelUploadResult = {
success: false,
masterInserted: 0,
masterUpdated: 0,
detailInserted: 0,
detailDeleted: 0,
errors: [],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 1. 데이터를 마스터 키로 그룹화
const groupedData = new Map<string, Record<string, any>[]>();
for (const row of data) {
const masterKey = row[masterKeyColumn];
if (!masterKey) {
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
continue;
}
if (!groupedData.has(masterKey)) {
groupedData.set(masterKey, []);
}
groupedData.get(masterKey)!.push(row);
}
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
// 2. 각 그룹 처리
for (const [masterKey, rows] of groupedData.entries()) {
try {
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
const masterData: Record<string, any> = {};
for (const col of masterColumns) {
if (rows[0][col.name] !== undefined) {
masterData[col.name] = rows[0][col.name];
}
}
// 회사 코드, 작성자 추가
masterData.company_code = companyCode;
if (userId) {
masterData.writer = userId;
}
// 2b. 마스터 UPSERT
const existingMaster = await client.query(
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
if (existingMaster.rows.length > 0) {
// UPDATE
const updateCols = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map((k, i) => `"${k}" = $${i + 1}`);
const updateValues = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map(k => masterData[k]);
if (updateCols.length > 0) {
await client.query(
`UPDATE "${masterTable}"
SET ${updateCols.join(", ")}, updated_date = NOW()
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
[...updateValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
// INSERT
const insertCols = Object.keys(masterData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.masterInserted++;
}
// 2c. 기존 디테일 삭제
const deleteResult = await client.query(
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
result.detailDeleted += deleteResult.rowCount || 0;
// 2d. 새 디테일 INSERT
for (const row of rows) {
const detailData: Record<string, any> = {};
// FK 컬럼 추가
detailData[detailFkColumn] = masterKey;
detailData.company_code = companyCode;
if (userId) {
detailData.writer = userId;
}
// 디테일 컬럼 데이터 추출
for (const col of detailColumns) {
if (row[col.name] !== undefined) {
detailData[col.name] = row[col.name];
}
}
const insertCols = Object.keys(detailData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => detailData[k]);
await client.query(
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.detailInserted++;
}
} catch (error: any) {
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
}
}
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
logger.info(`마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
detailDeleted: result.detailDeleted,
errors: result.errors.length,
});
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* -
*
* UI에서 ,
*
*
* @param screenId ID
* @param detailData
* @param masterFieldValues UI에서
* @param numberingRuleId ID (optional)
* @param companyCode
* @param userId ID
* @param afterUploadFlowId ID (optional, )
* @param afterUploadFlows (optional)
*/
async uploadSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId: string | undefined,
companyCode: string,
userId: string,
afterUploadFlowId?: string,
afterUploadFlows?: Array<{ flowId: string; order: number }>
): Promise<{
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
}> {
const result: {
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
} = {
success: false,
masterInserted: 0,
detailInserted: 0,
generatedKey: "",
errors: [] as string[],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. 마스터-디테일 관계 정보 조회
const relation = await this.getMasterDetailRelation(screenId);
if (!relation) {
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
}
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
// 2. 채번 처리
let generatedKey: string;
if (numberingRuleId) {
// 채번 규칙으로 키 생성
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
} else {
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
generatedKey = masterFieldValues[masterKeyColumn];
if (!generatedKey) {
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
}
}
result.generatedKey = generatedKey;
logger.info(`채번 결과: ${generatedKey}`);
// 3. 마스터 레코드 생성
const masterData: Record<string, any> = {
...masterFieldValues,
[masterKeyColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 마스터 컬럼명 목록 구성
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
const masterValues = masterCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
masterValues
);
result.masterInserted = 1;
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
const insertedDetailRows: Record<string, any>[] = [];
for (const row of detailData) {
try {
const detailRowData: Record<string, any> = {
...row,
[detailFkColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 빈 값 필터링 및 id 제외
const detailCols = Object.keys(detailRowData).filter(k =>
k !== "id" &&
detailRowData[k] !== undefined &&
detailRowData[k] !== null &&
detailRowData[k] !== ""
);
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
const detailValues = detailCols.map(k => detailRowData[k]);
// RETURNING *로 삽입된 데이터 반환받기
const insertResult = await client.query(
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${detailPlaceholders.join(", ")}, NOW())
RETURNING *`,
detailValues
);
if (insertResult.rows && insertResult.rows[0]) {
insertedDetailRows.push(insertResult.rows[0]);
}
result.detailInserted++;
} catch (error: any) {
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
logger.error(`디테일 행 처리 실패:`, error);
}
}
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.detailInserted > 0;
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
// 업로드 후 제어 실행 (단일 또는 다중)
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
? afterUploadFlows // 다중 제어
: afterUploadFlowId
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
: [];
if (flowsToExecute.length > 0 && result.success) {
try {
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
// 마스터 데이터 구성
const masterData = {
...masterFieldValues,
[relation!.masterKeyColumn]: result.generatedKey,
company_code: companyCode,
};
const controlResults: any[] = [];
// 순서대로 제어 실행
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}`);
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
const controlResult = await NodeFlowExecutionService.executeFlow(
parseInt(flow.flowId),
{
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
buttonId: "excel-upload-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: masterData,
// 추가 컨텍스트: 마스터/디테일 정보
masterData: masterData,
detailData: insertedDetailRows,
masterTable: relation!.masterTable,
detailTable: relation!.detailTable,
masterKeyColumn: relation!.masterKeyColumn,
detailFkColumn: relation!.detailFkColumn,
}
);
controlResults.push({
flowId: flow.flowId,
order: flow.order,
success: controlResult.success,
message: controlResult.message,
executedNodes: controlResult.nodes?.length || 0,
});
}
result.controlResult = {
success: controlResults.every(r => r.success),
executedFlows: controlResults.length,
results: controlResults,
};
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
} catch (controlError: any) {
logger.error(`업로드 후 제어 실행 실패:`, controlError);
result.controlResult = {
success: false,
message: `제어 실행 실패: ${controlError.message}`,
};
}
}
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* ( numberingRuleService )
*/
private async generateNumberWithRule(
client: any,
ruleId: string,
companyCode: string
): Promise<string> {
try {
// 기존 numberingRuleService를 사용하여 코드 할당
const { numberingRuleService } = await import("./numberingRuleService");
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
return generatedCode;
} catch (error: any) {
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
throw error;
}
}
}
export const masterDetailExcelService = new MasterDetailExcelService();
File diff suppressed because it is too large Load Diff
@@ -969,21 +969,56 @@ export class NodeFlowExecutionService {
const insertedData = { ...data };
console.log("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
// 🔥 채번 규칙 서비스 동적 import
const { numberingRuleService } = await import("./numberingRuleService");
for (const mapping of fieldMappings) {
fields.push(mapping.targetField);
const value =
mapping.staticValue !== undefined
? mapping.staticValue
: data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
);
let value: any;
// 🔥 값 생성 유형에 따른 처리
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
// 자동 생성 (채번 규칙)
const companyCode = context.buttonContext?.companyCode || "*";
try {
value = await numberingRuleService.allocateCode(
mapping.numberingRuleId,
companyCode
);
console.log(
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
);
} catch (error: any) {
logger.error(`채번 규칙 적용 실패: ${error.message}`);
console.error(
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
);
throw new Error(
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
);
}
} else if (valueType === "static" || mapping.staticValue !== undefined) {
// 고정값
value = mapping.staticValue;
console.log(
` 📌 고정값: ${mapping.targetField} = ${value}`
);
} else {
// 소스 필드
value = data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
);
}
values.push(value);
// 🔥 삽입된 값을 데이터에 반영
insertedData[mapping.targetField] = value;
});
}
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
const hasWriterMapping = fieldMappings.some(
@@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService {
}
});
// 🔑 Primary Key 자동 추가 (context-data 모드)
console.log("🔑 context-data 모드: Primary Key 자동 추가");
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
// 🔑 Primary Key 자동 추가 여부 결정:
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
let finalWhereConditions: any[];
if (whereConditions && whereConditions.length > 0) {
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
finalWhereConditions = whereConditions;
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause(
enhancedWhereConditions,
finalWhereConditions,
data,
paramIndex
);
@@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService {
return deletedDataArray;
}
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
// 🆕 context-data 모드: 개별 삭제
console.log("🎯 context-data 모드: 개별 삭제 시작");
for (const data of dataArray) {
console.log("🔍 WHERE 조건 처리 중...");
// 🔑 Primary Key 자동 추가 (context-data 모드)
console.log("🔑 context-data 모드: Primary Key 자동 추가");
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
// 🔑 Primary Key 자동 추가 여부 결정:
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
let finalWhereConditions: any[];
if (whereConditions && whereConditions.length > 0) {
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
finalWhereConditions = whereConditions;
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause(
enhancedWhereConditions,
finalWhereConditions,
data,
1
);
@@ -2282,6 +2333,7 @@ export class NodeFlowExecutionService {
UPDATE ${targetTable}
SET ${setClauses.join(", ")}
WHERE ${updateWhereConditions}
RETURNING *
`;
logger.info(`🔄 UPDATE 실행:`, {
@@ -2292,8 +2344,14 @@ export class NodeFlowExecutionService {
values: updateValues,
});
await txClient.query(updateSql, updateValues);
const updateResult = await txClient.query(updateSql, updateValues);
updatedCount++;
// 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
if (updateResult.rows && updateResult.rows[0]) {
Object.assign(data, updateResult.rows[0]);
logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`);
}
} else {
// 3-B. 없으면 INSERT
const columns: string[] = [];
@@ -2340,6 +2398,7 @@ export class NodeFlowExecutionService {
const insertSql = `
INSERT INTO ${targetTable} (${columns.join(", ")})
VALUES (${placeholders})
RETURNING *
`;
logger.info(` INSERT 실행:`, {
@@ -2348,8 +2407,14 @@ export class NodeFlowExecutionService {
conflictKeyValues,
});
await txClient.query(insertSql, values);
const insertResult = await txClient.query(insertSql, values);
insertedCount++;
// 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능)
if (insertResult.rows && insertResult.rows[0]) {
Object.assign(data, insertResult.rows[0]);
logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`);
}
}
}
@@ -2357,11 +2422,10 @@ export class NodeFlowExecutionService {
`✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}`
);
return {
insertedCount,
updatedCount,
totalCount: insertedCount + updatedCount,
};
// 🔥 다음 노드에 전달할 데이터 반환
// dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음
// 카운트 정보도 함께 반환하여 기존 호환성 유지
return dataArray;
};
// 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성
@@ -2707,28 +2771,48 @@ export class NodeFlowExecutionService {
const trueData: any[] = [];
const falseData: any[] = [];
inputData.forEach((item: any) => {
const results = conditions.map((condition: any) => {
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
for (const item of inputData) {
const results: boolean[] = [];
for (const condition of conditions) {
const fieldValue = item[condition.field];
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = item[condition.value];
// EXISTS 계열 연산자 처리
if (
condition.operator === "EXISTS_IN" ||
condition.operator === "NOT_EXISTS_IN"
) {
const existsResult = await this.evaluateExistsCondition(
fieldValue,
condition.operator,
condition.lookupTable,
condition.lookupField,
context.buttonContext?.companyCode
);
results.push(existsResult);
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
// 일반 연산자 처리
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = item[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
);
}
results.push(
this.evaluateCondition(fieldValue, condition.operator, compareValue)
);
}
return this.evaluateCondition(
fieldValue,
condition.operator,
compareValue
);
});
}
const result =
logic === "OR"
@@ -2740,7 +2824,7 @@ export class NodeFlowExecutionService {
} else {
falseData.push(item);
}
});
}
logger.info(
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
@@ -2755,27 +2839,46 @@ export class NodeFlowExecutionService {
}
// 단일 객체인 경우
const results = conditions.map((condition: any) => {
const results: boolean[] = [];
for (const condition of conditions) {
const fieldValue = inputData[condition.field];
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = inputData[condition.value];
// EXISTS 계열 연산자 처리
if (
condition.operator === "EXISTS_IN" ||
condition.operator === "NOT_EXISTS_IN"
) {
const existsResult = await this.evaluateExistsCondition(
fieldValue,
condition.operator,
condition.lookupTable,
condition.lookupField,
context.buttonContext?.companyCode
);
results.push(existsResult);
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
// 일반 연산자 처리
let compareValue = condition.value;
if (condition.valueType === "field") {
compareValue = inputData[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
);
} else {
logger.info(
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
);
}
results.push(
this.evaluateCondition(fieldValue, condition.operator, compareValue)
);
}
return this.evaluateCondition(
fieldValue,
condition.operator,
compareValue
);
});
}
const result =
logic === "OR"
@@ -2784,7 +2887,7 @@ export class NodeFlowExecutionService {
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
// 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
return {
@@ -2795,6 +2898,69 @@ export class NodeFlowExecutionService {
};
}
/**
* EXISTS_IN / NOT_EXISTS_IN
*
*/
private static async evaluateExistsCondition(
fieldValue: any,
operator: string,
lookupTable: string,
lookupField: string,
companyCode?: string
): Promise<boolean> {
if (!lookupTable || !lookupField) {
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
return false;
}
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
logger.info(
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
);
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
return false;
}
try {
// 멀티테넌시: company_code 필터 적용 여부 확인
// company_mng 테이블은 제외
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
let sql: string;
let params: any[];
if (hasCompanyCode) {
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
params = [fieldValue, companyCode];
} else {
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
params = [fieldValue];
}
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
const result = await query(sql, params);
const existsInTable = result[0]?.exists_result === true;
logger.info(
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}${existsInTable ? "존재함" : "존재하지 않음"}`
);
// EXISTS_IN: 존재하면 true
// NOT_EXISTS_IN: 존재하지 않으면 true
if (operator === "EXISTS_IN") {
return existsInTable;
} else {
return !existsInTable;
}
} catch (error: any) {
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
return false;
}
}
/**
* WHERE
*/
@@ -4280,6 +4446,8 @@ export class NodeFlowExecutionService {
/**
*
* : (leftOperand operator rightOperand) additionalOperations
* : (width * height) / 1000000 * qty
*/
private static evaluateArithmetic(
arithmetic: any,
@@ -4306,27 +4474,67 @@ export class NodeFlowExecutionService {
const leftNum = Number(left) || 0;
const rightNum = Number(right) || 0;
switch (arithmetic.operator) {
// 기본 연산 수행
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
if (result === null) {
return null;
}
// 추가 연산 처리 (다중 연산 지원)
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
for (const addOp of arithmetic.additionalOperations) {
const operandValue = this.getOperandValue(
addOp.operand,
sourceRow,
targetRow,
resultValues
);
const operandNum = Number(operandValue) || 0;
result = this.applyOperator(result, addOp.operator, operandNum);
if (result === null) {
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
return null;
}
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
}
}
return result;
}
/**
*
*/
private static applyOperator(
left: number,
operator: string,
right: number
): number | null {
switch (operator) {
case "+":
return leftNum + rightNum;
return left + right;
case "-":
return leftNum - rightNum;
return left - right;
case "*":
return leftNum * rightNum;
return left * right;
case "/":
if (rightNum === 0) {
if (right === 0) {
logger.warn(`⚠️ 0으로 나누기 시도`);
return null;
}
return leftNum / rightNum;
return left / right;
case "%":
if (rightNum === 0) {
if (right === 0) {
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
return null;
}
return leftNum % rightNum;
return left % right;
default:
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
throw new Error(`지원하지 않는 연산자: ${operator}`);
}
}
@@ -187,71 +187,68 @@ class TableCategoryValueService {
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
}
// 2. 카테고리 값 조회 (형제 메뉴 포함)
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
let query: string;
let params: any[];
const baseSelect = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 조회
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
params = [tableName, columnName, siblingObjids];
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND menu_objid = $3`;
params = [tableName, columnName, menuObjid];
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
} else {
// menuObjid 없으면 모든 값 조회 (중복 가능)
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
}
} else {
// 일반 회사: 자신의 카테고리 값만 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
// 일반 회사: 자신의 회사 + menuObjid로 필터링
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
params = [tableName, columnName, companyCode, siblingObjids];
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
params = [tableName, columnName, companyCode, menuObjid];
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
} else {
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
query = baseSelect + ` AND company_code = $3`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
}
}
if (!includeInactive) {
@@ -1398,6 +1395,220 @@ class TableCategoryValueService {
throw error;
}
}
/**
* ( )
*
*
*
* @param tableName -
* @param companyCode -
* @returns { [columnName]: { [label]: code } }
*/
async getCategoryLabelToCodeMapping(
tableName: string,
companyCode: string
): Promise<Record<string, Record<string, string>>> {
try {
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
const pool = getPool();
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
const categoryColumnsQuery = `
SELECT column_name
FROM table_type_columns
WHERE table_name = $1
AND input_type = 'category'
`;
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
if (categoryColumnsResult.rows.length === 0) {
logger.info("카테고리 타입 컬럼 없음", { tableName });
return {};
}
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
const result: Record<string, Record<string, string>> = {};
for (const columnName of categoryColumns) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
`;
params = [tableName, columnName];
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
AND (company_code = $3 OR company_code = '*')
`;
params = [tableName, columnName, companyCode];
}
const valuesResult = await pool.query(query, params);
// { [label]: code } 형태로 변환
const labelToCodeMap: Record<string, string> = {};
for (const row of valuesResult.rows) {
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
labelToCodeMap[row.value_label] = row.value_code;
// 소문자 키도 추가 (대소문자 무시 검색용)
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
}
if (Object.keys(labelToCodeMap).length > 0) {
result[columnName] = labelToCodeMap;
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
}
}
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
tableName,
columnCount: Object.keys(result).length
});
return result;
} catch (error: any) {
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
*
*
* DB
*
* @param tableName -
* @param companyCode -
* @param data -
* @returns
*/
async convertCategoryLabelsToCodesForData(
tableName: string,
companyCode: string,
data: Record<string, any>
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
try {
// 라벨→코드 매핑 조회
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
if (Object.keys(labelToCodeMapping).length === 0) {
// 카테고리 컬럼 없음
return { convertedData: data, conversions: [] };
}
const convertedData = { ...data };
const conversions: Array<{ column: string; label: string; code: string }> = [];
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
const value = data[columnName];
if (value !== undefined && value !== null && value !== "") {
const stringValue = String(value).trim();
// 다중 값 확인 (쉼표로 구분된 경우)
if (stringValue.includes(",")) {
// 다중 카테고리 값 처리
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
const convertedCodes: string[] = [];
let allConverted = true;
for (const label of labels) {
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[label];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[label.toLowerCase()];
}
if (matchedCode) {
convertedCodes.push(matchedCode);
conversions.push({
column: columnName,
label: label,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
if (isAlreadyCode) {
// 이미 코드값이면 그대로 사용
convertedCodes.push(label);
} else {
// 라벨도 코드도 아니면 원래 값 유지
convertedCodes.push(label);
allConverted = false;
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
}
}
}
// 변환된 코드들을 쉼표로 합쳐서 저장
convertedData[columnName] = convertedCodes.join(",");
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
} else {
// 단일 값 처리
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[stringValue];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[stringValue.toLowerCase()];
}
if (matchedCode) {
// 라벨 값을 코드 값으로 변환
convertedData[columnName] = matchedCode;
conversions.push({
column: columnName,
label: stringValue,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인 (역방향 확인)
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
if (!isAlreadyCode) {
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
}
// 변환 없이 원래 값 유지
}
}
}
}
logger.info(`카테고리 라벨→코드 변환 완료`, {
tableName,
conversionCount: conversions.length,
conversions,
});
return { convertedData, conversions };
} catch (error: any) {
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
// 실패 시 원본 데이터 반환
return { convertedData: data, conversions: [] };
}
}
}
export default new TableCategoryValueService();
@@ -1312,6 +1312,41 @@ export class TableManagementService {
paramCount: number;
} | null> {
try {
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
if (Array.isArray(value) && value.length > 0) {
// 배열의 각 값에 대해 OR 조건으로 검색
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
// 각 값을 LIKE 또는 = 조건으로 처리
const conditions: string[] = [];
const values: any[] = [];
value.forEach((v: any, idx: number) => {
const safeValue = String(v).trim();
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
// - 정확히 "2"
// - "2," 로 시작
// - ",2" 로 끝남
// - ",2," 중간에 포함
const paramBase = paramIndex + (idx * 4);
conditions.push(`(
${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3}
)`);
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
});
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
return {
whereClause: `(${conditions.join(" OR ")})`,
values,
paramCount: values.length,
};
}
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(
@@ -2267,11 +2302,12 @@ export class TableManagementService {
/**
*
* @returns ()
*/
async addTableData(
tableName: string,
data: Record<string, any>
): Promise<void> {
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
@@ -2302,10 +2338,41 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`);
}
// 컬럼명과 값을 분리하고 타입에 맞게 변환
const columns = Object.keys(data);
const values = Object.values(data).map((value, index) => {
const columnName = columns[index];
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => {
const exists = columnTypeMap.has(col);
if (!exists) {
skippedColumns.push(col);
}
return exists;
});
// 무시된 컬럼이 있으면 경고 로그 출력
if (skippedColumns.length > 0) {
logger.warn(
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
);
logger.warn(
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
skippedColumns.map((col) => ({ column: col, value: data[col] }))
);
}
if (existingColumns.length === 0) {
throw new Error(
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
);
}
logger.info(
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
);
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
const columns = existingColumns;
const values = columns.map((columnName) => {
const value = data[columnName];
const dataType = columnTypeMap.get(columnName) || "text";
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
logger.info(
@@ -2361,6 +2428,12 @@ export class TableManagementService {
await query(insertQuery, values);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
// 무시된 컬럼과 저장된 컬럼 정보 반환
return {
skippedColumns,
savedColumns: existingColumns,
};
} catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
throw error;
@@ -2644,6 +2717,12 @@ export class TableManagementService {
filterColumn?: string;
filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
}
): Promise<EntityJoinResponse> {
const startTime = Date.now();
@@ -2694,33 +2773,64 @@ export class TableManagementService {
);
for (const additionalColumn of options.additionalJoinColumns) {
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
const baseJoinConfig = joinConfigs.find(
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
let baseJoinConfig = joinConfigs.find(
(config) => config.sourceColumn === additionalColumn.sourceColumn
);
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
baseJoinConfig = joinConfigs.find(
(config) => config.referenceTable === (additionalColumn as any).referenceTable
);
if (baseJoinConfig) {
logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable}${baseJoinConfig.sourceColumn}`);
}
}
if (baseJoinConfig) {
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
// joinAlias에서 실제 컬럼명 추출
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
let actualColumnName: string;
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, "");
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
// 실제 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, "");
} else {
// 어느 것도 아니면 원본 사용
actualColumnName = originalJoinAlias;
}
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
logger.info(`🔍 조인 컬럼 상세 분석:`, {
sourceColumn,
joinAlias,
frontendSourceColumn,
originalJoinAlias,
correctedJoinAlias,
actualColumnName,
referenceTable: additionalColumn.sourceTable,
referenceTable: (additionalColumn as any).referenceTable,
});
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
const isBasicEntityJoin =
additionalColumn.joinAlias ===
`${baseJoinConfig.sourceColumn}_name`;
correctedJoinAlias === `${sourceColumn}_name`;
if (isBasicEntityJoin) {
logger.info(
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
);
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
}
@@ -2728,14 +2838,14 @@ export class TableManagementService {
// 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
referenceTable:
(additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
displayColumn: actualColumnName, // 하위 호환성
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
separator: " - ", // 기본 구분자
};
@@ -3702,6 +3812,15 @@ export class TableManagementService {
const cacheableJoins: EntityJoinConfig[] = [];
const dbJoins: EntityJoinConfig[] = [];
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
const companySpecificTables = [
"supplier_mng",
"customer_mng",
"item_info",
"dept_info",
// 필요시 추가
];
for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") {
@@ -3710,6 +3829,13 @@ export class TableManagementService {
continue;
}
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
if (companySpecificTables.includes(config.referenceTable)) {
dbJoins.push(config);
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
continue;
}
// 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
@@ -4598,4 +4724,101 @@ export class TableManagementService {
return false;
}
}
/**
*
* column_labels에서 .
*
* @param leftTable
* @param rightTable
* @returns
*/
async detectTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>> {
try {
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
const relations: Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}> = [];
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
const rightToLeftRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[rightTable, leftTable]
);
for (const rel of rightToLeftRels) {
relations.push({
leftColumn: rel.reference_column,
rightColumn: rel.column_name,
direction: "right_to_left",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: left_table의 item_id -> right_table(item_info)의 item_number
const leftToRightRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[leftTable, rightTable]
);
for (const rel of leftToRightRels) {
relations.push({
leftColumn: rel.column_name,
rightColumn: rel.reference_column,
direction: "left_to_right",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => {
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
});
return relations;
} catch (error) {
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
return [];
}
}
}
+48
View File
@@ -17,12 +17,30 @@ export interface LangKey {
langKey: string;
description?: string;
isActive: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdDate?: Date;
createdBy?: string;
updatedDate?: Date;
updatedBy?: string;
}
// 카테고리 인터페이스
export interface LangCategory {
categoryId: number;
categoryCode: string;
categoryName: string;
parentId?: number | null;
level: number;
keyPrefix: string;
description?: string;
sortOrder: number;
isActive: string;
children?: LangCategory[];
}
export interface LangText {
textId?: number;
keyId: number;
@@ -63,10 +81,38 @@ export interface CreateLangKeyRequest {
langKey: string;
description?: string;
isActive?: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdBy?: string;
updatedBy?: string;
}
// 자동 키 생성 요청
export interface GenerateKeyRequest {
companyCode: string;
categoryId: number;
keyMeaning: string;
usageNote?: string;
texts: Array<{
langCode: string;
langText: string;
}>;
createdBy?: string;
}
// 오버라이드 키 생성 요청
export interface CreateOverrideKeyRequest {
companyCode: string;
baseKeyId: number;
texts: Array<{
langCode: string;
langText: string;
}>;
createdBy?: string;
}
export interface UpdateLangKeyRequest {
companyCode?: string;
menuName?: string;
@@ -90,6 +136,8 @@ export interface GetLangKeysParams {
menuCode?: string;
keyType?: string;
searchText?: string;
categoryId?: number;
includeOverrides?: boolean;
page?: number;
limit?: number;
}