ERP-node/backend-node/src/services/screenManagementService.ts

5849 lines
190 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ✅ Prisma → Raw Query 전환 (Phase 2.1)
import { query, queryOne, transaction } from "../database/db";
import {
ScreenDefinition,
CreateScreenRequest,
UpdateScreenRequest,
LayoutData,
SaveLayoutRequest,
ScreenTemplate,
MenuAssignmentRequest,
PaginatedResponse,
ComponentData,
ColumnInfo,
ColumnWebTypeSetting,
WebType,
WidgetData,
} from "../types/screen";
import { generateId } from "../utils/generateId";
import logger from "../utils/logger";
import {
reconstructConfig,
extractConfigDiff,
} from "../utils/componentDefaults";
// 화면 복사 요청 인터페이스
interface CopyScreenRequest {
screenName: string;
screenCode: string;
description?: string;
companyCode: string; // 요청한 사용자의 회사 코드 (인증용)
createdBy?: string; // 생성자 ID
targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용)
}
// 백엔드에서 사용할 테이블 정보 타입
interface TableInfo {
tableName: string;
tableLabel: string;
columns: ColumnInfo[];
}
export class ScreenManagementService {
// ========================================
// 화면 정의 관리
// ========================================
/**
* 화면 정의 생성 (✅ Raw Query 전환 완료)
*/
async createScreen(
screenData: CreateScreenRequest,
userCompanyCode: string,
): Promise<ScreenDefinition> {
console.log(`=== 화면 생성 요청 ===`);
console.log(`요청 데이터:`, screenData);
console.log(`사용자 회사 코드:`, userCompanyCode);
// 화면 코드 중복 확인 (Raw Query)
const existingResult = await query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND is_active != 'D'
LIMIT 1`,
[screenData.screenCode],
);
console.log(
`화면 코드 '${screenData.screenCode}' 중복 검사 결과:`,
existingResult.length > 0 ? "중복됨" : "사용 가능",
);
if (existingResult.length > 0) {
console.log(`기존 화면 정보:`, existingResult[0]);
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 화면 생성 (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, 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,
screenData.screenCode,
screenData.tableName,
screenData.companyCode,
screenData.description || null,
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",
],
);
return this.mapToScreenDefinition(screen);
}
/**
* 회사별 화면 목록 조회 (페이징 지원) - 활성 화면만 (✅ Raw Query 전환 완료)
*/
async getScreensByCompany(
companyCode: string,
page: number = 1,
size: number = 20,
searchTerm?: string, // 검색어 추가
): Promise<PaginatedResponse<ScreenDefinition>> {
const offset = (page - 1) * size;
// WHERE 절 동적 생성
const whereConditions: string[] = ["is_active != 'D'"];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
if (searchTerm && searchTerm.trim() !== "") {
whereConditions.push(`(
screen_name ILIKE $${params.length + 1} OR
screen_code ILIKE $${params.length + 1} OR
table_name ILIKE $${params.length + 1}
)`);
params.push(`%${searchTerm.trim()}%`);
}
const whereSQL = whereConditions.join(" AND ");
// 페이징 쿼리 (Raw Query)
const [screens, totalResult] = await Promise.all([
query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
ORDER BY created_date DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, size, offset],
),
query<{ count: string }>(
`SELECT COUNT(*)::text as count FROM screen_definitions
WHERE ${whereSQL}`,
params,
),
]);
const total = parseInt(totalResult[0]?.count || "0", 10);
// 테이블 라벨 정보를 한 번에 조회 (Raw Query)
const tableNames = Array.from(
new Set(screens.map((s: any) => s.table_name).filter(Boolean)),
);
let tableLabelMap = new Map<string, string>();
if (tableNames.length > 0) {
try {
const placeholders = tableNames.map((_, i) => `$${i + 1}`).join(", ");
const tableLabels = await query<{
table_name: string;
table_label: string | null;
}>(
`SELECT table_name, table_label FROM table_labels
WHERE table_name IN (${placeholders})`,
tableNames,
);
tableLabelMap = new Map(
tableLabels.map((tl) => [
tl.table_name,
tl.table_label || tl.table_name,
]),
);
// 테스트: company_mng 라벨 직접 확인
if (tableLabelMap.has("company_mng")) {
console.log(
"✅ company_mng 라벨 찾음:",
tableLabelMap.get("company_mng"),
);
} else {
console.log("❌ company_mng 라벨 없음");
}
} catch (error) {
console.error("테이블 라벨 조회 오류:", error);
}
}
return {
data: screens.map((screen) =>
this.mapToScreenDefinition(screen, tableLabelMap),
),
pagination: {
page,
size,
total,
totalPages: Math.ceil(total / size),
},
};
}
/**
* 화면 목록 조회 (간단 버전) - 활성 화면만 (✅ Raw Query 전환 완료)
*/
async getScreens(companyCode: string): Promise<ScreenDefinition[]> {
// 동적 WHERE 절 생성
const whereConditions: string[] = ["is_active != 'D'"];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
const whereSQL = whereConditions.join(" AND ");
const screens = await query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
ORDER BY created_date DESC`,
params,
);
return screens.map((screen) => this.mapToScreenDefinition(screen));
}
/**
* 화면 정의 조회 (활성 화면만) (✅ Raw Query 전환 완료)
*/
async getScreenById(screenId: number): Promise<ScreenDefinition | null> {
const screens = await query<any>(
`SELECT * FROM screen_definitions
WHERE screen_id = $1 AND is_active != 'D'
LIMIT 1`,
[screenId],
);
return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null;
}
/**
* 화면 정의 조회 (회사 코드 검증 포함, 활성 화면만) (✅ Raw Query 전환 완료)
*/
async getScreen(
screenId: number,
companyCode: string,
): Promise<ScreenDefinition | null> {
// 동적 WHERE 절 생성
const whereConditions: string[] = [
"screen_id = $1",
"is_active != 'D'", // 삭제된 화면 제외
];
const params: any[] = [screenId];
// 회사 코드가 '*'가 아닌 경우 회사별 필터링
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
const whereSQL = whereConditions.join(" AND ");
const screens = await query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
LIMIT 1`,
params,
);
return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null;
}
/**
* 화면 정의 수정 (✅ Raw Query 전환 완료)
*/
async updateScreen(
screenId: number,
updateData: UpdateScreenRequest,
userCompanyCode: string,
): Promise<ScreenDefinition> {
// 권한 확인 (Raw Query)
const existingResult = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (existingResult.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = existingResult[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 수정할 권한이 없습니다.");
}
// 화면 업데이트 (Raw Query)
const [screen] = await query<any>(
`UPDATE screen_definitions
SET screen_name = $1,
description = $2,
is_active = $3,
updated_by = $4,
updated_date = $5
WHERE screen_id = $6
RETURNING *`,
[
updateData.screenName,
updateData.description || null,
updateData.isActive ? "Y" : "N",
updateData.updatedBy,
new Date(),
screenId,
],
);
return this.mapToScreenDefinition(screen);
}
/**
* 화면 정보 수정 (메타데이터만) - 편집 기능용
*/
async updateScreenInfo(
screenId: number,
updateData: {
screenName: string;
tableName?: string;
description?: string;
isActive: string;
// REST API 관련 필드 추가
dataSourceType?: string;
dbSourceType?: string;
dbConnectionId?: number;
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
},
userCompanyCode: string,
): Promise<void> {
// 권한 확인
const existingResult = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (existingResult.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = existingResult[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 수정할 권한이 없습니다.");
}
// 화면 정보 업데이트 (REST API 필드 포함)
await query(
`UPDATE screen_definitions
SET screen_name = $1,
table_name = $2,
description = $3,
is_active = $4,
updated_date = $5,
data_source_type = $6,
db_source_type = $7,
db_connection_id = $8,
rest_api_connection_id = $9,
rest_api_endpoint = $10,
rest_api_json_path = $11
WHERE screen_id = $12`,
[
updateData.screenName,
updateData.tableName || null,
updateData.description || null,
updateData.isActive,
new Date(),
updateData.dataSourceType || "database",
updateData.dbSourceType || "internal",
updateData.dbConnectionId || null,
updateData.restApiConnectionId || null,
updateData.restApiEndpoint || null,
updateData.restApiJsonPath || null,
screenId,
],
);
console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, {
dataSourceType: updateData.dataSourceType,
restApiConnectionId: updateData.restApiConnectionId,
restApiEndpoint: updateData.restApiEndpoint,
restApiJsonPath: updateData.restApiJsonPath,
});
}
/**
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
*/
async checkScreenDependencies(
screenId: number,
userCompanyCode: string,
): Promise<{
hasDependencies: boolean;
dependencies: Array<{
screenId: number;
screenName: string;
screenCode: string;
componentId: string;
componentType: string;
referenceType: string; // 'popup', 'navigate', 'targetScreen' 등
}>;
}> {
// 권한 확인
const targetScreens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (targetScreens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const targetScreen = targetScreens[0];
if (
userCompanyCode !== "*" &&
targetScreen.company_code !== "*" &&
targetScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면에 접근할 권한이 없습니다.");
}
// 같은 회사의 모든 활성 화면에서 이 화면을 참조하는지 확인
const whereConditions: string[] = ["sd.is_active != 'D'"];
const params: any[] = [];
if (userCompanyCode !== "*") {
whereConditions.push(
`sd.company_code IN ($${params.length + 1}, $${params.length + 2})`,
);
params.push(userCompanyCode, "*");
}
const whereSQL = whereConditions.join(" AND ");
// 화면과 레이아웃을 JOIN해서 조회
const allScreens = await query<any>(
`SELECT
sd.screen_id, sd.screen_name, sd.screen_code, sd.company_code,
sl.layout_id, sl.component_id, sl.component_type, sl.properties
FROM screen_definitions sd
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE ${whereSQL}
ORDER BY sd.screen_id, sl.layout_id`,
params,
);
const dependencies: Array<{
screenId: number;
screenName: string;
screenCode: string;
componentId: string;
componentType: string;
referenceType: string;
}> = [];
// 각 화면의 레이아웃에서 버튼 컴포넌트들을 검사
for (const screen of allScreens) {
if (screen.screen_id === screenId) continue; // 자기 자신은 제외
try {
// screen_layouts 테이블에서 버튼 컴포넌트 확인 (위젯 타입만)
if (screen.component_type === "widget") {
const properties = screen.properties as any;
// 버튼 컴포넌트인지 확인
if (properties?.widgetType === "button") {
const config = properties.webTypeConfig;
if (!config) continue;
// popup 액션에서 popupScreenId 확인
if (
config.actionType === "popup" &&
config.popupScreenId === screenId
) {
dependencies.push({
screenId: screen.screen_id,
screenName: screen.screen_name,
screenCode: screen.screen_code,
componentId: screen.component_id,
componentType: "button",
referenceType: "popup",
});
}
// navigate 액션에서 navigateScreenId 확인
if (
config.actionType === "navigate" &&
config.navigateScreenId === screenId
) {
dependencies.push({
screenId: screen.screen_id,
screenName: screen.screen_name,
screenCode: screen.screen_code,
componentId: screen.component_id,
componentType: "button",
referenceType: "navigate",
});
}
// navigateUrl에서 화면 ID 패턴 확인 (예: /screens/123)
if (
config.navigateUrl &&
config.navigateUrl.includes(`/screens/${screenId}`)
) {
dependencies.push({
screenId: screen.screen_id,
screenName: screen.screen_name,
screenCode: screen.screen_code,
componentId: screen.component_id,
componentType: "button",
referenceType: "url",
});
}
}
}
// 기존 layout_metadata도 확인 (하위 호환성) - 현재는 사용하지 않음
// 실제 데이터는 screen_layouts 테이블에서 개별적으로 조회해야 함
} catch (error) {
console.error(
`화면 ${screen.screen_id}의 레이아웃 분석 중 오류:`,
error,
);
continue;
}
}
// 메뉴 할당 확인 (Raw Query)
try {
const menuAssignments = await query<{
assignment_id: number;
menu_objid: number;
menu_name_kor?: string;
}>(
`SELECT sma.assignment_id, sma.menu_objid, mi.menu_name_kor
FROM screen_menu_assignments sma
LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid
WHERE sma.screen_id = $1 AND sma.is_active = 'Y'`,
[screenId],
);
// 메뉴에 할당된 경우 의존성에 추가
for (const assignment of menuAssignments) {
dependencies.push({
screenId: 0, // 메뉴는 화면이 아니므로 0으로 설정
screenName: assignment.menu_name_kor || "알 수 없는 메뉴",
screenCode: `MENU_${assignment.menu_objid}`,
componentId: `menu_${assignment.assignment_id}`,
componentType: "menu",
referenceType: "menu_assignment",
});
}
} catch (error) {
console.error("메뉴 할당 확인 중 오류:", error);
// 메뉴 할당 확인 실패해도 다른 의존성 체크는 계속 진행
}
return {
hasDependencies: dependencies.length > 0,
dependencies,
};
}
/**
* 화면 정의 삭제 (휴지통으로 이동 - 소프트 삭제) (✅ Raw Query 전환 완료)
*/
async deleteScreen(
screenId: number,
userCompanyCode: string,
deletedBy: string,
deleteReason?: string,
force: boolean = false,
): Promise<void> {
// 권한 확인 (Raw Query)
const existingResult = await query<{
company_code: string | null;
is_active: string;
}>(
`SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (existingResult.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = existingResult[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 삭제할 권한이 없습니다.");
}
// 이미 삭제된 화면인지 확인
if (existingScreen.is_active === "D") {
throw new Error("이미 삭제된 화면입니다.");
}
// 강제 삭제가 아닌 경우 의존성 체크
if (!force) {
const dependencyCheck = await this.checkScreenDependencies(
screenId,
userCompanyCode,
);
if (dependencyCheck.hasDependencies) {
const error = new Error("다른 화면에서 사용 중인 화면입니다.") as any;
error.code = "SCREEN_HAS_DEPENDENCIES";
error.dependencies = dependencyCheck.dependencies;
throw error;
}
}
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 (Raw Query)
await transaction(async (client) => {
// 1. 화면에서 사용하는 flowId 수집 (V2 레이아웃)
const layoutResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END
LIMIT 1`,
[screenId, userCompanyCode],
);
const layoutData = layoutResult.rows[0]?.layout_data;
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
// 2. 각 flowId가 다른 화면에서도 사용되는지 체크 후 삭제
if (flowIds.size > 0) {
for (const flowId of flowIds) {
// 다른 화면에서 사용 중인지 확인 (같은 회사 내, 삭제되지 않은 화면 기준)
const companyFilterForCheck = userCompanyCode === "*" ? "" : " AND sd.company_code = $3";
const checkParams = userCompanyCode === "*"
? [screenId, flowId]
: [screenId, flowId, userCompanyCode];
const otherUsageResult = await client.query<{ count: string }>(
`SELECT COUNT(*) as count FROM screen_layouts_v2 slv
JOIN screen_definitions sd ON slv.screen_id = sd.screen_id
WHERE slv.screen_id != $1
AND sd.is_active != 'D'
${companyFilterForCheck}
AND (
slv.layout_data::text LIKE '%"flowId":' || $2 || '%'
OR slv.layout_data::text LIKE '%"flowId":"' || $2 || '"%'
)`,
checkParams,
);
const otherUsageCount = parseInt(otherUsageResult.rows[0]?.count || "0");
// 다른 화면에서 사용하지 않는 경우에만 플로우 삭제
if (otherUsageCount === 0) {
// 해당 회사의 플로우만 삭제 (멀티테넌시)
const companyFilter = userCompanyCode === "*" ? "" : " AND company_code = $2";
const flowParams = userCompanyCode === "*" ? [flowId] : [flowId, userCompanyCode];
// 1. flow_definition 관련 데이터 먼저 삭제 (외래키 순서)
await client.query(
`DELETE FROM flow_step_connection WHERE flow_definition_id = $1`,
[flowId],
);
await client.query(
`DELETE FROM flow_step WHERE flow_definition_id = $1`,
[flowId],
);
await client.query(
`DELETE FROM flow_definition WHERE id = $1${companyFilter}`,
flowParams,
);
// 2. node_flows 테이블에서도 삭제 (제어플로우)
await client.query(
`DELETE FROM node_flows WHERE flow_id = $1${companyFilter}`,
flowParams,
);
logger.info("화면 삭제 시 플로우 삭제 (flow_definition + node_flows)", { screenId, flowId, companyCode: userCompanyCode });
} else {
logger.debug("플로우가 다른 화면에서 사용 중 - 삭제 스킵", { screenId, flowId, otherUsageCount });
}
}
}
// 3. 소프트 삭제 (휴지통으로 이동)
await client.query(
`UPDATE screen_definitions
SET is_active = 'D',
deleted_date = $1,
deleted_by = $2,
delete_reason = $3,
updated_date = $4,
updated_by = $5
WHERE screen_id = $6`,
[
new Date(),
deletedBy,
deleteReason || null,
new Date(),
deletedBy,
screenId,
],
);
// 4. 메뉴 할당도 비활성화
await client.query(
`UPDATE screen_menu_assignments
SET is_active = 'N'
WHERE screen_id = $1 AND is_active = 'Y'`,
[screenId],
);
// 5. 화면 그룹 연결 삭제 (screen_group_screens)
await client.query(
`DELETE FROM screen_group_screens WHERE screen_id = $1`,
[screenId],
);
logger.info("화면 삭제 시 그룹 연결 해제", { screenId });
});
}
/**
* 화면 복원 (휴지통에서 복원) (✅ Raw Query 전환 완료)
*/
async restoreScreen(
screenId: number,
userCompanyCode: string,
restoredBy: string,
): Promise<void> {
// 권한 확인
const screens = await query<{
company_code: string | null;
is_active: string;
screen_code: string;
}>(
`SELECT company_code, is_active, screen_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 복원할 권한이 없습니다.");
}
// 삭제된 화면이 아닌 경우
if (existingScreen.is_active !== "D") {
throw new Error("삭제된 화면이 아닙니다.");
}
// 화면 코드 중복 확인 (복원 시 같은 코드가 이미 존재하는지)
const duplicateScreens = await query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND is_active != 'D' AND screen_id != $2
LIMIT 1`,
[existingScreen.screen_code, screenId],
);
if (duplicateScreens.length > 0) {
throw new Error(
"같은 화면 코드를 가진 활성 화면이 이미 존재합니다. 복원하려면 기존 화면의 코드를 변경하거나 삭제해주세요.",
);
}
// 트랜잭션으로 화면 복원과 메뉴 할당 복원을 함께 처리
await transaction(async (client) => {
// 화면 복원
await client.query(
`UPDATE screen_definitions
SET is_active = 'Y', deleted_date = NULL, deleted_by = NULL,
delete_reason = NULL, updated_date = $1, updated_by = $2
WHERE screen_id = $3`,
[new Date(), restoredBy, screenId],
);
// 메뉴 할당도 다시 활성화
await client.query(
`UPDATE screen_menu_assignments
SET is_active = 'Y'
WHERE screen_id = $1 AND is_active = 'N'`,
[screenId],
);
});
}
/**
* 휴지통 화면들의 메뉴 할당 정리 (관리자용) (✅ Raw Query 전환 완료)
*/
async cleanupDeletedScreenMenuAssignments(): Promise<{
updatedCount: number;
message: string;
}> {
const result = await query(
`UPDATE screen_menu_assignments
SET is_active = 'N'
WHERE screen_id IN (
SELECT screen_id
FROM screen_definitions
WHERE is_active = 'D'
) AND is_active = 'Y'`,
[],
);
const updatedCount = result.length;
return {
updatedCount,
message: `${updatedCount}개의 메뉴 할당이 정리되었습니다.`,
};
}
/**
* 화면 영구 삭제 (휴지통에서 완전 삭제) (✅ Raw Query 전환 완료)
*/
async permanentDeleteScreen(
screenId: number,
userCompanyCode: string,
): Promise<void> {
// 권한 확인
const screens = await query<{
company_code: string | null;
is_active: string;
}>(
`SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 영구 삭제할 권한이 없습니다.");
}
// 삭제된 화면이 아닌 경우 영구 삭제 불가
if (existingScreen.is_active !== "D") {
throw new Error("휴지통에 있는 화면만 영구 삭제할 수 있습니다.");
}
// 물리적 삭제 (수동으로 관련 데이터 삭제)
await transaction(async (client) => {
await client.query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [
screenId,
]);
await client.query(
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
[screenId],
);
await client.query(
`DELETE FROM screen_definitions WHERE screen_id = $1`,
[screenId],
);
});
}
/**
* 휴지통 화면 목록 조회 (✅ Raw Query 전환 완료)
*/
async getDeletedScreens(
companyCode: string,
page: number = 1,
size: number = 20,
): Promise<
PaginatedResponse<
ScreenDefinition & {
deletedDate?: Date;
deletedBy?: string;
deleteReason?: string;
}
>
> {
const offset = (page - 1) * size;
const whereConditions: string[] = ["is_active = 'D'"];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
const whereSQL = whereConditions.join(" AND ");
const [screens, totalResult] = await Promise.all([
query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
ORDER BY deleted_date DESC NULLS LAST
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, size, offset],
),
query<{ count: string }>(
`SELECT COUNT(*)::text as count FROM screen_definitions WHERE ${whereSQL}`,
params,
),
]);
const total = parseInt(totalResult[0]?.count || "0", 10);
// 테이블 라벨 정보를 한 번에 조회
const tableNames = Array.from(
new Set(screens.map((s: any) => s.table_name).filter(Boolean)),
);
let tableLabelMap = new Map<string, string>();
if (tableNames.length > 0) {
const placeholders = tableNames.map((_, i) => `$${i + 1}`).join(", ");
const tableLabels = await query<{
table_name: string;
table_label: string | null;
}>(
`SELECT table_name, table_label FROM table_labels WHERE table_name IN (${placeholders})`,
tableNames,
);
tableLabelMap = new Map(
tableLabels.map((tl: any) => [
tl.table_name,
tl.table_label || tl.table_name,
]),
);
}
return {
data: screens.map((screen: any) => ({
...this.mapToScreenDefinition(screen, tableLabelMap),
deletedDate: screen.deleted_date || undefined,
deletedBy: screen.deleted_by || undefined,
deleteReason: screen.delete_reason || undefined,
})),
pagination: {
page,
size,
total,
totalPages: Math.ceil(total / size),
},
};
}
/**
* 활성 화면 일괄 삭제 (휴지통으로 이동)
*/
async bulkDeleteScreens(
screenIds: number[],
userCompanyCode: string,
deletedBy: string,
deleteReason?: string,
force: boolean = false,
): Promise<{
deletedCount: number;
skippedCount: number;
errors: Array<{ screenId: number; error: string }>;
}> {
if (screenIds.length === 0) {
throw new Error("삭제할 화면을 선택해주세요.");
}
let deletedCount = 0;
let skippedCount = 0;
const errors: Array<{ screenId: number; error: string }> = [];
// 각 화면을 개별적으로 삭제 처리
for (const screenId of screenIds) {
try {
// 권한 확인 (Raw Query)
const existingResult = await query<{
company_code: string | null;
is_active: string;
screen_name: string;
}>(
`SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (existingResult.length === 0) {
skippedCount++;
errors.push({
screenId,
error: "화면을 찾을 수 없습니다.",
});
continue;
}
const existingScreen = existingResult[0];
// 권한 확인
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
skippedCount++;
errors.push({
screenId,
error: "이 화면을 삭제할 권한이 없습니다.",
});
continue;
}
// 이미 삭제된 화면인지 확인
if (existingScreen.is_active === "D") {
skippedCount++;
errors.push({
screenId,
error: "이미 삭제된 화면입니다.",
});
continue;
}
// 강제 삭제가 아닌 경우 의존성 체크
if (!force) {
const dependencyCheck = await this.checkScreenDependencies(
screenId,
userCompanyCode,
);
if (dependencyCheck.hasDependencies) {
skippedCount++;
errors.push({
screenId,
error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`,
});
continue;
}
}
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리
await transaction(async (client) => {
const now = new Date();
// 소프트 삭제 (휴지통으로 이동)
await client.query(
`UPDATE screen_definitions
SET is_active = 'D',
deleted_date = $1,
deleted_by = $2,
delete_reason = $3,
updated_date = $4,
updated_by = $5
WHERE screen_id = $6`,
[now, deletedBy, deleteReason || null, now, deletedBy, screenId],
);
// 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거)
await client.query(
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
[screenId],
);
});
deletedCount++;
logger.info(
`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`,
);
} catch (error) {
skippedCount++;
errors.push({
screenId,
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
logger.error(`화면 삭제 실패: ${screenId}`, error);
}
}
logger.info(
`일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}`,
);
return { deletedCount, skippedCount, errors };
}
/**
* 휴지통 화면 일괄 영구 삭제
*/
async bulkPermanentDeleteScreens(
screenIds: number[],
userCompanyCode: string,
): Promise<{
deletedCount: number;
skippedCount: number;
errors: Array<{ screenId: number; error: string }>;
}> {
if (screenIds.length === 0) {
throw new Error("삭제할 화면을 선택해주세요.");
}
// 권한 확인 - 해당 회사의 휴지통 화면들만 조회
const whereClause: any = {
screen_id: { in: screenIds },
is_active: "D", // 휴지통에 있는 화면만
};
if (userCompanyCode !== "*") {
whereClause.company_code = userCompanyCode;
}
// WHERE 절 생성
const whereConditions: string[] = ["is_active = 'D'"];
const params: any[] = [];
if (userCompanyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(userCompanyCode);
}
const whereSQL = whereConditions.join(" AND ");
const screensToDelete = await query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions WHERE ${whereSQL}`,
params,
);
let deletedCount = 0;
let skippedCount = 0;
const errors: Array<{ screenId: number; error: string }> = [];
// 각 화면을 개별적으로 삭제 처리
for (const screenId of screenIds) {
try {
const screenToDelete = screensToDelete.find(
(s: any) => s.screen_id === screenId,
);
if (!screenToDelete) {
skippedCount++;
errors.push({
screenId,
error: "화면을 찾을 수 없거나 삭제 권한이 없습니다.",
});
continue;
}
// 관련 레이아웃 데이터도 함께 삭제 (트랜잭션)
await transaction(async (client) => {
// screen_layouts 삭제
await client.query(
`DELETE FROM screen_layouts WHERE screen_id = $1`,
[screenId],
);
// screen_menu_assignments 삭제
await client.query(
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
[screenId],
);
// screen_definitions 삭제
await client.query(
`DELETE FROM screen_definitions WHERE screen_id = $1`,
[screenId],
);
});
deletedCount++;
} catch (error) {
skippedCount++;
errors.push({
screenId,
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
console.error(`화면 ${screenId} 영구 삭제 실패:`, error);
}
}
return {
deletedCount,
skippedCount,
errors,
};
}
// ========================================
// 테이블 관리
// ========================================
/**
* 테이블 목록 조회 (모든 테이블) (✅ Raw Query 전환 완료)
*/
async getTables(companyCode: string): Promise<TableInfo[]> {
try {
// PostgreSQL에서 사용 가능한 테이블 목록 조회
const tables = await query<{ table_name: string }>(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name`,
[],
);
// 각 테이블의 컬럼 정보도 함께 조회
const tableInfos: TableInfo[] = [];
for (const table of tables) {
const columns = await this.getTableColumns(
table.table_name,
companyCode,
);
if (columns.length > 0) {
tableInfos.push({
tableName: table.table_name,
tableLabel: this.getTableLabel(table.table_name),
columns: columns,
});
}
}
return tableInfos;
} catch (error) {
console.error("테이블 목록 조회 실패:", error);
throw new Error("테이블 목록을 조회할 수 없습니다.");
}
}
/**
* 특정 테이블 정보 조회 (최적화된 단일 테이블 조회)
*/
async getTableInfo(
tableName: string,
companyCode: string,
): Promise<TableInfo | null> {
try {
console.log(`=== 단일 테이블 조회 시작: ${tableName} ===`);
// 테이블 존재 여부 확인
const tableExists = await query<{ table_name: string }>(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name = $1`,
[tableName],
);
if (tableExists.length === 0) {
console.log(`테이블 ${tableName}이 존재하지 않습니다.`);
return null;
}
// 해당 테이블의 컬럼 정보 조회
const columns = await this.getTableColumns(tableName, companyCode);
if (columns.length === 0) {
console.log(`테이블 ${tableName}의 컬럼 정보가 없습니다.`);
return null;
}
const tableInfo: TableInfo = {
tableName: tableName,
tableLabel: this.getTableLabel(tableName),
columns: columns,
};
console.log(
`단일 테이블 조회 완료: ${tableName}, 컬럼 ${columns.length}`,
);
return tableInfo;
} catch (error) {
console.error(`테이블 ${tableName} 조회 실패:`, error);
throw new Error(`테이블 ${tableName} 정보를 조회할 수 없습니다.`);
}
}
/**
* 테이블 컬럼 정보 조회 (✅ Raw Query 전환 완료)
*/
async getTableColumns(
tableName: string,
companyCode: string,
): Promise<ColumnInfo[]> {
try {
// 테이블 컬럼 정보 조회
const columns = await query<{
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
character_maximum_length: number | null;
numeric_precision: number | null;
numeric_scale: number | null;
}>(
`SELECT
column_name,
data_type,
is_nullable,
column_default,
character_maximum_length,
numeric_precision,
numeric_scale
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position`,
[tableName],
);
// 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음)
// 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리
console.log(
`🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`,
);
const typeInfo = await query<{
column_name: string;
input_type: string | null;
detail_settings: any;
}>(
`SELECT column_name, input_type, detail_settings
FROM table_type_columns
WHERE table_name = $1
AND company_code = $2
ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지)
[tableName, companyCode],
);
console.log(
`📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}`,
);
const currencyCodeType = typeInfo.find(
(t) => t.column_name === "currency_code",
);
if (currencyCodeType) {
console.log(
`💰 [getTableColumns] currency_code 발견:`,
currencyCodeType,
);
} else {
console.log(`⚠️ [getTableColumns] currency_code 없음`);
}
// table_type_columns 테이블에서 라벨 정보 조회 (우선순위 2)
const labelInfo = await query<{
column_name: string;
column_label: string | null;
}>(
`SELECT column_name, column_label
FROM table_type_columns
WHERE table_name = $1 AND company_code = '*'`,
[tableName],
);
// 🆕 category_column_mapping에서 코드 카테고리 정보 조회
const categoryInfo = await query<{
physical_column_name: string;
logical_column_name: string;
}>(
`SELECT physical_column_name, logical_column_name
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode],
);
// 컬럼 정보 매핑
const columnMap = new Map<string, any>();
// 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성
columns.forEach((column: any) => {
columnMap.set(column.column_name, {
tableName: tableName,
columnName: column.column_name,
dataType: column.data_type,
isNullable: column.is_nullable,
columnDefault: column.column_default || undefined,
characterMaximumLength: column.character_maximum_length || undefined,
numericPrecision: column.numeric_precision || undefined,
numericScale: column.numeric_scale || undefined,
});
});
console.log(
`🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}`,
);
// table_type_columns에서 input_type 추가 (중복 시 최신 것만)
const addedTypes = new Set<string>();
typeInfo.forEach((type) => {
const colName = type.column_name;
if (!addedTypes.has(colName) && columnMap.has(colName)) {
const col = columnMap.get(colName);
col.inputType = type.input_type;
col.webType = type.input_type; // webType도 동일하게 설정
col.detailSettings = type.detail_settings;
addedTypes.add(colName);
if (colName === "currency_code") {
console.log(
`✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`,
);
}
}
});
console.log(
`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}`,
);
// table_type_columns에서 라벨 추가
labelInfo.forEach((label) => {
const col = columnMap.get(label.column_name);
if (col) {
col.columnLabel =
label.column_label || this.getColumnLabel(label.column_name);
}
});
// category_column_mapping에서 코드 카테고리 추가
categoryInfo.forEach((cat) => {
const col = columnMap.get(cat.physical_column_name);
if (col) {
col.codeCategory = cat.logical_column_name;
}
});
// 최종 결과 생성
const result = Array.from(columnMap.values()).map((col) => ({
...col,
// 기본값 설정
columnLabel: col.columnLabel || this.getColumnLabel(col.columnName),
inputType: col.inputType || this.inferWebType(col.dataType),
webType: col.webType || this.inferWebType(col.dataType),
detailSettings: col.detailSettings || undefined,
codeCategory: col.codeCategory || undefined,
}));
// 디버깅: currency_code의 최종 inputType 확인
const currencyCodeResult = result.find(
(r) => r.columnName === "currency_code",
);
if (currencyCodeResult) {
console.log(`🎯 [getTableColumns] 최종 currency_code:`, {
inputType: currencyCodeResult.inputType,
webType: currencyCodeResult.webType,
dataType: currencyCodeResult.dataType,
});
}
console.log(`✅ [getTableColumns] 반환: ${result.length}개 컬럼`);
return result;
} catch (error) {
console.error("테이블 컬럼 조회 실패:", error);
throw new Error("테이블 컬럼 정보를 조회할 수 없습니다.");
}
}
/**
* 테이블 라벨 생성
*/
private getTableLabel(tableName: string): string {
// snake_case를 읽기 쉬운 형태로 변환
return tableName
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())
.replace(/\s+/g, " ")
.trim();
}
/**
* 컬럼 라벨 생성
*/
private getColumnLabel(columnName: string): string {
// snake_case를 읽기 쉬운 형태로 변환
return columnName
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())
.replace(/\s+/g, " ")
.trim();
}
/**
* 데이터 타입으로부터 웹타입 추론
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/v2-web-types");
const lowerType = dataType.toLowerCase();
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
// ========================================
// 레이아웃 관리
// ========================================
/**
* 레이아웃 저장 (✅ Raw Query 전환 완료)
*/
async saveLayout(
screenId: number,
layoutData: LayoutData,
companyCode: string,
): Promise<void> {
console.log(`=== 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}`);
console.log(`컴포넌트 수: ${layoutData.components.length}`);
console.log(`격자 설정:`, layoutData.gridSettings);
console.log(`해상도 설정:`, layoutData.screenResolution);
console.log(`기본 테이블:`, (layoutData as any).mainTableName);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 🆕 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
const mainTableName = (layoutData as any).mainTableName;
if (mainTableName) {
await query(
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
[mainTableName, screenId],
);
console.log(`✅ 화면 기본 테이블 업데이트: ${mainTableName}`);
}
// 기존 레이아웃 삭제 (컴포넌트와 메타데이터 모두)
await query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId]);
// 1. 메타데이터 저장 (격자 설정과 해상도 정보)
if (layoutData.gridSettings || layoutData.screenResolution) {
const metadata: any = {
gridSettings: layoutData.gridSettings,
screenResolution: layoutData.screenResolution,
};
await query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties, display_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
screenId,
"_metadata", // 특별한 타입으로 메타데이터 식별
`_metadata_${screenId}`,
null,
0,
0,
0,
0,
JSON.stringify(metadata),
-1, // 메타데이터는 맨 앞에 배치
],
);
console.log(`메타데이터 저장 완료:`, metadata);
}
// 2. 컴포넌트 저장
for (const component of layoutData.components) {
const { id, ...componentData } = component;
console.log(`저장 중인 컴포넌트:`, {
id: component.id,
type: component.type,
position: component.position,
size: component.size,
parentId: component.parentId,
title: (component as any).title,
});
// JSON 필드에 맞는 타입으로 변환
const properties: any = {
...componentData,
position: {
x: component.position.x,
y: component.position.y,
z: component.position.z || 1, // z 값 포함
},
size: {
width: component.size.width,
height: component.size.height,
},
};
// 🔍 디버깅: webTypeConfig.dataflowConfig 확인
if ((component as any).webTypeConfig?.dataflowConfig) {
console.log(
`🔍 컴포넌트 ${component.id}의 dataflowConfig:`,
JSON.stringify(
(component as any).webTypeConfig.dataflowConfig,
null,
2,
),
);
}
await query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
screenId,
component.type,
component.id,
component.parentId || null,
Math.round(component.position.x), // 정수로 반올림
Math.round(component.position.y), // 정수로 반올림
Math.round(component.size.width), // 정수로 반올림
Math.round(component.size.height), // 정수로 반올림
JSON.stringify(properties),
],
);
}
console.log(`=== 레이아웃 저장 완료 ===`);
}
/**
* 레이아웃 조회 (✅ Raw Query 전환 완료)
* V2 테이블 우선 조회 → 없으면 V1 테이블 조회
*/
async getLayout(
screenId: number,
companyCode: string,
): Promise<LayoutData | null> {
console.log(`=== 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}`);
// 권한 확인 및 테이블명 조회
const screens = await query<{
company_code: string | null;
table_name: string | null;
}>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
}
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*))
let v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
// 회사별 레이아웃 없으면 공통(*) 조회
if (!v2Layout && companyCode !== "*") {
v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
[screenId],
);
}
// V2 레이아웃이 있으면 V2 형식으로 반환
if (v2Layout && v2Layout.layout_data) {
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
const layoutData = v2Layout.layout_data;
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
const getTypeFromUrl = (url: string | undefined): string => {
if (!url) return "component";
const parts = url.split("/");
return parts[parts.length - 1] || "component";
};
// V2 형식의 components를 LayoutData 형식으로 변환
const components = (layoutData.components || []).map((comp: any) => {
const componentType = getTypeFromUrl(comp.url);
return {
id: comp.id,
type: componentType,
position: comp.position || { x: 0, y: 0, z: 1 },
size: comp.size || { width: 200, height: 100 },
componentUrl: comp.url,
componentType: componentType,
componentConfig: comp.overrides || {},
displayOrder: comp.displayOrder || 0,
...comp.overrides,
};
});
// screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산
let screenResolution = layoutData.screenResolution;
if (!screenResolution && components.length > 0) {
let maxRight = 0;
let maxBottom = 0;
for (const comp of layoutData.components || []) {
const right = (comp.position?.x || 0) + (comp.size?.width || 200);
const bottom = (comp.position?.y || 0) + (comp.size?.height || 100);
maxRight = Math.max(maxRight, right);
maxBottom = Math.max(maxBottom, bottom);
}
// 여백 100px 추가, 최소 1200x800 보장
screenResolution = {
width: Math.max(1200, maxRight + 100),
height: Math.max(800, maxBottom + 100),
};
console.log(`screenResolution 자동 계산:`, screenResolution);
}
return {
components,
gridSettings: layoutData.gridSettings || {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
},
screenResolution,
};
}
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
const layouts = await query<any>(
`SELECT * FROM screen_layouts
WHERE screen_id = $1
ORDER BY display_order ASC NULLS LAST, layout_id ASC`,
[screenId],
);
console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`);
// 메타데이터와 컴포넌트 분리
const metadataLayout = layouts.find(
(layout) => layout.component_type === "_metadata",
);
const componentLayouts = layouts.filter(
(layout) => layout.component_type !== "_metadata",
);
// 기본 메타데이터 설정
let gridSettings = {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
};
let screenResolution = null;
// 저장된 메타데이터가 있으면 적용
if (metadataLayout && metadataLayout.properties) {
const metadata = metadataLayout.properties as any;
if (metadata.gridSettings) {
gridSettings = { ...gridSettings, ...metadata.gridSettings };
}
if (metadata.screenResolution) {
screenResolution = metadata.screenResolution;
}
console.log(`메타데이터 로드:`, { gridSettings, screenResolution });
}
if (componentLayouts.length === 0) {
return {
components: [],
gridSettings,
screenResolution,
};
}
// 🔥 최신 inputType 정보 조회 (table_type_columns에서)
const inputTypeMap = await this.getLatestInputTypes(
componentLayouts,
companyCode,
);
const components: ComponentData[] = componentLayouts.map((layout) => {
const properties = layout.properties as any;
// 🔥 최신 inputType으로 widgetType 및 componentType 업데이트
const tableName = properties?.tableName;
const columnName = properties?.columnName;
const latestTypeInfo =
tableName && columnName
? inputTypeMap.get(`${tableName}.${columnName}`)
: null;
// 🆕 V2 컴포넌트는 덮어쓰지 않음 (새로운 컴포넌트 시스템 보호)
const savedComponentType = properties?.componentType;
const isV2Component = savedComponentType?.startsWith("v2-");
const component = {
id: layout.component_id,
// 🔥 최신 componentType이 있으면 type 덮어쓰기 (단, V2 컴포넌트는 제외)
type: isV2Component
? (layout.component_type as any) // V2는 저장된 값 유지
: latestTypeInfo?.componentType || (layout.component_type as any),
position: {
x: layout.position_x,
y: layout.position_y,
z: properties?.position?.z || 1, // z 값 복원
},
size: { width: layout.width, height: layout.height },
parentId: layout.parent_id,
...properties,
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 (단, V2 컴포넌트는 제외)
...(!isV2Component &&
latestTypeInfo && {
widgetType: latestTypeInfo.inputType,
inputType: latestTypeInfo.inputType,
componentType: latestTypeInfo.componentType,
componentConfig: {
...properties?.componentConfig,
type: latestTypeInfo.componentType,
inputType: latestTypeInfo.inputType,
},
}),
};
console.log(`로드된 컴포넌트:`, {
id: component.id,
type: component.type,
position: component.position,
size: component.size,
parentId: component.parentId,
title: (component as any).title,
widgetType: (component as any).widgetType,
componentType: (component as any).componentType,
latestTypeInfo,
});
return component;
});
console.log(`=== 레이아웃 로드 완료 ===`);
console.log(`반환할 컴포넌트 수: ${components.length}`);
console.log(`최종 격자 설정:`, gridSettings);
console.log(`최종 해상도 설정:`, screenResolution);
console.log(`테이블명:`, existingScreen.table_name);
return {
components,
gridSettings,
screenResolution,
tableName: existingScreen.table_name, // 🆕 테이블명 추가
};
}
/**
* V1 레이아웃 조회 (component_url + custom_config 기반)
* screen_layouts_v1 테이블에서 조회
*
* 🔒 확정 사항:
* - component_url: 컴포넌트 파일 경로 (필수, NOT NULL)
* - custom_config: 회사별 커스텀 설정 (slot 포함)
* - company_code: 멀티테넌시 필터 필수
*/
async getLayoutV1(
screenId: number,
companyCode: string,
): Promise<LayoutData | null> {
console.log(`=== V1 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 권한 확인 및 테이블명 조회
const screens = await query<{
company_code: string | null;
table_name: string | null;
}>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
}
// V1 테이블에서 조회 (company_code 필터 포함 - 멀티테넌시 필수)
const layouts = await query<any>(
`SELECT * FROM screen_layouts_v1
WHERE screen_id = $1
AND (company_code = $2 OR $2 = '*')
ORDER BY display_order ASC NULLS LAST, layout_id ASC`,
[screenId, companyCode],
);
console.log(`V1 DB에서 조회된 레이아웃 수: ${layouts.length}`);
if (layouts.length === 0) {
return {
components: [],
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
},
screenResolution: null,
};
}
const components: ComponentData[] = layouts.map((layout: any) => {
// component_url에서 컴포넌트 타입 추출
// "@/lib/registry/components/split-panel-layout" → "split-panel-layout"
const componentUrl = layout.component_url || "";
const componentType = componentUrl.split("/").pop() || "unknown";
// custom_config가 곧 componentConfig
const componentConfig = layout.custom_config || {};
const component = {
id: layout.component_id,
type: componentType as any,
componentType: componentType,
componentUrl: componentUrl, // URL도 전달
position: {
x: layout.position_x,
y: layout.position_y,
z: 1,
},
size: {
width: layout.width,
height: layout.height,
},
parentId: layout.parent_id,
componentConfig,
};
return component;
});
console.log(`=== V1 레이아웃 로드 완료 ===`);
console.log(`반환할 컴포넌트 수: ${components.length}`);
return {
components,
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
},
screenResolution: null,
tableName: existingScreen.table_name,
};
}
/**
* 입력 타입에 해당하는 컴포넌트 ID 반환
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
*/
private getComponentIdFromInputType(inputType: string): string {
const mapping: Record<string, string> = {
// 텍스트 입력
text: "text-input",
email: "text-input",
password: "text-input",
tel: "text-input",
// 숫자 입력
number: "number-input",
decimal: "number-input",
// 날짜/시간
date: "date-input",
datetime: "date-input",
time: "date-input",
// 텍스트 영역
textarea: "textarea-basic",
// 선택
select: "select-basic",
dropdown: "select-basic",
// 체크박스/라디오
checkbox: "checkbox-basic",
radio: "radio-basic",
boolean: "toggle-switch",
// 파일
file: "file-upload",
// 이미지
image: "image-widget",
img: "image-widget",
picture: "image-widget",
photo: "image-widget",
// 버튼
button: "button-primary",
// 기타
label: "text-display",
code: "select-basic",
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
category: "select-basic",
};
return mapping[inputType] || "text-input";
}
/**
* 컴포넌트들의 최신 inputType 정보 조회
* @param layouts - 레이아웃 목록
* @param companyCode - 회사 코드
* @returns Map<"tableName.columnName", { inputType, componentType }>
*/
private async getLatestInputTypes(
layouts: any[],
companyCode: string,
): Promise<Map<string, { inputType: string; componentType: string }>> {
const inputTypeMap = new Map<
string,
{ inputType: string; componentType: string }
>();
// tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출
const tableColumnPairs = new Set<string>();
for (const layout of layouts) {
const properties = layout.properties as any;
if (properties?.tableName && properties?.columnName) {
tableColumnPairs.add(
`${properties.tableName}|${properties.columnName}`,
);
}
}
if (tableColumnPairs.size === 0) {
return inputTypeMap;
}
// 각 테이블-컬럼 조합에 대해 최신 inputType 조회
const pairs = Array.from(tableColumnPairs).map((pair) => {
const [tableName, columnName] = pair.split("|");
return { tableName, columnName };
});
// 배치 쿼리로 한 번에 조회
const placeholders = pairs
.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`)
.join(", ");
const params = pairs.flatMap((p) => [p.tableName, p.columnName]);
try {
const results = await query<{
table_name: string;
column_name: string;
input_type: string;
}>(
`SELECT table_name, column_name, input_type
FROM table_type_columns
WHERE (table_name, column_name) IN (${placeholders})
AND company_code = $${params.length + 1}`,
[...params, companyCode],
);
for (const row of results) {
const componentType = this.getComponentIdFromInputType(row.input_type);
inputTypeMap.set(`${row.table_name}.${row.column_name}`, {
inputType: row.input_type,
componentType: componentType,
});
}
console.log(`최신 inputType 조회 완료: ${results.length}`);
} catch (error) {
console.warn(`최신 inputType 조회 실패 (무시됨):`, error);
}
return inputTypeMap;
}
// ========================================
// 템플릿 관리
// ========================================
/**
* 템플릿 목록 조회 (회사별) (✅ Raw Query 전환 완료)
*/
async getTemplatesByCompany(
companyCode: string,
type?: string,
isPublic?: boolean,
): Promise<ScreenTemplate[]> {
const whereConditions: string[] = [];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
if (type) {
whereConditions.push(`template_type = $${params.length + 1}`);
params.push(type);
}
if (isPublic !== undefined) {
whereConditions.push(`is_public = $${params.length + 1}`);
params.push(isPublic);
}
const whereSQL =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const templates = await query<any>(
`SELECT * FROM screen_templates
${whereSQL}
ORDER BY created_date DESC`,
params,
);
return templates.map(this.mapToScreenTemplate);
}
/**
* 템플릿 생성 (✅ Raw Query 전환 완료)
*/
async createTemplate(
templateData: Partial<ScreenTemplate>,
): Promise<ScreenTemplate> {
const [template] = await query<any>(
`INSERT INTO screen_templates (
template_name, template_type, company_code, description,
layout_data, is_public, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
templateData.templateName!,
templateData.templateType!,
templateData.companyCode!,
templateData.description || null,
templateData.layoutData
? JSON.stringify(JSON.parse(JSON.stringify(templateData.layoutData)))
: null,
templateData.isPublic || false,
templateData.createdBy || null,
],
);
return this.mapToScreenTemplate(template);
}
// ========================================
// 메뉴 할당 관리
// ========================================
/**
* 화면-메뉴 할당 (✅ Raw Query 전환 완료)
*/
async assignScreenToMenu(
screenId: number,
assignmentData: MenuAssignmentRequest,
): Promise<void> {
// 중복 할당 방지
const existing = await query<{ assignment_id: number }>(
`SELECT assignment_id FROM screen_menu_assignments
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3
LIMIT 1`,
[screenId, assignmentData.menuObjid, assignmentData.companyCode],
);
if (existing.length > 0) {
throw new Error("이미 할당된 화면입니다.");
}
// screen_menu_assignments에 할당 추가
await query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, created_by
) VALUES ($1, $2, $3, $4, $5)`,
[
screenId,
assignmentData.menuObjid,
assignmentData.companyCode,
assignmentData.displayOrder || 0,
assignmentData.createdBy || null,
],
);
// 화면 정보 조회 (screen_code 가져오기)
const screen = await queryOne<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
[screenId],
);
if (screen) {
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 설정)
// 관리자 메뉴인지 확인
const menu = await queryOne<{ menu_type: string }>(
`SELECT menu_type FROM menu_info WHERE objid = $1`,
[assignmentData.menuObjid],
);
const isAdminMenu =
menu && (menu.menu_type === "0" || menu.menu_type === "admin");
const menuUrl = isAdminMenu
? `/screens/${screenId}?mode=admin`
: `/screens/${screenId}`;
await query(
`UPDATE menu_info
SET menu_url = $1, screen_code = $2
WHERE objid = $3`,
[menuUrl, screen.screen_code, assignmentData.menuObjid],
);
logger.info("화면 할당 완료 (menu_info 업데이트)", {
screenId,
menuObjid: assignmentData.menuObjid,
menuUrl,
screenCode: screen.screen_code,
});
}
}
/**
* 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료)
*/
async getScreensByMenu(
menuObjid: number,
companyCode: string,
): Promise<ScreenDefinition[]> {
const screens = await query<any>(
`SELECT sd.* FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = $1
AND sma.company_code = $2
AND sma.is_active = 'Y'
ORDER BY sma.display_order ASC`,
[menuObjid, companyCode],
);
return screens.map((screen) => this.mapToScreenDefinition(screen));
}
/**
* 화면에 할당된 메뉴 조회 (첫 번째 할당만 반환)
* 화면 편집기에서 menuObjid를 가져오기 위해 사용
*/
async getMenuByScreen(
screenId: number,
companyCode: string,
): Promise<{ menuObjid: number; menuName?: string } | null> {
const result = await queryOne<{
menu_objid: string;
menu_name_kor?: string;
}>(
`SELECT sma.menu_objid, mi.menu_name_kor
FROM screen_menu_assignments sma
LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid
WHERE sma.screen_id = $1
AND sma.company_code = $2
AND sma.is_active = 'Y'
ORDER BY sma.created_date ASC
LIMIT 1`,
[screenId, companyCode],
);
if (!result) {
return null;
}
return {
menuObjid: parseInt(result.menu_objid),
menuName: result.menu_name_kor,
};
}
/**
* 화면-메뉴 할당 해제 (✅ Raw Query 전환 완료)
*/
async unassignScreenFromMenu(
screenId: number,
menuObjid: number,
companyCode: string,
): Promise<void> {
// screen_menu_assignments에서 할당 삭제
await query(
`DELETE FROM screen_menu_assignments
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`,
[screenId, menuObjid, companyCode],
);
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 제거)
await query(
`UPDATE menu_info
SET menu_url = NULL, screen_code = NULL
WHERE objid = $1`,
[menuObjid],
);
logger.info("화면 할당 해제 완료 (menu_info 업데이트)", {
screenId,
menuObjid,
companyCode,
});
}
// ========================================
// 테이블 타입 연계
// ========================================
/**
* 컬럼 정보 조회 (웹 타입 포함) (✅ Raw Query 전환 완료)
*/
async getColumnInfo(tableName: string): Promise<ColumnInfo[]> {
const columns = await query<any>(
`SELECT
c.column_name,
COALESCE(ttc.column_label, c.column_name) as column_label,
c.data_type,
COALESCE(ttc.input_type, 'text') as web_type,
c.is_nullable,
c.column_default,
c.character_maximum_length,
c.numeric_precision,
c.numeric_scale,
ttc.detail_settings,
ttc.code_category,
ttc.reference_table,
ttc.reference_column,
ttc.display_column,
ttc.is_visible,
ttc.display_order,
ttc.description
FROM information_schema.columns c
LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name
AND c.column_name = ttc.column_name AND ttc.company_code = '*'
WHERE c.table_name = $1
ORDER BY COALESCE(ttc.display_order, c.ordinal_position)`,
[tableName],
);
return columns as ColumnInfo[];
}
/**
* 입력 타입 설정 (✅ Raw Query 전환 완료)
*/
async setColumnWebType(
tableName: string,
columnName: string,
webType: WebType,
additionalSettings?: Partial<ColumnWebTypeSetting>,
): Promise<void> {
// UPSERT를 INSERT ... ON CONFLICT로 변환 (table_type_columns 사용)
await query(
`INSERT INTO table_type_columns (
table_name, column_name, column_label, input_type, detail_settings,
code_category, reference_table, reference_column, display_column,
is_visible, display_order, description, is_nullable, company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', '*', $13, $14)
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category),
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table),
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column),
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
description = COALESCE(EXCLUDED.description, table_type_columns.description),
updated_date = EXCLUDED.updated_date`,
[
tableName,
columnName,
additionalSettings?.columnLabel || null,
webType,
additionalSettings?.detailSettings
? JSON.stringify(additionalSettings.detailSettings)
: null,
additionalSettings?.codeCategory || null,
additionalSettings?.referenceTable || null,
additionalSettings?.referenceColumn || null,
(additionalSettings as any)?.displayColumn || null,
additionalSettings?.isVisible ?? true,
additionalSettings?.displayOrder ?? 0,
additionalSettings?.description || null,
new Date(),
new Date(),
],
);
}
/**
* 웹 타입별 위젯 생성
*/
generateWidgetFromColumn(column: ColumnInfo): WidgetData {
const baseWidget = {
id: generateId(),
tableName: column.tableName,
columnName: column.columnName,
type: column.webType || "text",
label: column.columnLabel || column.columnName,
required: column.isNullable === "N",
readonly: false,
};
// detail_settings JSON 파싱
const detailSettings = column.detailSettings
? JSON.parse(column.detailSettings)
: {};
switch (column.webType) {
case "text":
return {
...baseWidget,
maxLength: detailSettings.maxLength || column.characterMaximumLength,
placeholder: `Enter ${column.columnLabel || column.columnName}`,
pattern: detailSettings.pattern,
};
case "number":
return {
...baseWidget,
min: detailSettings.min,
max:
detailSettings.max ||
(column.numericPrecision
? Math.pow(10, column.numericPrecision) - 1
: undefined),
step:
detailSettings.step ||
(column.numericScale && column.numericScale > 0
? Math.pow(10, -column.numericScale)
: 1),
};
case "date":
return {
...baseWidget,
format: detailSettings.format || "YYYY-MM-DD",
minDate: detailSettings.minDate,
maxDate: detailSettings.maxDate,
};
case "code":
return {
...baseWidget,
codeCategory: column.codeCategory,
multiple: detailSettings.multiple || false,
searchable: detailSettings.searchable || false,
};
case "entity":
return {
...baseWidget,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
searchable: detailSettings.searchable || true,
multiple: detailSettings.multiple || false,
};
case "textarea":
return {
...baseWidget,
rows: detailSettings.rows || 3,
maxLength: detailSettings.maxLength || column.characterMaximumLength,
};
case "select":
return {
...baseWidget,
options: detailSettings.options || [],
multiple: detailSettings.multiple || false,
searchable: detailSettings.searchable || false,
};
case "checkbox":
return {
...baseWidget,
defaultChecked: detailSettings.defaultChecked || false,
label: detailSettings.label || column.columnLabel,
};
case "radio":
return {
...baseWidget,
options: detailSettings.options || [],
inline: detailSettings.inline || false,
};
case "file":
return {
...baseWidget,
accept: detailSettings.accept || "*/*",
maxSize: detailSettings.maxSize || 10485760, // 10MB
multiple: detailSettings.multiple || false,
};
default:
return {
...baseWidget,
type: "text",
};
}
}
// ========================================
// 유틸리티 메서드
// ========================================
private mapToScreenDefinition(
data: any,
tableLabelMap?: Map<string, string>,
): ScreenDefinition {
const tableLabel = tableLabelMap?.get(data.table_name) || data.table_name;
return {
screenId: data.screen_id,
screenName: data.screen_name,
screenCode: data.screen_code,
tableName: data.table_name,
tableLabel: tableLabel, // 라벨이 있으면 라벨, 없으면 테이블명
companyCode: data.company_code,
description: data.description,
isActive: data.is_active,
createdDate: data.created_date,
createdBy: data.created_by,
updatedDate: data.updated_date,
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",
};
}
private mapToScreenTemplate(data: any): ScreenTemplate {
return {
templateId: data.template_id,
templateName: data.template_name,
templateType: data.template_type,
companyCode: data.company_code,
description: data.description,
layoutData: data.layout_data,
isPublic: data.is_public,
createdBy: data.created_by,
createdDate: data.created_date,
};
}
/**
* 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료)
* 동시성 문제 방지: Advisory Lock 사용
*/
async generateScreenCode(companyCode: string): Promise<string> {
return await transaction(async (client) => {
// 회사 코드를 숫자로 변환하여 advisory lock ID로 사용
const lockId = Buffer.from(companyCode).reduce(
(acc, byte) => acc + byte,
0,
);
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
await client.query("SELECT pg_advisory_xact_lock($1)", [lockId]);
// 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지)
// LIMIT 제거하고 숫자 추출하여 최대값 찾기
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE screen_code LIKE $1
ORDER BY screen_code DESC`,
[`${companyCode}_%`],
);
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`,
);
console.log(
`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`,
);
console.log(`🔍 패턴: ${pattern}`);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`);
if (number > maxNumber) {
maxNumber = number;
}
}
}
// 다음 순번으로 화면 코드 생성
const nextNumber = maxNumber + 1;
// 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
const newCode = `${companyCode}_${nextNumber}`;
console.log(
`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`,
);
return newCode;
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
});
}
/**
* 여러 개의 화면 코드를 한 번에 생성 (중복 방지)
* 한 트랜잭션 내에서 순차적으로 생성하여 중복 방지
*/
async generateMultipleScreenCodes(
companyCode: string,
count: number,
): Promise<string[]> {
return await transaction(async (client) => {
// Advisory lock 획득
const lockId = Buffer.from(companyCode).reduce(
(acc, byte) => acc + byte,
0,
);
await client.query("SELECT pg_advisory_xact_lock($1)", [lockId]);
// 현재 최대 번호 조회 (숫자 추출 후 정렬)
// 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
const existingScreens = await client.query<{
screen_code: string;
num: number;
}>(
`SELECT screen_code,
COALESCE(
NULLIF(
regexp_replace(screen_code, $2, '\\1'),
screen_code
)::integer,
0
) as num
FROM screen_definitions
WHERE company_code = $1
AND screen_code ~ $2
AND deleted_date IS NULL
ORDER BY num DESC
LIMIT 1`,
[
companyCode,
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`,
],
);
let maxNumber = 0;
if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
maxNumber = existingScreens.rows[0].num;
}
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode}${maxNumber}`);
// count개의 코드를 순차적으로 생성
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const nextNumber = maxNumber + i + 1;
const paddedNumber = nextNumber.toString().padStart(3, "0");
codes.push(`${companyCode}_${paddedNumber}`);
}
console.log(
`🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(", ")}]`,
);
return codes;
});
}
/**
* 화면명 중복 체크
* 같은 회사 내에서 동일한 화면명이 있는지 확인
*/
async checkDuplicateScreenName(
companyCode: string,
screenName: string,
): Promise<boolean> {
const result = await query<any>(
`SELECT COUNT(*) as count
FROM screen_definitions
WHERE company_code = $1
AND screen_name = $2
AND deleted_date IS NULL`,
[companyCode, screenName],
);
const count = parseInt(result[0]?.count || "0", 10);
return count > 0;
}
/**
* 화면에 연결된 모달/화면들을 재귀적으로 자동 감지
* - 버튼 컴포넌트: popup/modal/edit/openModalWithData 액션의 targetScreenId
* - 조건부 컨테이너: sections[].screenId (조건별 화면 할당)
* - 중첩된 화면들도 모두 감지 (재귀)
*/
async detectLinkedModalScreens(
screenId: number,
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`);
const allLinkedScreenIds = new Set<number>();
const visited = new Set<number>(); // 무한 루프 방지
const queue: number[] = [screenId]; // BFS 큐
// BFS로 연결된 모든 화면 탐색
while (queue.length > 0) {
const currentScreenId = queue.shift()!;
// 이미 방문한 화면은 스킵 (순환 참조 방지)
if (visited.has(currentScreenId)) {
console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`);
continue;
}
visited.add(currentScreenId);
console.log(
`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`,
);
// 현재 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[currentScreenId],
);
console.log(` 📦 레이아웃 개수: ${layouts.length}`);
// 각 레이아웃에서 연결된 화면 ID 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// 1. 버튼 컴포넌트의 액션 확인
if (
properties?.componentType === "button" ||
properties?.componentType?.startsWith("button-")
) {
const action = properties?.componentConfig?.action;
const modalActionTypes = [
"popup",
"modal",
"edit",
"openModalWithData",
];
if (
modalActionTypes.includes(action?.type) &&
action?.targetScreenId
) {
const targetScreenId = parseInt(action.targetScreenId);
if (
!isNaN(targetScreenId) &&
targetScreenId !== currentScreenId
) {
// 메인 화면이 아닌 경우에만 추가
if (targetScreenId !== screenId) {
allLinkedScreenIds.add(targetScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(targetScreenId)) {
queue.push(targetScreenId);
console.log(
` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`,
);
}
}
}
}
// 2. conditional-container 컴포넌트의 sections 확인
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const sectionScreenId = parseInt(section.screenId);
if (
!isNaN(sectionScreenId) &&
sectionScreenId !== currentScreenId
) {
// 메인 화면이 아닌 경우에만 추가
if (sectionScreenId !== screenId) {
allLinkedScreenIds.add(sectionScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(sectionScreenId)) {
queue.push(sectionScreenId);
console.log(
` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`,
);
}
}
}
}
}
} catch (error) {
console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
}
}
console.log(
`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}`,
);
console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`);
console.log(
` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`,
);
// 감지된 화면 ID들의 정보 조회
if (allLinkedScreenIds.size === 0) {
console.log(` 연결된 화면이 없습니다.`);
return [];
}
const screenIds = Array.from(allLinkedScreenIds);
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
const linkedScreens = await query<any>(
`SELECT screen_id, screen_name, screen_code
FROM screen_definitions
WHERE screen_id IN (${placeholders})
AND deleted_date IS NULL
ORDER BY screen_name`,
screenIds,
);
console.log(`\n📋 최종 감지된 화면 목록:`);
linkedScreens.forEach((s: any) => {
console.log(
` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`,
);
});
return linkedScreens.map((s) => ({
screenId: s.screen_id,
screenName: s.screen_name,
screenCode: s.screen_code,
}));
}
/**
* 화면 레이아웃에서 사용하는 numberingRuleId 수집
* - componentConfig.autoGeneration.options.numberingRuleId (text-input)
* - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal)
* - componentConfig.action.excelNumberingRuleId (엑셀 업로드)
*/
private collectNumberingRuleIdsFromLayouts(layouts: any[]): Set<string> {
const ruleIds = new Set<string>();
for (const layout of layouts) {
const props = layout.properties;
if (!props) continue;
// 1. componentConfig.autoGeneration.options.numberingRuleId (text-input 컴포넌트)
const autoGenRuleId =
props?.componentConfig?.autoGeneration?.options?.numberingRuleId;
if (
autoGenRuleId &&
typeof autoGenRuleId === "string" &&
autoGenRuleId.startsWith("rule-")
) {
ruleIds.add(autoGenRuleId);
}
// 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal)
const sections = props?.componentConfig?.sections;
if (Array.isArray(sections)) {
for (const section of sections) {
const fields = section?.fields;
if (Array.isArray(fields)) {
for (const field of fields) {
const ruleId = field?.numberingRule?.ruleId;
if (
ruleId &&
typeof ruleId === "string" &&
ruleId.startsWith("rule-")
) {
ruleIds.add(ruleId);
}
}
}
// optionalFieldGroups 내부의 필드들도 확인
const optGroups = section?.optionalFieldGroups;
if (Array.isArray(optGroups)) {
for (const optGroup of optGroups) {
const optFields = optGroup?.fields;
if (Array.isArray(optFields)) {
for (const field of optFields) {
const ruleId = field?.numberingRule?.ruleId;
if (
ruleId &&
typeof ruleId === "string" &&
ruleId.startsWith("rule-")
) {
ruleIds.add(ruleId);
}
}
}
}
}
}
}
// 3. componentConfig.action.excelNumberingRuleId (엑셀 업로드)
const excelRuleId = props?.componentConfig?.action?.excelNumberingRuleId;
if (
excelRuleId &&
typeof excelRuleId === "string" &&
excelRuleId.startsWith("rule-")
) {
ruleIds.add(excelRuleId);
}
// 4. componentConfig.action.numberingRuleId (버튼 액션)
const actionRuleId = props?.componentConfig?.action?.numberingRuleId;
if (
actionRuleId &&
typeof actionRuleId === "string" &&
actionRuleId.startsWith("rule-")
) {
ruleIds.add(actionRuleId);
}
}
return ruleIds;
}
/**
* 채번 규칙 복사 및 ID 매핑 반환
* - 원본 회사의 채번 규칙을 대상 회사로 복사
* - 이름이 같은 규칙이 있으면 재사용
* - current_sequence는 0으로 초기화
*/
/**
* 채번 규칙 복제 (numbering_rules 테이블 사용)
* - menu_objid 의존성 제거됨
* - table_name + column_name + company_code 기반
*/
private async copyNumberingRulesForScreen(
ruleIds: Set<string>,
sourceCompanyCode: string,
targetCompanyCode: string,
client: any,
): Promise<Map<string, string>> {
const ruleIdMap = new Map<string, string>();
if (ruleIds.size === 0) {
return ruleIdMap;
}
console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`);
// 1. 원본 채번 규칙 조회 (numbering_rules 테이블)
const ruleIdArray = Array.from(ruleIds);
const sourceRulesResult = await client.query(
`SELECT * FROM numbering_rules WHERE rule_id = ANY($1)`,
[ruleIdArray],
);
if (sourceRulesResult.rows.length === 0) {
console.log(` 📭 복사할 채번 규칙 없음 (해당 rule_id 없음)`);
return ruleIdMap;
}
console.log(` 📋 원본 채번 규칙: ${sourceRulesResult.rows.length}`);
// 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준)
const existingRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode],
);
const existingRulesByName = new Map<string, string>(
existingRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id]),
);
// 3. 각 규칙 복사 또는 재사용
for (const rule of sourceRulesResult.rows) {
const existingId = existingRulesByName.get(rule.rule_name);
if (existingId) {
// 기존 규칙 재사용
ruleIdMap.set(rule.rule_id, existingId);
console.log(
` ♻️ 기존 채번 규칙 재사용: ${rule.rule_name} (${rule.rule_id}${existingId})`,
);
} else {
// 새로 복사 - 새 rule_id 생성
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// numbering_rules 복사 (current_sequence = 0으로 초기화)
await client.query(
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
created_at, updated_at, created_by, last_generated_date,
category_column, category_value_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[
newRuleId,
rule.rule_name,
rule.description,
rule.separator,
rule.reset_period,
0, // current_sequence 초기화
rule.table_name,
rule.column_name,
targetCompanyCode,
new Date(),
new Date(),
rule.created_by,
null, // last_generated_date 초기화
rule.category_column,
rule.category_value_id,
],
);
// numbering_rule_parts 복사
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
[rule.rule_id],
);
for (const part of partsResult.rows) {
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
newRuleId,
part.part_order,
part.part_type,
part.generation_method,
part.auto_config ? JSON.stringify(part.auto_config) : null,
part.manual_config ? JSON.stringify(part.manual_config) : null,
targetCompanyCode,
new Date(),
],
);
}
ruleIdMap.set(rule.rule_id, newRuleId);
console.log(
` 채번 규칙 복사: ${rule.rule_name} (${rule.rule_id}${newRuleId}), 파트 ${partsResult.rows.length}`,
);
}
}
console.log(` ✅ 채번 규칙 복사 완료: 매핑 ${ruleIdMap.size}`);
return ruleIdMap;
}
/**
* properties 내의 numberingRuleId 매핑
* - componentConfig.autoGeneration.options.numberingRuleId (text-input)
* - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal)
* - componentConfig.action.excelNumberingRuleId (엑셀 업로드)
*/
private updateNumberingRuleIdsInProperties(
properties: any,
ruleIdMap: Map<string, string>,
): any {
if (!properties || ruleIdMap.size === 0) return properties;
const updated = JSON.parse(JSON.stringify(properties));
// 1. componentConfig.autoGeneration.options.numberingRuleId (text-input)
if (updated?.componentConfig?.autoGeneration?.options?.numberingRuleId) {
const oldId =
updated.componentConfig.autoGeneration.options.numberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
updated.componentConfig.autoGeneration.options.numberingRuleId = newId;
console.log(
` 🔗 autoGeneration.numberingRuleId: ${oldId}${newId}`,
);
}
}
// 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal)
if (Array.isArray(updated?.componentConfig?.sections)) {
for (const section of updated.componentConfig.sections) {
// 일반 필드
if (Array.isArray(section?.fields)) {
for (const field of section.fields) {
if (field?.numberingRule?.ruleId) {
const oldId = field.numberingRule.ruleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
field.numberingRule.ruleId = newId;
console.log(
` 🔗 field.numberingRule.ruleId: ${oldId}${newId}`,
);
}
}
}
}
// optionalFieldGroups 내부의 필드들
if (Array.isArray(section?.optionalFieldGroups)) {
for (const optGroup of section.optionalFieldGroups) {
if (Array.isArray(optGroup?.fields)) {
for (const field of optGroup.fields) {
if (field?.numberingRule?.ruleId) {
const oldId = field.numberingRule.ruleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
field.numberingRule.ruleId = newId;
console.log(
` 🔗 optField.numberingRule.ruleId: ${oldId}${newId}`,
);
}
}
}
}
}
}
}
}
// 3. componentConfig.action.excelNumberingRuleId
if (updated?.componentConfig?.action?.excelNumberingRuleId) {
const oldId = updated.componentConfig.action.excelNumberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
updated.componentConfig.action.excelNumberingRuleId = newId;
console.log(` 🔗 excelNumberingRuleId: ${oldId}${newId}`);
}
}
// 4. componentConfig.action.numberingRuleId (버튼 액션)
if (updated?.componentConfig?.action?.numberingRuleId) {
const oldId = updated.componentConfig.action.numberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
updated.componentConfig.action.numberingRuleId = newId;
console.log(` 🔗 action.numberingRuleId: ${oldId}${newId}`);
}
}
return updated;
}
/**
* properties 내의 탭 컴포넌트 screenId 매핑
* - componentConfig.tabs[].screenId (tabs-widget)
*/
private updateTabScreenIdsInProperties(
properties: any,
screenIdMap: Map<number, number>,
): any {
if (!properties || screenIdMap.size === 0) return properties;
const updated = JSON.parse(JSON.stringify(properties));
// componentConfig.tabs[].screenId (tabs-widget)
if (Array.isArray(updated?.componentConfig?.tabs)) {
for (const tab of updated.componentConfig.tabs) {
if (tab?.screenId) {
const oldId = Number(tab.screenId);
const newId = screenIdMap.get(oldId);
if (newId) {
tab.screenId = newId;
console.log(` 🔗 tab.screenId: ${oldId}${newId}`);
}
}
}
}
return updated;
}
/**
* 그룹 복제 완료 후 모든 컴포넌트의 화면 참조 일괄 업데이트
* - tabs 컴포넌트의 screenId
* - conditional-container의 screenId
* - 버튼/액션의 modalScreenId
* - 버튼/액션의 targetScreenId (화면 이동, 모달 열기 등)
* @param targetScreenIds 복제된 대상 화면 ID 목록
* @param screenIdMap 원본 화면 ID -> 새 화면 ID 매핑
*/
async updateTabScreenReferences(
targetScreenIds: number[],
screenIdMap: { [key: number]: number },
): Promise<{ updated: number; details: string[] }> {
const result = { updated: 0, details: [] as string[] };
if (targetScreenIds.length === 0 || Object.keys(screenIdMap).length === 0) {
console.log(
`⚠️ updateTabScreenReferences 스킵: targetScreenIds=${targetScreenIds.length}, screenIdMap keys=${Object.keys(screenIdMap).length}`,
);
return result;
}
console.log(`🔄 updateTabScreenReferences 시작:`);
console.log(` - targetScreenIds: ${targetScreenIds.length}`);
console.log(` - screenIdMap: ${JSON.stringify(screenIdMap)}`);
const screenMap = new Map<number, number>(
Object.entries(screenIdMap).map(([k, v]) => [Number(k), v]),
);
await transaction(async (client) => {
// 대상 화면들의 모든 레이아웃 조회 (screenId, modalScreenId, targetScreenId 참조가 있는 것)
const placeholders = targetScreenIds
.map((_, i) => `$${i + 1}`)
.join(", ");
const layoutsResult = await client.query(
`SELECT layout_id, screen_id, properties
FROM screen_layouts
WHERE screen_id IN (${placeholders})
AND (
properties::text LIKE '%"screenId"%'
OR properties::text LIKE '%"modalScreenId"%'
OR properties::text LIKE '%"targetScreenId"%'
)`,
targetScreenIds,
);
console.log(
`🔍 참조 업데이트 대상 레이아웃: ${layoutsResult.rows.length}`,
);
for (const layout of layoutsResult.rows) {
let properties = layout.properties;
if (typeof properties === "string") {
try {
properties = JSON.parse(properties);
} catch (e) {
continue;
}
}
let hasChanges = false;
// 재귀적으로 모든 screenId/modalScreenId 참조 업데이트
const updateReferences = async (
obj: any,
path: string = "",
): Promise<void> => {
if (!obj || typeof obj !== "object") return;
for (const key of Object.keys(obj)) {
const value = obj[key];
const currentPath = path ? `${path}.${key}` : key;
// screenId 업데이트
if (key === "screenId" && typeof value === "number") {
const newId = screenMap.get(value);
if (newId) {
obj[key] = newId;
hasChanges = true;
result.details.push(
`layout_id=${layout.layout_id}: ${currentPath} ${value}${newId}`,
);
console.log(
`🔗 screenId 매핑: ${value}${newId} (${currentPath})`,
);
// screenName도 함께 업데이트 (있는 경우)
if (obj.screenName !== undefined) {
const newScreenResult = await client.query(
`SELECT screen_name FROM screen_definitions WHERE screen_id = $1`,
[newId],
);
if (newScreenResult.rows.length > 0) {
obj.screenName = newScreenResult.rows[0].screen_name;
}
}
} else {
console.log(
`⚠️ screenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`,
);
}
}
// modalScreenId 업데이트
if (key === "modalScreenId" && typeof value === "number") {
const newId = screenMap.get(value);
if (newId) {
obj[key] = newId;
hasChanges = true;
result.details.push(
`layout_id=${layout.layout_id}: ${currentPath} ${value}${newId}`,
);
console.log(
`🔗 modalScreenId 매핑: ${value}${newId} (${currentPath})`,
);
} else {
console.log(
`⚠️ modalScreenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`,
);
}
}
// targetScreenId 업데이트 (버튼 액션에서 사용, 문자열 또는 숫자)
if (key === "targetScreenId") {
const oldId =
typeof value === "string" ? parseInt(value, 10) : value;
if (!isNaN(oldId)) {
const newId = screenMap.get(oldId);
if (newId) {
// 원래 타입 유지 (문자열이면 문자열, 숫자면 숫자)
obj[key] =
typeof value === "string" ? newId.toString() : newId;
hasChanges = true;
result.details.push(
`layout_id=${layout.layout_id}: ${currentPath} ${oldId}${newId}`,
);
console.log(
`🔗 targetScreenId 매핑: ${oldId}${newId} (${currentPath})`,
);
} else {
console.log(
`⚠️ targetScreenId ${oldId} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`,
);
}
}
}
// 배열 처리
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
await updateReferences(value[i], `${currentPath}[${i}]`);
}
}
// 객체 재귀
else if (typeof value === "object" && value !== null) {
await updateReferences(value, currentPath);
}
}
};
await updateReferences(properties);
if (hasChanges) {
await client.query(
`UPDATE screen_layouts SET properties = $1 WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id],
);
result.updated++;
}
}
console.log(
`✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
);
});
return result;
}
/**
* 탭 컴포넌트의 screenId를 대상 회사에서 같은 이름의 화면으로 자동 매핑
* @param properties 레이아웃 properties
* @param targetCompanyCode 대상 회사 코드
* @param client PostgreSQL 클라이언트
* @returns 업데이트된 properties
*/
private async autoMapTabScreenIds(
properties: any,
targetCompanyCode: string,
client: any,
): Promise<any> {
if (!Array.isArray(properties?.componentConfig?.tabs)) {
return properties;
}
const tabs = properties.componentConfig.tabs;
let hasChanges = false;
for (const tab of tabs) {
if (!tab?.screenId) continue;
const oldScreenId = Number(tab.screenId);
const oldScreenName = tab.screenName;
// 1. 원본 화면 이름 조회 (screenName이 없는 경우)
let screenNameToFind = oldScreenName;
if (!screenNameToFind) {
const sourceResult = await client.query(
`SELECT screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[oldScreenId],
);
if (sourceResult.rows.length > 0) {
screenNameToFind = sourceResult.rows[0].screen_name;
}
}
if (!screenNameToFind) continue;
// 2. 대상 회사에서 유사한 이름의 화면 찾기
// 원본 화면 이름에서 회사 접두어를 제거하고 핵심 이름으로 검색
// 예: "탑씰 품목 카테고리설정" → "카테고리설정"으로 검색
const nameParts = screenNameToFind.split(" ");
const coreNamePart =
nameParts.length > 1 ? nameParts.slice(-1)[0] : screenNameToFind;
const targetResult = await client.query(
`SELECT screen_id, screen_name
FROM screen_definitions
WHERE company_code = $1
AND deleted_date IS NULL
AND is_active = 'Y'
AND screen_name LIKE $2
ORDER BY screen_id DESC
LIMIT 1`,
[targetCompanyCode, `%${coreNamePart}`],
);
if (targetResult.rows.length > 0) {
const newScreen = targetResult.rows[0];
tab.screenId = newScreen.screen_id;
tab.screenName = newScreen.screen_name;
hasChanges = true;
console.log(
`🔗 탭 screenId 자동 매핑: ${oldScreenId} (${oldScreenName}) → ${newScreen.screen_id} (${newScreen.screen_name})`,
);
}
}
return properties;
}
/**
* 화면 레이아웃에서 사용하는 flowId 수집
*/
private collectFlowIdsFromLayouts(layouts: any[]): Set<number> {
const flowIds = new Set<number>();
for (const layout of layouts) {
const props = layout.properties;
if (!props) continue;
// webTypeConfig.dataflowConfig.flowConfig.flowId
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
if (flowId && !isNaN(parseInt(flowId))) {
flowIds.add(parseInt(flowId));
}
// webTypeConfig.dataflowConfig.selectedDiagramId
const diagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
if (diagramId && !isNaN(parseInt(diagramId))) {
flowIds.add(parseInt(diagramId));
}
// webTypeConfig.dataflowConfig.flowControls[].flowId
const flowControls = props?.webTypeConfig?.dataflowConfig?.flowControls;
if (Array.isArray(flowControls)) {
for (const control of flowControls) {
if (control?.flowId && !isNaN(parseInt(control.flowId))) {
flowIds.add(parseInt(control.flowId));
}
}
}
// componentConfig.action.excelAfterUploadFlows[].flowId
const excelFlows = props?.componentConfig?.action?.excelAfterUploadFlows;
if (Array.isArray(excelFlows)) {
for (const flow of excelFlows) {
if (flow?.flowId && !isNaN(parseInt(flow.flowId))) {
flowIds.add(parseInt(flow.flowId));
}
}
}
}
return flowIds;
}
/**
* V2 레이아웃에서 flowId 수집 (screen_layouts_v2용)
* - overrides.flowId (flow-widget)
* - overrides.webTypeConfig.dataflowConfig.flowConfig.flowId (버튼)
* - overrides.webTypeConfig.dataflowConfig.flowControls[].flowId
* - overrides.action.excelAfterUploadFlows[].flowId
*/
private collectFlowIdsFromLayoutData(layoutData: any): Set<number> {
const flowIds = new Set<number>();
if (!layoutData?.components) return flowIds;
for (const comp of layoutData.components) {
const overrides = comp.overrides || {};
// 1. overrides.flowId (flow-widget 등)
if (overrides.flowId && !isNaN(parseInt(overrides.flowId))) {
flowIds.add(parseInt(overrides.flowId));
}
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId (버튼)
const flowConfigId = overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
if (flowConfigId && !isNaN(parseInt(flowConfigId))) {
flowIds.add(parseInt(flowConfigId));
}
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
const diagramId = overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
if (diagramId && !isNaN(parseInt(diagramId))) {
flowIds.add(parseInt(diagramId));
}
// 4. webTypeConfig.dataflowConfig.flowControls[].flowId
const flowControls = overrides?.webTypeConfig?.dataflowConfig?.flowControls;
if (Array.isArray(flowControls)) {
for (const control of flowControls) {
if (control?.flowId && !isNaN(parseInt(control.flowId))) {
flowIds.add(parseInt(control.flowId));
}
}
}
// 5. action.excelAfterUploadFlows[].flowId
const excelFlows = overrides?.action?.excelAfterUploadFlows;
if (Array.isArray(excelFlows)) {
for (const flow of excelFlows) {
if (flow?.flowId && !isNaN(parseInt(flow.flowId))) {
flowIds.add(parseInt(flow.flowId));
}
}
}
}
return flowIds;
}
/**
* V2 레이아웃에서 numberingRuleId 수집 (screen_layouts_v2용)
* - overrides.autoGeneration.options.numberingRuleId
* - overrides.sections[].fields[].numberingRule.ruleId
* - overrides.action.excelNumberingRuleId
* - overrides.action.numberingRuleId
*/
private collectNumberingRuleIdsFromLayoutData(layoutData: any): Set<string> {
const ruleIds = new Set<string>();
if (!layoutData?.components) return ruleIds;
for (const comp of layoutData.components) {
const overrides = comp.overrides || {};
// 1. autoGeneration.options.numberingRuleId
const autoGenRuleId = overrides?.autoGeneration?.options?.numberingRuleId;
if (autoGenRuleId && typeof autoGenRuleId === "string" && autoGenRuleId.startsWith("rule-")) {
ruleIds.add(autoGenRuleId);
}
// 2. sections[].fields[].numberingRule.ruleId
const sections = overrides?.sections;
if (Array.isArray(sections)) {
for (const section of sections) {
const fields = section?.fields;
if (Array.isArray(fields)) {
for (const field of fields) {
const ruleId = field?.numberingRule?.ruleId;
if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) {
ruleIds.add(ruleId);
}
}
}
// optionalFieldGroups 내부
const optGroups = section?.optionalFieldGroups;
if (Array.isArray(optGroups)) {
for (const optGroup of optGroups) {
const optFields = optGroup?.fields;
if (Array.isArray(optFields)) {
for (const field of optFields) {
const ruleId = field?.numberingRule?.ruleId;
if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) {
ruleIds.add(ruleId);
}
}
}
}
}
}
}
// 3. action.excelNumberingRuleId
const excelRuleId = overrides?.action?.excelNumberingRuleId;
if (excelRuleId && typeof excelRuleId === "string" && excelRuleId.startsWith("rule-")) {
ruleIds.add(excelRuleId);
}
// 4. action.numberingRuleId
const actionRuleId = overrides?.action?.numberingRuleId;
if (actionRuleId && typeof actionRuleId === "string" && actionRuleId.startsWith("rule-")) {
ruleIds.add(actionRuleId);
}
}
return ruleIds;
}
/**
* V2 레이아웃 데이터의 참조 ID들을 업데이트
* - componentId, flowId, numberingRuleId, screenId 매핑 적용
*/
private updateReferencesInLayoutData(
layoutData: any,
mappings: {
componentIdMap: Map<string, string>;
flowIdMap?: Map<number, number>;
ruleIdMap?: Map<string, string>;
screenIdMap?: Map<number, number>;
},
): any {
if (!layoutData?.components) return layoutData;
const updatedComponents = layoutData.components.map((comp: any) => {
// 1. componentId 매핑
const newId = mappings.componentIdMap.get(comp.id) || comp.id;
// 2. overrides 복사 및 참조 업데이트
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
// flowId 매핑
if (mappings.flowIdMap && mappings.flowIdMap.size > 0) {
overrides = this.updateFlowIdsInOverrides(overrides, mappings.flowIdMap);
}
// numberingRuleId 매핑
if (mappings.ruleIdMap && mappings.ruleIdMap.size > 0) {
overrides = this.updateNumberingRuleIdsInOverrides(overrides, mappings.ruleIdMap);
}
// screenId 매핑 (탭, 버튼 등)
if (mappings.screenIdMap && mappings.screenIdMap.size > 0) {
overrides = this.updateScreenIdsInOverrides(overrides, mappings.screenIdMap);
}
return {
...comp,
id: newId,
overrides,
};
});
return {
...layoutData,
components: updatedComponents,
updatedAt: new Date().toISOString(),
};
}
/**
* V2 overrides 내의 flowId 업데이트
*/
private updateFlowIdsInOverrides(
overrides: any,
flowIdMap: Map<number, number>,
): any {
if (!overrides || flowIdMap.size === 0) return overrides;
// 1. overrides.flowId (flow-widget)
if (overrides.flowId) {
const oldId = parseInt(overrides.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
overrides.flowId = newId;
console.log(` 🔗 flowId: ${oldId}${newId}`);
}
}
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId
if (overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) {
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.flowConfig.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
overrides.webTypeConfig.dataflowConfig.flowConfig.flowId = newId;
console.log(` 🔗 flowConfig.flowId: ${oldId}${newId}`);
}
}
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
if (overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId) {
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.selectedDiagramId);
const newId = flowIdMap.get(oldId);
if (newId) {
overrides.webTypeConfig.dataflowConfig.selectedDiagramId = newId;
console.log(` 🔗 selectedDiagramId: ${oldId}${newId}`);
}
}
// 4. webTypeConfig.dataflowConfig.flowControls[]
if (Array.isArray(overrides?.webTypeConfig?.dataflowConfig?.flowControls)) {
for (const control of overrides.webTypeConfig.dataflowConfig.flowControls) {
if (control?.flowId) {
const oldId = parseInt(control.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
control.flowId = newId;
console.log(` 🔗 flowControls.flowId: ${oldId}${newId}`);
}
}
}
}
// 5. action.excelAfterUploadFlows[]
if (Array.isArray(overrides?.action?.excelAfterUploadFlows)) {
for (const flow of overrides.action.excelAfterUploadFlows) {
if (flow?.flowId) {
const oldId = parseInt(flow.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
flow.flowId = newId;
console.log(` 🔗 excelAfterUploadFlows.flowId: ${oldId}${newId}`);
}
}
}
}
return overrides;
}
/**
* V2 overrides 내의 numberingRuleId 업데이트
*/
private updateNumberingRuleIdsInOverrides(
overrides: any,
ruleIdMap: Map<string, string>,
): any {
if (!overrides || ruleIdMap.size === 0) return overrides;
// 1. autoGeneration.options.numberingRuleId
if (overrides?.autoGeneration?.options?.numberingRuleId) {
const oldId = overrides.autoGeneration.options.numberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
overrides.autoGeneration.options.numberingRuleId = newId;
console.log(` 🔗 autoGeneration.numberingRuleId: ${oldId}${newId}`);
}
}
// 2. sections[].fields[].numberingRule.ruleId
if (Array.isArray(overrides?.sections)) {
for (const section of overrides.sections) {
if (Array.isArray(section?.fields)) {
for (const field of section.fields) {
if (field?.numberingRule?.ruleId) {
const oldId = field.numberingRule.ruleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
field.numberingRule.ruleId = newId;
console.log(` 🔗 field.numberingRule.ruleId: ${oldId}${newId}`);
}
}
}
}
if (Array.isArray(section?.optionalFieldGroups)) {
for (const optGroup of section.optionalFieldGroups) {
if (Array.isArray(optGroup?.fields)) {
for (const field of optGroup.fields) {
if (field?.numberingRule?.ruleId) {
const oldId = field.numberingRule.ruleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
field.numberingRule.ruleId = newId;
console.log(` 🔗 optField.numberingRule.ruleId: ${oldId}${newId}`);
}
}
}
}
}
}
}
}
// 3. action.excelNumberingRuleId
if (overrides?.action?.excelNumberingRuleId) {
const oldId = overrides.action.excelNumberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
overrides.action.excelNumberingRuleId = newId;
console.log(` 🔗 excelNumberingRuleId: ${oldId}${newId}`);
}
}
// 4. action.numberingRuleId
if (overrides?.action?.numberingRuleId) {
const oldId = overrides.action.numberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
overrides.action.numberingRuleId = newId;
console.log(` 🔗 action.numberingRuleId: ${oldId}${newId}`);
}
}
return overrides;
}
/**
* V2 overrides 내의 screenId 업데이트 (탭, 버튼 등)
*/
private updateScreenIdsInOverrides(
overrides: any,
screenIdMap: Map<number, number>,
): any {
if (!overrides || screenIdMap.size === 0) return overrides;
// 1. tabs[].screenId (탭 위젯)
if (Array.isArray(overrides?.tabs)) {
for (const tab of overrides.tabs) {
if (tab?.screenId) {
const oldId = parseInt(tab.screenId);
const newId = screenIdMap.get(oldId);
if (newId) {
tab.screenId = newId;
console.log(` 🔗 tab.screenId: ${oldId}${newId}`);
}
}
}
}
// 2. action.targetScreenId (버튼)
if (overrides?.action?.targetScreenId) {
const oldId = parseInt(overrides.action.targetScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
overrides.action.targetScreenId = newId;
console.log(` 🔗 action.targetScreenId: ${oldId}${newId}`);
}
}
// 3. action.modalScreenId
if (overrides?.action?.modalScreenId) {
const oldId = parseInt(overrides.action.modalScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
overrides.action.modalScreenId = newId;
console.log(` 🔗 action.modalScreenId: ${oldId}${newId}`);
}
}
return overrides;
}
/**
* 노드 플로우 복사 및 ID 매핑 반환
* - 원본 회사의 플로우를 대상 회사로 복사
* - 이름이 같은 플로우가 있으면 재사용
*/
private async copyNodeFlowsForScreen(
flowIds: Set<number>,
sourceCompanyCode: string,
targetCompanyCode: string,
client: any,
): Promise<Map<number, number>> {
const flowIdMap = new Map<number, number>();
if (flowIds.size === 0) {
return flowIdMap;
}
console.log(`🔄 노드 플로우 복사 시작: ${flowIds.size}개 flowId`);
// 1. 원본 플로우 조회 (company_code = "*" 전역 플로우는 복사하지 않음)
const flowIdArray = Array.from(flowIds);
const sourceFlowsResult = await client.query(
`SELECT * FROM node_flows
WHERE flow_id = ANY($1)
AND company_code = $2`,
[flowIdArray, sourceCompanyCode],
);
if (sourceFlowsResult.rows.length === 0) {
console.log(` 📭 복사할 노드 플로우 없음 (원본 회사 소속 플로우 없음)`);
return flowIdMap;
}
console.log(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}`);
// 2. 대상 회사의 기존 플로우 조회 (이름 기준)
const existingFlowsResult = await client.query(
`SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`,
[targetCompanyCode],
);
const existingFlowsByName = new Map<string, number>(
existingFlowsResult.rows.map((f: any) => [f.flow_name, f.flow_id]),
);
// 3. 각 플로우 복사 또는 재사용
for (const flow of sourceFlowsResult.rows) {
const existingId = existingFlowsByName.get(flow.flow_name);
if (existingId) {
// 기존 플로우 재사용
flowIdMap.set(flow.flow_id, existingId);
console.log(
` ♻️ 기존 플로우 재사용: ${flow.flow_name} (${flow.flow_id}${existingId})`,
);
} else {
// 새로 복사
const insertResult = await client.query(
`INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code)
VALUES ($1, $2, $3, $4)
RETURNING flow_id`,
[
flow.flow_name,
flow.flow_description,
JSON.stringify(flow.flow_data),
targetCompanyCode,
],
);
const newFlowId = insertResult.rows[0].flow_id;
flowIdMap.set(flow.flow_id, newFlowId);
console.log(
` 플로우 복사: ${flow.flow_name} (${flow.flow_id}${newFlowId})`,
);
}
}
console.log(` ✅ 노드 플로우 복사 완료: 매핑 ${flowIdMap.size}`);
return flowIdMap;
}
/**
* properties 내의 flowId, selectedDiagramId 등을 매핑
*/
private updateFlowIdsInProperties(
properties: any,
flowIdMap: Map<number, number>,
): any {
if (!properties || flowIdMap.size === 0) return properties;
const updated = JSON.parse(JSON.stringify(properties));
// webTypeConfig.dataflowConfig.flowConfig.flowId
if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) {
const oldId = parseInt(
updated.webTypeConfig.dataflowConfig.flowConfig.flowId,
);
const newId = flowIdMap.get(oldId);
if (newId) {
updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId;
console.log(` 🔗 flowConfig.flowId: ${oldId}${newId}`);
}
}
// webTypeConfig.dataflowConfig.selectedDiagramId
if (updated?.webTypeConfig?.dataflowConfig?.selectedDiagramId) {
const oldId = parseInt(
updated.webTypeConfig.dataflowConfig.selectedDiagramId,
);
const newId = flowIdMap.get(oldId);
if (newId) {
updated.webTypeConfig.dataflowConfig.selectedDiagramId = newId;
console.log(` 🔗 selectedDiagramId: ${oldId}${newId}`);
}
}
// webTypeConfig.dataflowConfig.flowControls[].flowId
if (Array.isArray(updated?.webTypeConfig?.dataflowConfig?.flowControls)) {
for (const control of updated.webTypeConfig.dataflowConfig.flowControls) {
if (control?.flowId) {
const oldId = parseInt(control.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
control.flowId = newId;
console.log(` 🔗 flowControls.flowId: ${oldId}${newId}`);
}
}
}
}
// componentConfig.action.excelAfterUploadFlows[].flowId
if (
Array.isArray(updated?.componentConfig?.action?.excelAfterUploadFlows)
) {
for (const flow of updated.componentConfig.action.excelAfterUploadFlows) {
if (flow?.flowId) {
const oldId = parseInt(flow.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
flow.flowId = String(newId);
console.log(
` 🔗 excelAfterUploadFlows.flowId: ${oldId}${newId}`,
);
}
}
}
}
return updated;
}
/**
* 화면 복사 (화면 정보 + 레이아웃 모두 복사) (✅ Raw Query 전환 완료)
*/
async copyScreen(
sourceScreenId: number,
copyData: CopyScreenRequest,
): Promise<ScreenDefinition> {
// 트랜잭션으로 처리
return await transaction(async (client) => {
// 1. 원본 화면 정보 조회
// 최고 관리자(company_code = "*")는 모든 화면을 조회할 수 있음
let sourceScreenQuery: string;
let sourceScreenParams: any[];
if (copyData.companyCode === "*") {
// 최고 관리자: 모든 회사의 화면 조회 가능
sourceScreenQuery = `
SELECT * FROM screen_definitions
WHERE screen_id = $1
LIMIT 1
`;
sourceScreenParams = [sourceScreenId];
} else {
// 일반 회사: 자신의 회사 화면만 조회 가능
sourceScreenQuery = `
SELECT * FROM screen_definitions
WHERE screen_id = $1 AND company_code = $2
LIMIT 1
`;
sourceScreenParams = [sourceScreenId, copyData.companyCode];
}
const sourceScreens = await client.query<any>(
sourceScreenQuery,
sourceScreenParams,
);
if (sourceScreens.rows.length === 0) {
throw new Error("복사할 화면을 찾을 수 없습니다.");
}
const sourceScreen = sourceScreens.rows[0];
// 2. 대상 회사 코드 결정
// copyData.targetCompanyCode가 있으면 사용 (회사 간 복사)
// 없으면 원본과 같은 회사에 복사
const targetCompanyCode =
copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
const existingScreens = await client.query<any>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
[copyData.screenCode, targetCompanyCode],
);
if (existingScreens.rows.length > 0) {
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 4. 새 화면 생성 (대상 회사에 생성)
// 삭제된 화면(is_active = 'D')을 복사할 경우 활성 상태('Y')로 변경
const newIsActive =
sourceScreen.is_active === "D" ? "Y" : sourceScreen.is_active;
const newScreenResult = await client.query<any>(
`INSERT INTO screen_definitions (
screen_code, screen_name, description, company_code, table_name,
is_active, created_by, created_date, updated_by, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
copyData.screenCode,
copyData.screenName,
copyData.description || sourceScreen.description,
targetCompanyCode, // 대상 회사 코드 사용
sourceScreen.table_name,
newIsActive, // 삭제된 화면은 활성 상태로 복사
copyData.createdBy,
new Date(),
copyData.createdBy,
new Date(),
],
);
const newScreen = newScreenResult.rows[0];
// 4. 원본 화면의 V2 레이아웃 조회
let sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[sourceScreenId, sourceScreen.company_code],
);
// 없으면 공통(*) 레이아웃 조회
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data;
if (!layoutData && sourceScreen.company_code !== "*") {
const fallbackResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
[sourceScreenId],
);
layoutData = fallbackResult.rows[0]?.layout_data;
}
const components = layoutData?.components || [];
// 5. 노드 플로우 복사 (회사가 다른 경우)
let flowIdMap = new Map<number, number>();
if (
components.length > 0 &&
sourceScreen.company_code !== targetCompanyCode
) {
// V2 레이아웃에서 flowId 수집
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
if (flowIds.size > 0) {
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}`);
// 노드 플로우 복사 및 매핑 생성
flowIdMap = await this.copyNodeFlowsForScreen(
flowIds,
sourceScreen.company_code,
targetCompanyCode,
client,
);
}
}
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
let ruleIdMap = new Map<string, string>();
if (
components.length > 0 &&
sourceScreen.company_code !== targetCompanyCode
) {
// V2 레이아웃에서 채번 규칙 ID 수집
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
if (ruleIds.size > 0) {
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}`);
// 채번 규칙 복사 및 매핑 생성
ruleIdMap = await this.copyNumberingRulesForScreen(
ruleIds,
sourceScreen.company_code,
targetCompanyCode,
client,
);
}
}
// 6. V2 레이아웃이 있다면 복사
if (layoutData && components.length > 0) {
try {
// componentId 매핑 생성
const componentIdMap = new Map<string, string>();
for (const comp of components) {
componentIdMap.set(comp.id, generateId());
}
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutData(
layoutData,
{
componentIdMap,
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
},
);
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
} catch (error) {
console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
}
}
// 7. 생성된 화면 정보 반환
return {
screenId: newScreen.screen_id,
screenCode: newScreen.screen_code,
screenName: newScreen.screen_name,
description: newScreen.description || "",
companyCode: newScreen.company_code,
tableName: newScreen.table_name,
isActive: newScreen.is_active,
createdBy: newScreen.created_by || undefined,
createdDate: newScreen.created_date,
updatedBy: newScreen.updated_by || undefined,
updatedDate: newScreen.updated_date,
};
});
}
/**
* 메인 화면 + 연결된 모달 화면들 일괄 복사
*/
async copyScreenWithModals(data: {
sourceScreenId: number;
companyCode: string;
userId: string;
targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사
mainScreen: {
screenName: string;
screenCode: string;
description?: string;
};
modalScreens: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
}): Promise<{
mainScreen: ScreenDefinition;
modalScreens: ScreenDefinition[];
}> {
const targetCompany = data.targetCompanyCode || data.companyCode;
console.log(
`🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`,
);
// 1. 메인 화면 복사
const mainScreen = await this.copyScreen(data.sourceScreenId, {
screenName: data.mainScreen.screenName,
screenCode: data.mainScreen.screenCode,
description: data.mainScreen.description || "",
companyCode: data.companyCode,
createdBy: data.userId,
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
});
console.log(
`✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`,
);
// 2. 모달 화면들 복사 (원본 screenId → 새 screenId 매핑)
const modalScreens: ScreenDefinition[] = [];
const screenIdMapping: Map<number, number> = new Map(); // 원본 ID → 새 ID
for (const modalData of data.modalScreens) {
const copiedModal = await this.copyScreen(modalData.sourceScreenId, {
screenName: modalData.screenName,
screenCode: modalData.screenCode,
description: "",
companyCode: data.companyCode,
createdBy: data.userId,
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
});
modalScreens.push(copiedModal);
screenIdMapping.set(modalData.sourceScreenId, copiedModal.screenId);
console.log(
`✅ 모달 화면 복사 완료: ${modalData.sourceScreenId}${copiedModal.screenId} (${copiedModal.screenCode})`,
);
}
// 3. 메인 화면의 버튼 액션에서 targetScreenId 업데이트
// 모든 복사가 완료되고 커밋된 후에 실행
console.log(
`🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`,
Array.from(screenIdMapping.entries()),
);
const updateCount = await this.updateButtonTargetScreenIds(
mainScreen.screenId,
screenIdMapping,
);
console.log(
`🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`,
);
return {
mainScreen,
modalScreens,
};
}
/**
* 화면 레이아웃에서 버튼의 targetScreenId를 새 screenId로 업데이트
* (독립적인 트랜잭션으로 실행)
*/
private async updateButtonTargetScreenIds(
screenId: number,
screenIdMapping: Map<number, number>,
): Promise<number> {
console.log(
`🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`,
);
// 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[screenId],
);
console.log(`📦 조회된 레이아웃 개수: ${layouts.length}`);
let updateCount = 0;
for (const layout of layouts) {
try {
const properties = layout.properties;
let needsUpdate = false;
// 1. 버튼 컴포넌트의 targetScreenId 업데이트
if (
properties?.componentType === "button" ||
properties?.componentType?.startsWith("button-")
) {
const action = properties?.componentConfig?.action;
// targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData)
const modalActionTypes = [
"popup",
"modal",
"edit",
"openModalWithData",
];
if (
modalActionTypes.includes(action?.type) &&
action?.targetScreenId
) {
const oldScreenId = parseInt(action.targetScreenId);
console.log(
`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`,
);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
const newScreenId = screenIdMapping.get(oldScreenId)!;
console.log(`✅ 매핑 발견: ${oldScreenId}${newScreenId}`);
// properties 업데이트
properties.componentConfig.action.targetScreenId =
newScreenId.toString();
needsUpdate = true;
console.log(
`🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`,
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
// 2. conditional-container 컴포넌트의 sections[].screenId 업데이트
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const oldScreenId = parseInt(section.screenId);
console.log(
`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`,
);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
const newScreenId = screenIdMapping.get(oldScreenId)!;
console.log(`✅ 매핑 발견: ${oldScreenId}${newScreenId}`);
// section.screenId 업데이트
section.screenId = newScreenId;
needsUpdate = true;
console.log(
`🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`,
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
}
// 3. 업데이트가 필요한 경우 DB 저장
if (needsUpdate) {
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id],
);
updateCount++;
console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`);
}
} catch (error) {
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
// 개별 레이아웃 오류는 무시하고 계속 진행
}
}
console.log(
`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`,
);
return updateCount;
}
/**
* 화면-메뉴 할당 복제 (screen_menu_assignments)
*
* @param sourceCompanyCode 원본 회사 코드
* @param targetCompanyCode 대상 회사 코드
* @param screenIdMap 원본 화면 ID -> 새 화면 ID 매핑
* @returns 복제 결과
*/
async copyScreenMenuAssignments(
sourceCompanyCode: string,
targetCompanyCode: string,
screenIdMap: Record<number, number>,
): Promise<{ copiedCount: number; skippedCount: number; details: string[] }> {
const result = {
copiedCount: 0,
skippedCount: 0,
details: [] as string[],
};
return await transaction(async (client) => {
logger.info("🔗 화면-메뉴 할당 복제 시작", {
sourceCompanyCode,
targetCompanyCode,
});
// 1. 원본 회사의 screen_groups (menu_objid 포함) 조회
const sourceGroupsResult = await client.query<{
id: number;
group_name: string;
menu_objid: string | null;
}>(
`SELECT id, group_name, menu_objid
FROM screen_groups
WHERE company_code = $1 AND menu_objid IS NOT NULL`,
[sourceCompanyCode],
);
// 2. 대상 회사의 screen_groups (menu_objid 포함) 조회
const targetGroupsResult = await client.query<{
id: number;
group_name: string;
menu_objid: string | null;
}>(
`SELECT id, group_name, menu_objid
FROM screen_groups
WHERE company_code = $1 AND menu_objid IS NOT NULL`,
[targetCompanyCode],
);
// 3. 그룹 이름 기반으로 menu_objid 매핑 생성
const menuObjidMap = new Map<string, string>(); // 원본 menu_objid -> 새 menu_objid
for (const sourceGroup of sourceGroupsResult.rows) {
if (!sourceGroup.menu_objid) continue;
const matchingTarget = targetGroupsResult.rows.find(
(t) => t.group_name === sourceGroup.group_name,
);
if (matchingTarget?.menu_objid) {
menuObjidMap.set(sourceGroup.menu_objid, matchingTarget.menu_objid);
logger.debug(
`메뉴 매핑: ${sourceGroup.group_name} | ${sourceGroup.menu_objid}${matchingTarget.menu_objid}`,
);
}
}
logger.info(`📋 메뉴 매핑 생성 완료: ${menuObjidMap.size}`);
// 4. 원본 screen_menu_assignments 조회
const assignmentsResult = await client.query<{
screen_id: number;
menu_objid: string;
display_order: number;
is_active: string;
}>(
`SELECT screen_id, menu_objid::text, display_order, is_active
FROM screen_menu_assignments
WHERE company_code = $1`,
[sourceCompanyCode],
);
logger.info(`📌 원본 할당: ${assignmentsResult.rowCount}`);
// 5. 새 할당 생성
for (const assignment of assignmentsResult.rows) {
const newScreenId = screenIdMap[assignment.screen_id];
const newMenuObjid = menuObjidMap.get(assignment.menu_objid);
if (!newScreenId) {
logger.warn(`⚠️ 화면 ID 매핑 없음: ${assignment.screen_id}`);
result.skippedCount++;
result.details.push(`화면 ${assignment.screen_id}: 매핑 없음`);
continue;
}
if (!newMenuObjid) {
logger.warn(`⚠️ 메뉴 objid 매핑 없음: ${assignment.menu_objid}`);
result.skippedCount++;
result.details.push(`메뉴 ${assignment.menu_objid}: 매핑 없음`);
continue;
}
try {
await client.query(
`INSERT INTO screen_menu_assignments
(screen_id, menu_objid, company_code, display_order, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, 'system')
ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`,
[
newScreenId,
newMenuObjid,
targetCompanyCode,
assignment.display_order,
assignment.is_active,
],
);
// 🔧 menu_info.menu_url도 새 화면 ID로 업데이트
const menuInfo = await client.query<{
menu_type: string;
screen_code: string | null;
}>(
`SELECT mi.menu_type, sd.screen_code
FROM menu_info mi
LEFT JOIN screen_definitions sd ON sd.screen_id = $1
WHERE mi.objid = $2`,
[newScreenId, newMenuObjid],
);
if (menuInfo.rows.length > 0) {
// menu_type: "0" = 관리자 메뉴, "1" = 사용자 메뉴
const isAdminMenu = menuInfo.rows[0].menu_type === "0";
const newMenuUrl = isAdminMenu
? `/screens/${newScreenId}?mode=admin`
: `/screens/${newScreenId}`;
const screenCode = menuInfo.rows[0].screen_code;
await client.query(
`UPDATE menu_info
SET menu_url = $1, screen_code = $2
WHERE objid = $3`,
[newMenuUrl, screenCode, newMenuObjid],
);
logger.debug(
`✅ menu_info.menu_url 업데이트: ${newMenuObjid}${newMenuUrl}`,
);
}
result.copiedCount++;
logger.debug(
`✅ 할당 복제: screen ${newScreenId} → menu ${newMenuObjid}`,
);
} catch (error: any) {
logger.error(`❌ 할당 복제 실패: ${error.message}`);
result.skippedCount++;
result.details.push(`할당 실패: ${error.message}`);
}
}
logger.info(
`✅ 화면-메뉴 할당 복제 완료: ${result.copiedCount}개 복제, ${result.skippedCount}개 스킵`,
);
return result;
});
}
/**
* 코드 카테고리 + 코드 복제
*/
async copyCodeCategoryAndCodes(
sourceCompanyCode: string,
targetCompanyCode: string,
menuObjidMap?: Map<string, string>,
): Promise<{
copiedCategories: number;
copiedCodes: number;
details: string[];
}> {
const result = {
copiedCategories: 0,
copiedCodes: 0,
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 코드 카테고리/코드 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 코드 카테고리/코드 복제: ${sourceCompanyCode}${targetCompanyCode}`,
);
// 1. 기존 대상 회사 데이터 삭제
await client.query(`DELETE FROM code_info WHERE company_code = $1`, [
targetCompanyCode,
]);
await client.query(`DELETE FROM code_category WHERE company_code = $1`, [
targetCompanyCode,
]);
// 2. menuObjidMap 생성 (없는 경우)
if (!menuObjidMap || menuObjidMap.size === 0) {
menuObjidMap = new Map();
const groupPairs = await client.query<{
source_objid: string;
target_objid: string;
}>(
`SELECT DISTINCT
sg1.menu_objid::text as source_objid,
sg2.menu_objid::text as target_objid
FROM screen_groups sg1
JOIN screen_groups sg2 ON sg1.group_name = sg2.group_name
WHERE sg1.company_code = $1 AND sg2.company_code = $2
AND sg1.menu_objid IS NOT NULL AND sg2.menu_objid IS NOT NULL`,
[sourceCompanyCode, targetCompanyCode],
);
groupPairs.rows.forEach((p) =>
menuObjidMap!.set(p.source_objid, p.target_objid),
);
}
// 3. 코드 카테고리 복제
const categories = await client.query(
`SELECT * FROM code_category WHERE company_code = $1`,
[sourceCompanyCode],
);
for (const cat of categories.rows) {
const newMenuObjid = cat.menu_objid
? menuObjidMap.get(cat.menu_objid.toString()) || cat.menu_objid
: null;
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, 'system')`,
[
cat.category_code,
cat.category_name,
cat.category_name_eng,
cat.description,
cat.sort_order,
cat.is_active,
targetCompanyCode,
newMenuObjid,
],
);
result.copiedCategories++;
}
// 4. 코드 정보 복제
const codes = await client.query(
`SELECT * FROM code_info WHERE company_code = $1`,
[sourceCompanyCode],
);
for (const code of codes.rows) {
const newMenuObjid = code.menu_objid
? menuObjidMap.get(code.menu_objid.toString()) || code.menu_objid
: null;
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, parent_code_value, depth, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'system')`,
[
code.code_category,
code.code_value,
code.code_name,
code.code_name_eng,
code.description,
code.sort_order,
code.is_active,
targetCompanyCode,
newMenuObjid,
code.parent_code_value,
code.depth,
],
);
result.copiedCodes++;
}
logger.info(
`✅ 코드 카테고리/코드 복제 완료: 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}`,
);
return result;
});
}
/**
* 카테고리 값 복제 (category_values 테이블 사용)
* - menu_objid 의존성 제거됨
* - table_name + column_name + company_code 기반
*/
async copyCategoryMapping(
sourceCompanyCode: string,
targetCompanyCode: string,
): Promise<{
copiedMappings: number;
copiedValues: number;
details: string[];
}> {
const result = {
copiedMappings: 0,
copiedValues: 0,
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 카테고리 값 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 카테고리 값 복제: ${sourceCompanyCode}${targetCompanyCode}`,
);
// 1. 기존 대상 회사 데이터 삭제 (다른 회사로 복제 시에만)
await client.query(
`DELETE FROM category_values WHERE company_code = $1`,
[targetCompanyCode],
);
// 2. category_values 복제
const values = await client.query(
`SELECT * FROM category_values WHERE company_code = $1`,
[sourceCompanyCode],
);
// value_id 매핑 (parent_value_id 참조 업데이트용)
const valueIdMap = new Map<number, number>();
for (const v of values.rows) {
const insertResult = await client.query(
`INSERT INTO category_values
(table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'system')
RETURNING value_id`,
[
v.table_name,
v.column_name,
v.value_code,
v.value_label,
v.value_order,
null, // parent_value_id는 나중에 업데이트
v.depth,
v.path,
v.description,
v.color,
v.icon,
v.is_active,
v.is_default,
targetCompanyCode,
],
);
valueIdMap.set(v.value_id, insertResult.rows[0].value_id);
result.copiedValues++;
}
// 3. parent_value_id 업데이트 (새 value_id로 매핑)
for (const v of values.rows) {
if (v.parent_value_id) {
const newParentId = valueIdMap.get(v.parent_value_id);
const newValueId = valueIdMap.get(v.value_id);
if (newParentId && newValueId) {
await client.query(
`UPDATE category_values SET parent_value_id = $1 WHERE value_id = $2`,
[newParentId, newValueId],
);
}
}
}
logger.info(`✅ 카테고리 값 복제 완료: ${result.copiedValues}`);
return result;
});
}
/**
* 테이블 타입관리 입력타입 설정 복제
* - column_labels 통합 후 모든 컬럼 포함
*/
async copyTableTypeColumns(
sourceCompanyCode: string,
targetCompanyCode: string,
): Promise<{ copiedCount: number; details: string[] }> {
const result = {
copiedCount: 0,
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 테이블 타입 컬럼 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 테이블 타입 컬럼 복제: ${sourceCompanyCode}${targetCompanyCode}`,
);
// 1. 기존 대상 회사 데이터 삭제
await client.query(
`DELETE FROM table_type_columns WHERE company_code = $1`,
[targetCompanyCode],
);
// 2. 복제 (column_labels 통합 후 모든 컬럼 포함)
const columns = await client.query(
`SELECT * FROM table_type_columns WHERE company_code = $1`,
[sourceCompanyCode],
);
for (const col of columns.rows) {
await client.query(
`INSERT INTO table_type_columns
(table_name, column_name, input_type, detail_settings, is_nullable, display_order,
column_label, description, is_visible, code_category, code_value,
reference_table, reference_column, display_column, company_code,
created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW())`,
[
col.table_name,
col.column_name,
col.input_type,
col.detail_settings,
col.is_nullable,
col.display_order,
col.column_label,
col.description,
col.is_visible,
col.code_category,
col.code_value,
col.reference_table,
col.reference_column,
col.display_column,
targetCompanyCode,
],
);
result.copiedCount++;
}
logger.info(`✅ 테이블 타입 컬럼 복제 완료: ${result.copiedCount}`);
return result;
});
}
/**
* 연쇄관계 설정 복제
*/
async copyCascadingRelation(
sourceCompanyCode: string,
targetCompanyCode: string,
): Promise<{ copiedCount: number; details: string[] }> {
const result = {
copiedCount: 0,
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 연쇄관계 설정 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 연쇄관계 설정 복제: ${sourceCompanyCode}${targetCompanyCode}`,
);
// 1. 기존 대상 회사 데이터 삭제
await client.query(
`DELETE FROM cascading_relation WHERE company_code = $1`,
[targetCompanyCode],
);
// 2. 복제
const relations = await client.query(
`SELECT * FROM cascading_relation WHERE company_code = $1`,
[sourceCompanyCode],
);
for (const rel of relations.rows) {
// 새로운 relation_code 생성
const newRelationCode = `${rel.relation_code}_${targetCompanyCode}`;
await client.query(
`INSERT INTO cascading_relation
(relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column,
child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction,
empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, 'system')`,
[
newRelationCode,
rel.relation_name,
rel.description,
rel.parent_table,
rel.parent_value_column,
rel.parent_label_column,
rel.child_table,
rel.child_filter_column,
rel.child_value_column,
rel.child_label_column,
rel.child_order_column,
rel.child_order_direction,
rel.empty_parent_message,
rel.no_options_message,
rel.loading_message,
rel.clear_on_parent_change,
targetCompanyCode,
rel.is_active,
],
);
result.copiedCount++;
}
logger.info(`✅ 연쇄관계 설정 복제 완료: ${result.copiedCount}`);
return result;
});
}
// ========================================
// V2 레이아웃 관리 (1 레코드 방식)
// ========================================
/**
* V2 레이아웃 조회 (1 레코드 방식)
* - screen_layouts_v2 테이블에서 화면당 1개 레코드 조회
* - layout_data JSON에 모든 컴포넌트 포함
*/
async getLayoutV2(
screenId: number,
companyCode: string,
userType?: string,
): Promise<any | null> {
console.log(`=== V2 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
// SUPER_ADMIN 여부 확인
const isSuperAdmin = userType === "SUPER_ADMIN";
// 권한 확인
const screens = await query<{
company_code: string | null;
table_name: string | null;
}>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
// SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
}
let layout: { layout_data: any } | null = null;
// 🆕 기본 레이어(layer_id=1)를 우선 로드
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) {
// 1. 화면 정의의 회사 코드 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, existingScreen.company_code],
);
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, existingScreen.company_code],
);
}
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
} else {
// 일반 사용자: 회사별 우선 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, companyCode],
);
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, companyCode],
);
}
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
}
if (!layout) {
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
return null;
}
console.log(
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
);
return layout.layout_data;
}
/**
* V2 레이아웃 저장 (레이어별 저장)
* - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장
* - layout_data JSON에 해당 레이어의 컴포넌트 포함
*/
async saveLayoutV2(
screenId: number,
layoutData: any,
companyCode: string,
): Promise<void> {
const layerId = layoutData.layerId || 1;
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
// conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달)
const hasConditionConfig = 'conditionConfig' in layoutData;
const conditionConfig = layoutData.conditionConfig || null;
console.log(`=== V2 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
console.log(`조건 설정 포함 여부: ${hasConditionConfig}`);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
const dataToSave = {
version: "2.0",
...pureLayoutData,
};
if (hasConditionConfig) {
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
} else {
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
);
}
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`);
}
/**
* 화면의 모든 레이어 목록 조회
* 레이어가 없으면 기본 레이어를 자동 생성
*/
async getScreenLayers(
screenId: number,
companyCode: string,
): Promise<any[]> {
let layers;
if (companyCode === "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY layer_id`,
[screenId],
);
} else {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
// 회사별 레이어가 없으면 공통(*) 레이어 조회
if (layers.length === 0 && companyCode !== "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id`,
[screenId],
);
}
}
// 레이어가 없으면 기본 레이어 자동 생성
if (layers.length === 0) {
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
[screenId, companyCode, defaultLayout],
);
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
// 다시 조회
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
}
return layers;
}
/**
* 특정 레이어의 레이아웃 조회
*/
async getLayerLayout(
screenId: number,
layerId: number,
companyCode: string,
): Promise<any> {
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
// 회사별 레이어가 없으면 공통(*) 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
[screenId, layerId],
);
}
if (!layout) return null;
return {
...layout.layout_data,
layerId,
layerName: layout.layer_name,
conditionConfig: layout.condition_config,
};
}
/**
* 레이어 삭제
*/
async deleteLayer(
screenId: number,
layerId: number,
companyCode: string,
): Promise<void> {
if (layerId === 1) {
throw new Error("기본 레이어는 삭제할 수 없습니다.");
}
await query(
`DELETE FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
}
/**
* 레이어 조건 설정 업데이트
*/
async updateLayerCondition(
screenId: number,
layerId: number,
companyCode: string,
conditionConfig: any,
layerName?: string,
): Promise<void> {
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
if (layerName) {
setClauses.push(`layer_name = $${params.length + 1}`);
params.push(layerName);
}
await query(
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
params,
);
}
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
/**
* 화면의 조건부 영역(Zone) 목록 조회
*/
async getScreenZones(screenId: number, companyCode: string): Promise<any[]> {
let zones;
if (companyCode === "*") {
// 최고 관리자: 모든 회사 Zone 조회 가능
zones = await query<any>(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`,
[screenId],
);
} else {
// 일반 회사: 자사 Zone만 조회 (company_code = '*' 제외)
zones = await query<any>(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2 ORDER BY zone_id`,
[screenId, companyCode],
);
}
return zones;
}
/**
* 조건부 영역(Zone) 생성
*/
async createZone(
screenId: number,
companyCode: string,
zoneData: {
zone_name?: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<any> {
const result = await queryOne<any>(
`INSERT INTO screen_conditional_zones
(screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
screenId,
companyCode,
zoneData.zone_name || '조건부 영역',
zoneData.x,
zoneData.y,
zoneData.width,
zoneData.height,
zoneData.trigger_component_id || null,
zoneData.trigger_operator || 'eq',
],
);
return result;
}
/**
* 조건부 영역(Zone) 업데이트 (위치/크기/트리거)
*/
async updateZone(
zoneId: number,
companyCode: string,
updates: {
zone_name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<void> {
const setClauses: string[] = ['updated_at = NOW()'];
const params: any[] = [zoneId, companyCode];
let paramIdx = 3;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
setClauses.push(`${key} = $${paramIdx}`);
params.push(value);
paramIdx++;
}
}
await query(
`UPDATE screen_conditional_zones SET ${setClauses.join(', ')}
WHERE zone_id = $1 AND company_code = $2`,
params,
);
}
/**
* 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리
*/
async deleteZone(zoneId: number, companyCode: string): Promise<void> {
// Zone에 소속된 레이어들의 condition_config에서 zone_id 제거
await query(
`UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW()
WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`,
[companyCode, String(zoneId)],
);
await query(
`DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
}
/**
* Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당)
*/
async addLayerToZone(
screenId: number,
companyCode: string,
zoneId: number,
conditionValue: string,
layerName?: string,
): Promise<{ layerId: number }> {
// 다음 layer_id 계산
const maxResult = await queryOne<{ max_id: number }>(
`SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
const newLayerId = (maxResult?.max_id || 1) + 1;
// Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수)
const zone = await queryOne<any>(
`SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
const layoutData = {
version: "2.1",
components: [],
screenResolution: zone
? { width: zone.width, height: zone.height }
: { width: 800, height: 200 },
};
const conditionConfig = {
zone_id: zoneId,
condition_value: conditionValue,
};
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE
SET layout_data = EXCLUDED.layout_data,
layer_name = EXCLUDED.layer_name,
condition_config = EXCLUDED.condition_config,
updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)],
);
return { layerId: newLayerId };
}
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
// ========================================
/**
* POP v1 → v2 마이그레이션 (백엔드)
* - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components
*/
private migratePopV1ToV2(v1Data: any): any {
console.log("POP v1 → v2 마이그레이션 시작");
// 기본 v2 구조
const v2Data: any = {
version: "pop-2.0",
layouts: {
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
},
sections: {},
components: {},
dataFlow: {
sectionConnections: [],
},
settings: {
touchTargetMin: 48,
mode: "normal",
canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 },
},
metadata: v1Data.metadata,
};
// v1 섹션 배열 처리
const sections = v1Data.sections || [];
const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"];
for (const section of sections) {
// 섹션 정의 생성
v2Data.sections[section.id] = {
id: section.id,
label: section.label,
componentIds: (section.components || []).map((c: any) => c.id),
innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 },
style: section.style,
};
// 섹션 위치 복사 (4모드 모두 동일)
const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 };
for (const mode of modeKeys) {
v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos };
}
// 컴포넌트별 처리
for (const comp of section.components || []) {
// 컴포넌트 정의 생성
v2Data.components[comp.id] = {
id: comp.id,
type: comp.type,
label: comp.label,
dataBinding: comp.dataBinding,
style: comp.style,
config: comp.config,
};
// 컴포넌트 위치 복사 (4모드 모두 동일)
const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
for (const mode of modeKeys) {
v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos };
}
}
}
const sectionCount = Object.keys(v2Data.sections).length;
const componentCount = Object.keys(v2Data.components).length;
console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
return v2Data;
}
/**
* POP 레이아웃 조회
* - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회
* - v1 데이터는 자동으로 v2로 마이그레이션하여 반환
*/
async getLayoutPop(
screenId: number,
companyCode: string,
userType?: string,
): Promise<any | null> {
console.log(`=== POP 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
// SUPER_ADMIN 여부 확인
const isSuperAdmin = userType === "SUPER_ADMIN";
// 권한 확인
const screens = await query<{
company_code: string | null;
table_name: string | null;
}>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
// SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다.");
}
let layout: { layout_data: any } | null = null;
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) {
// 1. 화면 정의의 회사 코드로 레이아웃 조회
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = $2`,
[screenId, existingScreen.company_code],
);
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1
ORDER BY updated_at DESC
LIMIT 1`,
[screenId],
);
}
} else {
// 일반 사용자: 회사별 우선, 없으면 공통(*) 조회
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = '*'`,
[screenId],
);
}
}
if (!layout) {
console.log(`POP 레이아웃 없음: screen_id=${screenId}`);
return null;
}
const layoutData = layout.layout_data;
// v1 → v2 자동 마이그레이션
if (layoutData && layoutData.version === "pop-1.0") {
console.log("POP v1 레이아웃 감지, v2로 마이그레이션");
return this.migratePopV1ToV2(layoutData);
}
// v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인)
if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) {
console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션");
return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" });
}
// v2 레이아웃 그대로 반환
const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0;
const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0;
console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
return layoutData;
}
/**
* POP 레이아웃 저장
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
* - v3 형식 지원 (version: "pop-3.0", 섹션 제거)
* - v2/v1 하위 호환
*/
async saveLayoutPop(
screenId: number,
layoutData: any,
companyCode: string,
userId?: string,
): Promise<void> {
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// v5 그리드 레이아웃만 지원
const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`컴포넌트: ${componentCount}`);
// v5 형식 검증
if (layoutData.version && layoutData.version !== "pop-5.0") {
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
}
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
}
// SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게)
const targetCompanyCode = companyCode === "*"
? (existingScreen.company_code || "*")
: companyCode;
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
// v5 그리드 레이아웃으로 저장 (단일 버전)
const dataToSave = {
...layoutData,
version: "pop-5.0",
};
console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`)
// UPSERT (있으면 업데이트, 없으면 삽입)
await query(
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
[screenId, targetCompanyCode, JSON.stringify(dataToSave), userId || null],
);
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`);
}
/**
* POP 레이아웃이 존재하는 화면 ID 목록 조회
* - 옵션 B: POP 레이아웃 존재 여부로 화면 구분
*/
async getScreenIdsWithPopLayout(
companyCode: string,
): Promise<number[]> {
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
console.log(`회사 코드: ${companyCode}`);
let result: { screen_id: number }[];
if (companyCode === "*") {
// 최고 관리자: 모든 POP 레이아웃 조회
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
[],
);
} else {
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop
WHERE company_code = $1 OR company_code = '*'`,
[companyCode],
);
}
const screenIds = result.map((r) => r.screen_id);
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}`);
return screenIds;
}
/**
* POP 레이아웃 삭제
*/
async deleteLayoutPop(
screenId: number,
companyCode: string,
): Promise<boolean> {
console.log(`=== POP 레이아웃 삭제 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다.");
}
const result = await query(
`DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
console.log(`POP 레이아웃 삭제 완료`);
return true;
}
}
// 서비스 인스턴스 export
export const screenManagementService = new ScreenManagementService();