Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
@@ -299,6 +299,8 @@ export class DashboardService {
|
||||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||
* - company_code가 '*'인 경우 최고 관리자만 조회 가능
|
||||
*/
|
||||
static async getDashboardById(
|
||||
dashboardId: string,
|
||||
@@ -310,44 +312,43 @@ export class DashboardService {
|
||||
let dashboardQuery: string;
|
||||
let dashboardParams: any[];
|
||||
|
||||
if (userId) {
|
||||
if (companyCode) {
|
||||
if (companyCode) {
|
||||
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
|
||||
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
|
||||
if (companyCode === '*') {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND (d.created_by = $3 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode, userId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
}
|
||||
} else {
|
||||
if (companyCode) {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
}
|
||||
} else if (userId) {
|
||||
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
} else {
|
||||
// 비로그인 사용자는 공개 대시보드만
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
}
|
||||
|
||||
const dashboardResult = await PostgreSQLService.query(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
|
||||
@@ -1635,6 +1635,69 @@ export class DynamicFormService {
|
||||
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 필드 값만 업데이트
|
||||
* (다른 테이블의 레코드 업데이트 지원)
|
||||
*/
|
||||
async updateFieldValue(
|
||||
tableName: string,
|
||||
keyField: string,
|
||||
keyValue: any,
|
||||
updateField: string,
|
||||
updateValue: any,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<{ affectedRows: number }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외)
|
||||
let whereClause = `"${keyField}" = $1`;
|
||||
const params: any[] = [keyValue, updateValue, userId];
|
||||
let paramIndex = 4;
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const sqlQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET "${updateField}" = $2,
|
||||
updated_by = $3,
|
||||
updated_at = NOW()
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery);
|
||||
console.log("🔍 [updateFieldValue] 파라미터:", params);
|
||||
|
||||
const result = await client.query(sqlQuery, params);
|
||||
|
||||
console.log("✅ [updateFieldValue] 결과:", {
|
||||
affectedRows: result.rowCount,
|
||||
});
|
||||
|
||||
return { affectedRows: result.rowCount || 0 };
|
||||
} catch (error) {
|
||||
console.error("❌ [updateFieldValue] 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 export
|
||||
|
||||
@@ -166,6 +166,9 @@ export class ExternalRestApiConnectionService {
|
||||
? this.decryptSensitiveData(connection.auth_config)
|
||||
: null;
|
||||
|
||||
// 디버깅: 조회된 연결 정보 로깅
|
||||
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connection,
|
||||
@@ -227,6 +230,15 @@ export class ExternalRestApiConnectionService {
|
||||
data.created_by || "system",
|
||||
];
|
||||
|
||||
// 디버깅: 저장하려는 데이터 로깅
|
||||
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||
connection_name: data.connection_name,
|
||||
default_method: data.default_method,
|
||||
endpoint_path: data.endpoint_path,
|
||||
base_url: data.base_url,
|
||||
default_body: data.default_body ? "있음" : "없음",
|
||||
});
|
||||
|
||||
const result: QueryResult<any> = await pool.query(query, params);
|
||||
|
||||
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
||||
@@ -316,12 +328,14 @@ export class ExternalRestApiConnectionService {
|
||||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_method: ${data.default_method}`);
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body);
|
||||
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
@@ -885,6 +899,166 @@ export class ExternalRestApiConnectionService {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
* 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환
|
||||
*/
|
||||
static async fetchData(
|
||||
connectionId: number,
|
||||
endpoint?: string,
|
||||
jsonPath?: string,
|
||||
userCompanyCode?: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 연결을 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "CONNECTION_NOT_FOUND",
|
||||
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 비활성화된 연결인지 확인
|
||||
if (connection.is_active !== "Y") {
|
||||
return {
|
||||
success: false,
|
||||
message: "비활성화된 REST API 연결입니다.",
|
||||
error: {
|
||||
code: "CONNECTION_INACTIVE",
|
||||
details: "연결이 비활성화 상태입니다.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 엔드포인트 결정 (파라미터 > 저장된 값)
|
||||
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
|
||||
|
||||
// API 호출을 위한 테스트 요청 생성
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
method: (connection.default_method as any) || "GET",
|
||||
headers: connection.default_headers,
|
||||
body: connection.default_body,
|
||||
auth_type: connection.auth_type,
|
||||
auth_config: connection.auth_config,
|
||||
timeout: connection.timeout,
|
||||
};
|
||||
|
||||
// API 호출
|
||||
const result = await this.testConnection(testRequest, connection.company_code);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || "REST API 호출에 실패했습니다.",
|
||||
error: {
|
||||
code: "API_CALL_FAILED",
|
||||
details: result.error_details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 데이터에서 jsonPath로 데이터 추출
|
||||
let extractedData = result.response_data;
|
||||
|
||||
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
|
||||
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
|
||||
|
||||
if (jsonPath && result.response_data) {
|
||||
try {
|
||||
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
|
||||
const pathParts = jsonPath.split(".");
|
||||
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (extractedData && typeof extractedData === "object") {
|
||||
extractedData = (extractedData as any)[part];
|
||||
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
|
||||
} else {
|
||||
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (pathError) {
|
||||
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
|
||||
// 추출 실패 시 원본 데이터 반환
|
||||
extractedData = result.response_data;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터가 배열이 아닌 경우 배열로 변환
|
||||
// null이나 undefined인 경우 빈 배열로 처리
|
||||
let dataArray: any[] = [];
|
||||
if (extractedData === null || extractedData === undefined) {
|
||||
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
|
||||
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
|
||||
if (result.response_data && typeof result.response_data === "object") {
|
||||
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
|
||||
}
|
||||
} else {
|
||||
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
|
||||
}
|
||||
|
||||
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
|
||||
if (dataArray.length > 0) {
|
||||
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
|
||||
}
|
||||
|
||||
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
|
||||
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
|
||||
|
||||
// 첫 번째 유효한 객체 찾기
|
||||
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
|
||||
|
||||
if (firstValidItem) {
|
||||
columns = Object.keys(firstValidItem).map((key) => ({
|
||||
columnName: key,
|
||||
columnLabel: key,
|
||||
dataType: typeof firstValidItem[key],
|
||||
}));
|
||||
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
|
||||
} else {
|
||||
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataArray,
|
||||
columns,
|
||||
total: dataArray.length,
|
||||
connectionInfo: {
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.connection_name,
|
||||
baseUrl: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
},
|
||||
},
|
||||
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("REST API 데이터 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 조회에 실패했습니다.",
|
||||
error: {
|
||||
code: "FETCH_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 데이터 유효성 검증
|
||||
*/
|
||||
|
||||
@@ -334,9 +334,12 @@ class MailSendSimpleService {
|
||||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
// styles 객체 또는 직접 속성에서 색상 가져오기
|
||||
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
|
||||
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
|
||||
// 버튼은 왼쪽 정렬 (text-align 제거)
|
||||
html += `<div style="margin: 30px 0; text-align: left;">
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
@@ -348,6 +351,89 @@ class MailSendSimpleService {
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||
break;
|
||||
case 'header':
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${component.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'infoTable':
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(component.rows || []).map((row: any, i: number) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'alertBox':
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[component.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||
<div>${component.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'divider':
|
||||
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case 'footer':
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||
${(component.ceoName || component.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||
${(component.phone || component.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||
${component.phone && component.email ? ' | ' : ''}
|
||||
${component.email ? `Email: ${component.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'numberedList':
|
||||
html += `
|
||||
<div style="padding: 16px;">
|
||||
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,35 @@ import path from "path";
|
||||
// MailComponent 인터페이스 정의
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer";
|
||||
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: "info" | "warning" | "danger" | "success";
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
||||
@@ -236,6 +258,89 @@ class MailTemplateFileService {
|
||||
case "spacer":
|
||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||
break;
|
||||
case "header":
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${comp.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "infoTable":
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(comp.rows || []).map((row, i) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "alertBox":
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[comp.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
|
||||
<div>${comp.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "divider":
|
||||
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case "footer":
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
|
||||
${(comp.ceoName || comp.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
|
||||
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
|
||||
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
|
||||
${(comp.phone || comp.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.phone ? `Tel: ${comp.phone}` : ''}
|
||||
${comp.phone && comp.email ? ' | ' : ''}
|
||||
${comp.email ? `Email: ${comp.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "numberedList":
|
||||
html += `
|
||||
<div style="padding: 16px; ${styles}">
|
||||
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ export interface MenuCopyResult {
|
||||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCategories: number;
|
||||
copiedCodes: number;
|
||||
copiedCategorySettings: number;
|
||||
copiedNumberingRules: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
@@ -129,35 +125,6 @@ interface FlowStepConnection {
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리
|
||||
*/
|
||||
interface CodeCategory {
|
||||
category_code: string;
|
||||
category_name: string;
|
||||
category_name_eng: string | null;
|
||||
description: string | null;
|
||||
sort_order: number | null;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
menu_objid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 정보
|
||||
*/
|
||||
interface CodeInfo {
|
||||
code_category: string;
|
||||
code_value: string;
|
||||
code_name: string;
|
||||
code_name_eng: string | null;
|
||||
description: string | null;
|
||||
sort_order: number | null;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
menu_objid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 복사 서비스
|
||||
*/
|
||||
@@ -249,6 +216,24 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 탭 컴포넌트 (tabs 배열 내부의 screenId)
|
||||
if (
|
||||
props?.componentConfig?.tabs &&
|
||||
Array.isArray(props.componentConfig.tabs)
|
||||
) {
|
||||
for (const tab of props.componentConfig.tabs) {
|
||||
if (tab.screenId) {
|
||||
const screenId = tab.screenId;
|
||||
const numId =
|
||||
typeof screenId === "number" ? screenId : parseInt(screenId);
|
||||
if (!isNaN(numId)) {
|
||||
referenced.push(numId);
|
||||
logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return referenced;
|
||||
@@ -355,127 +340,6 @@ export class MenuCopyService {
|
||||
return flowIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 수집
|
||||
*/
|
||||
private async collectCodes(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> {
|
||||
logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const categories: CodeCategory[] = [];
|
||||
const codes: CodeInfo[] = [];
|
||||
|
||||
for (const menuObjid of menuObjids) {
|
||||
// 코드 카테고리
|
||||
const catsResult = await client.query<CodeCategory>(
|
||||
`SELECT * FROM code_category
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[menuObjid, sourceCompanyCode]
|
||||
);
|
||||
categories.push(...catsResult.rows);
|
||||
|
||||
// 각 카테고리의 코드 정보
|
||||
for (const cat of catsResult.rows) {
|
||||
const codesResult = await client.query<CodeInfo>(
|
||||
`SELECT * FROM code_info
|
||||
WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`,
|
||||
[cat.category_code, menuObjid, sourceCompanyCode]
|
||||
);
|
||||
codes.push(...codesResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개`
|
||||
);
|
||||
return { categories, codes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 수집
|
||||
*/
|
||||
private async collectCategorySettings(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{
|
||||
columnMappings: any[];
|
||||
categoryValues: any[];
|
||||
}> {
|
||||
logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const columnMappings: any[] = [];
|
||||
const categoryValues: any[] = [];
|
||||
|
||||
// 카테고리 컬럼 매핑 (메뉴별 + 공통)
|
||||
const mappingsResult = await client.query(
|
||||
`SELECT * FROM category_column_mapping
|
||||
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
|
||||
AND company_code = $2`,
|
||||
[menuObjids, sourceCompanyCode]
|
||||
);
|
||||
columnMappings.push(...mappingsResult.rows);
|
||||
|
||||
// 테이블 컬럼 카테고리 값 (메뉴별 + 공통)
|
||||
const valuesResult = await client.query(
|
||||
`SELECT * FROM table_column_category_values
|
||||
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
|
||||
AND company_code = $2`,
|
||||
[menuObjids, sourceCompanyCode]
|
||||
);
|
||||
categoryValues.push(...valuesResult.rows);
|
||||
|
||||
logger.info(
|
||||
`✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)`
|
||||
);
|
||||
return { columnMappings, categoryValues };
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 수집
|
||||
*/
|
||||
private async collectNumberingRules(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{
|
||||
rules: any[];
|
||||
parts: any[];
|
||||
}> {
|
||||
logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const rules: any[] = [];
|
||||
const parts: any[] = [];
|
||||
|
||||
for (const menuObjid of menuObjids) {
|
||||
// 채번 규칙
|
||||
const rulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[menuObjid, sourceCompanyCode]
|
||||
);
|
||||
rules.push(...rulesResult.rows);
|
||||
|
||||
// 각 규칙의 파트
|
||||
for (const rule of rulesResult.rows) {
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts
|
||||
WHERE rule_id = $1 AND company_code = $2`,
|
||||
[rule.rule_id, sourceCompanyCode]
|
||||
);
|
||||
parts.push(...partsResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개`
|
||||
);
|
||||
return { rules, parts };
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 메뉴 objid 생성
|
||||
*/
|
||||
@@ -709,42 +573,8 @@ export class MenuCopyService {
|
||||
]);
|
||||
logger.info(` ✅ 메뉴 권한 삭제 완료`);
|
||||
|
||||
// 5-5. 채번 규칙 파트 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rule_parts
|
||||
WHERE rule_id IN (
|
||||
SELECT rule_id FROM numbering_rules
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2
|
||||
)`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 채번 규칙 파트 삭제 완료`);
|
||||
|
||||
// 5-6. 채번 규칙 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rules
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 채번 규칙 삭제 완료`);
|
||||
|
||||
// 5-7. 테이블 컬럼 카테고리 값 삭제
|
||||
await client.query(
|
||||
`DELETE FROM table_column_category_values
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 카테고리 값 삭제 완료`);
|
||||
|
||||
// 5-8. 카테고리 컬럼 매핑 삭제
|
||||
await client.query(
|
||||
`DELETE FROM category_column_mapping
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 카테고리 매핑 삭제 완료`);
|
||||
|
||||
// 5-9. 메뉴 삭제 (역순: 하위 메뉴부터)
|
||||
// 5-5. 메뉴 삭제 (역순: 하위 메뉴부터)
|
||||
// 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음
|
||||
for (let i = existingMenus.length - 1; i >= 0; i--) {
|
||||
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
|
||||
existingMenus[i].objid,
|
||||
@@ -801,33 +631,11 @@ export class MenuCopyService {
|
||||
|
||||
const flowIds = await this.collectFlows(screenIds, client);
|
||||
|
||||
const codes = await this.collectCodes(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const categorySettings = await this.collectCategorySettings(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const numberingRules = await this.collectNumberingRules(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
logger.info(`
|
||||
📊 수집 완료:
|
||||
- 메뉴: ${menus.length}개
|
||||
- 화면: ${screenIds.size}개
|
||||
- 플로우: ${flowIds.size}개
|
||||
- 코드 카테고리: ${codes.categories.length}개
|
||||
- 코드: ${codes.codes.length}개
|
||||
- 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개
|
||||
- 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개
|
||||
`);
|
||||
|
||||
// === 2단계: 플로우 복사 ===
|
||||
@@ -871,30 +679,6 @@ export class MenuCopyService {
|
||||
client
|
||||
);
|
||||
|
||||
// === 6단계: 코드 복사 ===
|
||||
logger.info("\n📋 [6단계] 코드 복사");
|
||||
await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client);
|
||||
|
||||
// === 7단계: 카테고리 설정 복사 ===
|
||||
logger.info("\n📂 [7단계] 카테고리 설정 복사");
|
||||
await this.copyCategorySettings(
|
||||
categorySettings,
|
||||
menuIdMap,
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
// === 8단계: 채번 규칙 복사 ===
|
||||
logger.info("\n📋 [8단계] 채번 규칙 복사");
|
||||
await this.copyNumberingRules(
|
||||
numberingRules,
|
||||
menuIdMap,
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
// 커밋
|
||||
await client.query("COMMIT");
|
||||
logger.info("✅ 트랜잭션 커밋 완료");
|
||||
@@ -904,13 +688,6 @@ export class MenuCopyService {
|
||||
copiedMenus: menuIdMap.size,
|
||||
copiedScreens: screenIdMap.size,
|
||||
copiedFlows: flowIdMap.size,
|
||||
copiedCategories: codes.categories.length,
|
||||
copiedCodes: codes.codes.length,
|
||||
copiedCategorySettings:
|
||||
categorySettings.columnMappings.length +
|
||||
categorySettings.categoryValues.length,
|
||||
copiedNumberingRules:
|
||||
numberingRules.rules.length + numberingRules.parts.length,
|
||||
menuIdMap: Object.fromEntries(menuIdMap),
|
||||
screenIdMap: Object.fromEntries(screenIdMap),
|
||||
flowIdMap: Object.fromEntries(flowIdMap),
|
||||
@@ -923,10 +700,8 @@ export class MenuCopyService {
|
||||
- 메뉴: ${result.copiedMenus}개
|
||||
- 화면: ${result.copiedScreens}개
|
||||
- 플로우: ${result.copiedFlows}개
|
||||
- 코드 카테고리: ${result.copiedCategories}개
|
||||
- 코드: ${result.copiedCodes}개
|
||||
- 카테고리 설정: ${result.copiedCategorySettings}개
|
||||
- 채번 규칙: ${result.copiedNumberingRules}개
|
||||
|
||||
⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다.
|
||||
============================================
|
||||
`);
|
||||
|
||||
@@ -1125,13 +900,31 @@ export class MenuCopyService {
|
||||
|
||||
const screenDef = screenDefResult.rows[0];
|
||||
|
||||
// 2) 새 screen_code 생성
|
||||
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
|
||||
const existingScreenResult = await client.query<{ screen_id: number }>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
LIMIT 1`,
|
||||
[screenDef.screen_code, targetCompanyCode]
|
||||
);
|
||||
|
||||
if (existingScreenResult.rows.length > 0) {
|
||||
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
|
||||
const existingScreenId = existingScreenResult.rows[0].screen_id;
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
logger.info(
|
||||
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})`
|
||||
);
|
||||
continue; // 레이아웃 복사도 스킵
|
||||
}
|
||||
|
||||
// 3) 새 screen_code 생성
|
||||
const newScreenCode = await this.generateUniqueScreenCode(
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
// 2-1) 화면명 변환 적용
|
||||
// 4) 화면명 변환 적용
|
||||
let transformedScreenName = screenDef.screen_name;
|
||||
if (screenNameConfig) {
|
||||
// 1. 제거할 텍스트 제거
|
||||
@@ -1150,7 +943,7 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code,
|
||||
@@ -1479,383 +1272,4 @@ export class MenuCopyService {
|
||||
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리 중복 체크
|
||||
*/
|
||||
private async checkCodeCategoryExists(
|
||||
categoryCode: string,
|
||||
companyCode: string,
|
||||
menuObjid: number,
|
||||
client: PoolClient
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM code_category
|
||||
WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3
|
||||
) as exists`,
|
||||
[categoryCode, companyCode, menuObjid]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 정보 중복 체크
|
||||
*/
|
||||
private async checkCodeInfoExists(
|
||||
categoryCode: string,
|
||||
codeValue: string,
|
||||
companyCode: string,
|
||||
menuObjid: number,
|
||||
client: PoolClient
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM code_info
|
||||
WHERE code_category = $1 AND code_value = $2
|
||||
AND company_code = $3 AND menu_objid = $4
|
||||
) as exists`,
|
||||
[categoryCode, codeValue, companyCode, menuObjid]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 복사
|
||||
*/
|
||||
private async copyCodes(
|
||||
codes: { categories: CodeCategory[]; codes: CodeInfo[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📋 코드 복사 중...`);
|
||||
|
||||
let categoryCount = 0;
|
||||
let codeCount = 0;
|
||||
let skippedCategories = 0;
|
||||
let skippedCodes = 0;
|
||||
|
||||
// 1) 코드 카테고리 복사 (중복 체크)
|
||||
for (const category of codes.categories) {
|
||||
const newMenuObjid = menuIdMap.get(category.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 중복 체크
|
||||
const exists = await this.checkCodeCategoryExists(
|
||||
category.category_code,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
client
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
skippedCategories++;
|
||||
logger.debug(
|
||||
` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 카테고리 복사
|
||||
await client.query(
|
||||
`INSERT INTO code_category (
|
||||
category_code, category_name, category_name_eng, description,
|
||||
sort_order, is_active, company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
category.category_code,
|
||||
category.category_name,
|
||||
category.category_name_eng,
|
||||
category.description,
|
||||
category.sort_order,
|
||||
category.is_active,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
newMenuObjid, // 재매핑
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
categoryCount++;
|
||||
}
|
||||
|
||||
// 2) 코드 정보 복사 (중복 체크)
|
||||
for (const code of codes.codes) {
|
||||
const newMenuObjid = menuIdMap.get(code.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 중복 체크
|
||||
const exists = await this.checkCodeInfoExists(
|
||||
code.code_category,
|
||||
code.code_value,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
client
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
skippedCodes++;
|
||||
logger.debug(
|
||||
` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 코드 복사
|
||||
await client.query(
|
||||
`INSERT INTO code_info (
|
||||
code_category, code_value, code_name, code_name_eng, description,
|
||||
sort_order, is_active, company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
code.code_category,
|
||||
code.code_value,
|
||||
code.code_name,
|
||||
code.code_name_eng,
|
||||
code.description,
|
||||
code.sort_order,
|
||||
code.is_active,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
newMenuObjid, // 재매핑
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
codeCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 복사
|
||||
*/
|
||||
private async copyCategorySettings(
|
||||
settings: { columnMappings: any[]; categoryValues: any[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📂 카테고리 설정 복사 중...`);
|
||||
|
||||
const valueIdMap = new Map<number, number>(); // 원본 value_id → 새 value_id
|
||||
let mappingCount = 0;
|
||||
let valueCount = 0;
|
||||
|
||||
// 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드)
|
||||
for (const mapping of settings.columnMappings) {
|
||||
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
|
||||
let newMenuObjid: number | undefined;
|
||||
|
||||
if (
|
||||
mapping.menu_objid === 0 ||
|
||||
mapping.menu_objid === "0" ||
|
||||
mapping.menu_objid == 0
|
||||
) {
|
||||
newMenuObjid = 0; // 공통 설정
|
||||
} else {
|
||||
newMenuObjid = menuIdMap.get(mapping.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.debug(
|
||||
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 매핑 삭제 (덮어쓰기)
|
||||
await client.query(
|
||||
`DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`,
|
||||
[mapping.table_name, mapping.physical_column_name, targetCompanyCode]
|
||||
);
|
||||
|
||||
// 새 매핑 추가
|
||||
await client.query(
|
||||
`INSERT INTO category_column_mapping (
|
||||
table_name, logical_column_name, physical_column_name,
|
||||
menu_objid, company_code, description, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
mapping.table_name,
|
||||
mapping.logical_column_name,
|
||||
mapping.physical_column_name,
|
||||
newMenuObjid,
|
||||
targetCompanyCode,
|
||||
mapping.description,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
mappingCount++;
|
||||
}
|
||||
|
||||
// 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지)
|
||||
const sortedValues = settings.categoryValues.sort(
|
||||
(a, b) => a.depth - b.depth
|
||||
);
|
||||
|
||||
// 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위)
|
||||
const uniqueTableColumns = new Set<string>();
|
||||
for (const value of sortedValues) {
|
||||
uniqueTableColumns.add(`${value.table_name}:${value.column_name}`);
|
||||
}
|
||||
|
||||
for (const tableColumn of uniqueTableColumns) {
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
await client.query(
|
||||
`DELETE FROM table_column_category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND company_code = $3`,
|
||||
[tableName, columnName, targetCompanyCode]
|
||||
);
|
||||
logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`);
|
||||
}
|
||||
|
||||
// 새 값 추가
|
||||
for (const value of sortedValues) {
|
||||
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
|
||||
let newMenuObjid: number | undefined;
|
||||
|
||||
if (
|
||||
value.menu_objid === 0 ||
|
||||
value.menu_objid === "0" ||
|
||||
value.menu_objid == 0
|
||||
) {
|
||||
newMenuObjid = 0; // 공통 설정
|
||||
} else {
|
||||
newMenuObjid = menuIdMap.get(value.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.debug(
|
||||
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 ID 재매핑
|
||||
let newParentValueId = null;
|
||||
if (value.parent_value_id) {
|
||||
newParentValueId = valueIdMap.get(value.parent_value_id) || null;
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO table_column_category_values (
|
||||
table_name, column_name, value_code, value_label,
|
||||
value_order, parent_value_id, depth, description,
|
||||
color, icon, is_active, is_default,
|
||||
company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING value_id`,
|
||||
[
|
||||
value.table_name,
|
||||
value.column_name,
|
||||
value.value_code,
|
||||
value.value_label,
|
||||
value.value_order,
|
||||
newParentValueId,
|
||||
value.depth,
|
||||
value.description,
|
||||
value.color,
|
||||
value.icon,
|
||||
value.is_active,
|
||||
value.is_default,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
// ID 매핑 저장
|
||||
const newValueId = result.rows[0].value_id;
|
||||
valueIdMap.set(value.value_id, newValueId);
|
||||
|
||||
valueCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 복사
|
||||
*/
|
||||
private async copyNumberingRules(
|
||||
rules: { rules: any[]; parts: any[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📋 채번 규칙 복사 중...`);
|
||||
|
||||
const ruleIdMap = new Map<string, string>(); // 원본 rule_id → 새 rule_id
|
||||
let ruleCount = 0;
|
||||
let partCount = 0;
|
||||
|
||||
// 1) 채번 규칙 복사
|
||||
for (const rule of rules.rules) {
|
||||
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 새 rule_id 생성 (타임스탬프 기반)
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
ruleIdMap.set(rule.rule_id, newRuleId);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator,
|
||||
reset_period, current_sequence, table_name, column_name,
|
||||
company_code, menu_objid, created_by, scope_type
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
newRuleId,
|
||||
rule.rule_name,
|
||||
rule.description,
|
||||
rule.separator,
|
||||
rule.reset_period,
|
||||
1, // 시퀀스 초기화
|
||||
rule.table_name,
|
||||
rule.column_name,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
userId,
|
||||
rule.scope_type,
|
||||
]
|
||||
);
|
||||
|
||||
ruleCount++;
|
||||
}
|
||||
|
||||
// 2) 채번 규칙 파트 복사
|
||||
for (const part of rules.parts) {
|
||||
const newRuleId = ruleIdMap.get(part.rule_id);
|
||||
if (!newRuleId) continue;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
newRuleId,
|
||||
part.part_order,
|
||||
part.part_type,
|
||||
part.generation_method,
|
||||
part.auto_config,
|
||||
part.manual_config,
|
||||
targetCompanyCode,
|
||||
]
|
||||
);
|
||||
|
||||
partCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회
|
||||
*
|
||||
* 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다.
|
||||
* 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다.
|
||||
*
|
||||
* @param menuObjid 메뉴 OBJID
|
||||
* @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적)
|
||||
*
|
||||
* @example
|
||||
* // 메뉴 구조:
|
||||
* // └── 구매관리 (100)
|
||||
* // ├── 공급업체관리 (101)
|
||||
* // ├── 발주관리 (102)
|
||||
* // └── 입고관리 (103)
|
||||
* // └── 입고상세 (104)
|
||||
*
|
||||
* await getMenuAndChildObjids(100);
|
||||
* // 결과: [100, 101, 102, 103, 104]
|
||||
*/
|
||||
export async function getMenuAndChildObjids(menuObjid: number): Promise<number[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid });
|
||||
|
||||
// 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회
|
||||
const query = `
|
||||
WITH RECURSIVE menu_tree AS (
|
||||
-- 시작점: 선택한 메뉴
|
||||
SELECT objid, parent_obj_id, 1 AS depth
|
||||
FROM menu_info
|
||||
WHERE objid = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 재귀: 하위 메뉴들
|
||||
SELECT m.objid, m.parent_obj_id, mt.depth + 1
|
||||
FROM menu_info m
|
||||
INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid
|
||||
WHERE mt.depth < 10 -- 무한 루프 방지
|
||||
)
|
||||
SELECT objid FROM menu_tree ORDER BY depth, objid
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [menuObjid]);
|
||||
const objids = result.rows.map((row) => Number(row.objid));
|
||||
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 완료", {
|
||||
menuObjid,
|
||||
totalCount: objids.length,
|
||||
objids
|
||||
});
|
||||
|
||||
return objids;
|
||||
} catch (error: any) {
|
||||
logger.error("메뉴 및 하위 메뉴 조회 실패", {
|
||||
menuObjid,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
// 에러 발생 시 안전하게 자기 자신만 반환
|
||||
return [menuObjid];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { getSiblingMenuObjids } from "./menuService";
|
||||
import { getMenuAndChildObjids } from "./menuService";
|
||||
|
||||
interface NumberingRulePart {
|
||||
id?: number;
|
||||
@@ -161,7 +161,7 @@ class NumberingRuleService {
|
||||
companyCode: string,
|
||||
menuObjid?: number
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
|
||||
try {
|
||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
|
||||
@@ -171,14 +171,14 @@ class NumberingRuleService {
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 형제 메뉴 OBJID 조회
|
||||
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
|
||||
if (menuObjid) {
|
||||
siblingObjids = await getSiblingMenuObjids(menuObjid);
|
||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
|
||||
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
|
||||
}
|
||||
|
||||
// menuObjid가 없으면 global 규칙만 반환
|
||||
if (!menuObjid || siblingObjids.length === 0) {
|
||||
if (!menuObjid || menuAndChildObjids.length === 0) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
@@ -280,7 +280,7 @@ class NumberingRuleService {
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함)
|
||||
// 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
@@ -301,8 +301,7 @@ class NumberingRuleService {
|
||||
WHERE
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
@@ -311,10 +310,10 @@ class NumberingRuleService {
|
||||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [siblingObjids];
|
||||
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||
params = [menuAndChildObjids];
|
||||
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||
// 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
@@ -336,8 +335,7 @@ class NumberingRuleService {
|
||||
AND (
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2))
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
@@ -347,8 +345,8 @@ class NumberingRuleService {
|
||||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [companyCode, siblingObjids];
|
||||
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||
params = [companyCode, menuAndChildObjids];
|
||||
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
|
||||
}
|
||||
|
||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||
@@ -420,7 +418,7 @@ class NumberingRuleService {
|
||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingCount: siblingObjids.length,
|
||||
menuAndChildCount: menuAndChildObjids.length,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
@@ -432,7 +430,7 @@ class NumberingRuleService {
|
||||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingObjids: siblingObjids || [],
|
||||
menuAndChildObjids: menuAndChildObjids || [],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -70,12 +70,13 @@ export class ScreenManagementService {
|
||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||
}
|
||||
|
||||
// 화면 생성 (Raw Query)
|
||||
// 화면 생성 (Raw Query) - REST API 지원 추가
|
||||
const [screen] = await query<any>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code, description, created_by,
|
||||
db_source_type, db_connection_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
|
||||
rest_api_endpoint, rest_api_json_path
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenData.screenName,
|
||||
@@ -86,6 +87,10 @@ export class ScreenManagementService {
|
||||
screenData.createdBy,
|
||||
screenData.dbSourceType || "internal",
|
||||
screenData.dbConnectionId || null,
|
||||
(screenData as any).dataSourceType || "database",
|
||||
(screenData as any).restApiConnectionId || null,
|
||||
(screenData as any).restApiEndpoint || null,
|
||||
(screenData as any).restApiJsonPath || "data",
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1977,6 +1982,11 @@ export class ScreenManagementService {
|
||||
updatedBy: data.updated_by,
|
||||
dbSourceType: data.db_source_type || "internal",
|
||||
dbConnectionId: data.db_connection_id || undefined,
|
||||
// REST API 관련 필드
|
||||
dataSourceType: data.data_source_type || "database",
|
||||
restApiConnectionId: data.rest_api_connection_id || undefined,
|
||||
restApiEndpoint: data.rest_api_endpoint || undefined,
|
||||
restApiJsonPath: data.rest_api_json_path || "data",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1066,6 +1066,66 @@ class TableCategoryValueService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||
*
|
||||
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param columnName - 컬럼명
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns 삭제된 매핑 수
|
||||
*/
|
||||
async deleteColumnMappingsByColumn(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode: string
|
||||
): Promise<number> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode });
|
||||
|
||||
// 멀티테넌시 적용
|
||||
let deleteQuery: string;
|
||||
let deleteParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
`;
|
||||
deleteParams = [tableName, columnName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 매핑만 삭제
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
deleteParams = [tableName, columnName, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(deleteQuery, deleteParams);
|
||||
const deletedCount = result.rowCount || 0;
|
||||
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제 완료", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
deletedCount
|
||||
});
|
||||
|
||||
return deletedCount;
|
||||
} catch (error: any) {
|
||||
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리적 컬럼명을 물리적 컬럼명으로 변환
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user