import { query, queryOne, transaction } from "../database/db"; import { logger } from "../utils/logger"; import { Language, LangKey, LangText, LangCategory, CreateLanguageRequest, UpdateLanguageRequest, CreateLangKeyRequest, UpdateLangKeyRequest, SaveLangTextsRequest, GetLangKeysParams, GetUserTextParams, BatchTranslationRequest, GenerateKeyRequest, CreateOverrideKeyRequest, ApiResponse, } from "../types/multilang"; export class MultiLangService { constructor() {} // ===================================================== // 카테고리 관련 메서드 // ===================================================== /** * 카테고리 목록 조회 (트리 구조) */ async getCategories(): Promise { try { logger.info("카테고리 목록 조회 시작"); const categories = await query<{ category_id: number; category_code: string; category_name: string; parent_id: number | null; level: number; key_prefix: string; description: string | null; sort_order: number; is_active: string; }>( `SELECT category_id, category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active FROM multi_lang_category WHERE is_active = 'Y' ORDER BY level ASC, sort_order ASC, category_name ASC` ); // 트리 구조로 변환 const categoryMap = new Map(); const rootCategories: LangCategory[] = []; // 모든 카테고리를 맵에 저장 categories.forEach((cat) => { const category: LangCategory = { categoryId: cat.category_id, categoryCode: cat.category_code, categoryName: cat.category_name, parentId: cat.parent_id, level: cat.level, keyPrefix: cat.key_prefix, description: cat.description || undefined, sortOrder: cat.sort_order, isActive: cat.is_active, children: [], }; categoryMap.set(cat.category_id, category); }); // 부모-자식 관계 설정 categoryMap.forEach((category) => { if (category.parentId && categoryMap.has(category.parentId)) { const parent = categoryMap.get(category.parentId)!; parent.children = parent.children || []; parent.children.push(category); } else if (!category.parentId) { rootCategories.push(category); } }); logger.info(`카테고리 목록 조회 완료: ${categories.length}개`); return rootCategories; } catch (error) { logger.error("카테고리 목록 조회 중 오류 발생:", error); throw new Error( `카테고리 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 카테고리 단일 조회 */ async getCategoryById(categoryId: number): Promise { try { const category = await queryOne<{ category_id: number; category_code: string; category_name: string; parent_id: number | null; level: number; key_prefix: string; description: string | null; sort_order: number; is_active: string; }>( `SELECT category_id, category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active FROM multi_lang_category WHERE category_id = $1`, [categoryId] ); if (!category) { return null; } return { categoryId: category.category_id, categoryCode: category.category_code, categoryName: category.category_name, parentId: category.parent_id, level: category.level, keyPrefix: category.key_prefix, description: category.description || undefined, sortOrder: category.sort_order, isActive: category.is_active, }; } catch (error) { logger.error("카테고리 조회 중 오류 발생:", error); throw new Error( `카테고리 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 카테고리 경로 조회 (부모 포함) */ async getCategoryPath(categoryId: number): Promise { try { const categories = await query<{ category_id: number; category_code: string; category_name: string; parent_id: number | null; level: number; key_prefix: string; description: string | null; sort_order: number; is_active: string; }>( `WITH RECURSIVE category_path AS ( SELECT category_id, category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active FROM multi_lang_category WHERE category_id = $1 UNION ALL SELECT c.category_id, c.category_code, c.category_name, c.parent_id, c.level, c.key_prefix, c.description, c.sort_order, c.is_active FROM multi_lang_category c INNER JOIN category_path cp ON c.category_id = cp.parent_id ) SELECT * FROM category_path ORDER BY level ASC`, [categoryId] ); return categories.map((cat) => ({ categoryId: cat.category_id, categoryCode: cat.category_code, categoryName: cat.category_name, parentId: cat.parent_id, level: cat.level, keyPrefix: cat.key_prefix, description: cat.description || undefined, sortOrder: cat.sort_order, isActive: cat.is_active, })); } catch (error) { logger.error("카테고리 경로 조회 중 오류 발생:", error); throw new Error( `카테고리 경로 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 키 자동 생성 */ async generateKey(params: GenerateKeyRequest): Promise { try { logger.info("키 자동 생성 시작", { params }); // 카테고리 경로 조회 const categoryPath = await this.getCategoryPath(params.categoryId); if (categoryPath.length === 0) { throw new Error("존재하지 않는 카테고리입니다"); } // lang_key 자동 생성 (prefix.meaning 형식) const prefixes = categoryPath.map((c) => c.keyPrefix); const langKey = [...prefixes, params.keyMeaning].join("."); // 중복 체크 const existingKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE company_code = $1 AND lang_key = $2`, [params.companyCode, langKey] ); if (existingKey) { throw new Error(`이미 존재하는 키입니다: ${langKey}`); } // 트랜잭션으로 키와 텍스트 생성 let keyId: number = 0; await transaction(async (client) => { // 키 생성 const keyResult = await client.query( `INSERT INTO multi_lang_key_master (company_code, lang_key, category_id, key_meaning, usage_note, description, is_active, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $7) RETURNING key_id`, [ params.companyCode, langKey, params.categoryId, params.keyMeaning, params.usageNote || null, params.usageNote || null, params.createdBy || "system", ] ); keyId = keyResult.rows[0].key_id; // 텍스트 생성 for (const text of params.texts) { await client.query( `INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_by, updated_by) VALUES ($1, $2, $3, 'Y', $4, $4)`, [keyId, text.langCode, text.langText, params.createdBy || "system"] ); } }); logger.info("키 자동 생성 완료", { keyId, langKey }); return keyId; } catch (error) { logger.error("키 자동 생성 중 오류 발생:", error); throw new Error( `키 자동 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 회사별 오버라이드 키 생성 */ async createOverrideKey(params: CreateOverrideKeyRequest): Promise { try { logger.info("오버라이드 키 생성 시작", { params }); // 원본 키 조회 const baseKey = await queryOne<{ key_id: number; company_code: string; lang_key: string; category_id: number | null; key_meaning: string | null; }>( `SELECT key_id, company_code, lang_key, category_id, key_meaning FROM multi_lang_key_master WHERE key_id = $1`, [params.baseKeyId] ); if (!baseKey) { throw new Error("원본 키를 찾을 수 없습니다"); } // 공통 키(*)만 오버라이드 가능 if (baseKey.company_code !== "*") { throw new Error("공통 키(*)만 오버라이드 할 수 있습니다"); } // 이미 오버라이드 키가 있는지 확인 const existingOverride = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE company_code = $1 AND lang_key = $2`, [params.companyCode, baseKey.lang_key] ); if (existingOverride) { throw new Error("이미 해당 회사의 오버라이드 키가 존재합니다"); } let keyId: number = 0; await transaction(async (client) => { // 오버라이드 키 생성 const keyResult = await client.query( `INSERT INTO multi_lang_key_master (company_code, lang_key, category_id, key_meaning, base_key_id, is_active, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, 'Y', $6, $6) RETURNING key_id`, [ params.companyCode, baseKey.lang_key, baseKey.category_id, baseKey.key_meaning, params.baseKeyId, params.createdBy || "system", ] ); keyId = keyResult.rows[0].key_id; // 텍스트 생성 for (const text of params.texts) { await client.query( `INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_by, updated_by) VALUES ($1, $2, $3, 'Y', $4, $4)`, [keyId, text.langCode, text.langText, params.createdBy || "system"] ); } }); logger.info("오버라이드 키 생성 완료", { keyId, langKey: baseKey.lang_key }); return keyId; } catch (error) { logger.error("오버라이드 키 생성 중 오류 발생:", error); throw new Error( `오버라이드 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 회사의 오버라이드 키 목록 조회 */ async getOverrideKeys(companyCode: string): Promise { try { logger.info("오버라이드 키 목록 조회 시작", { companyCode }); const keys = await query<{ key_id: number; company_code: string; lang_key: string; category_id: number | null; key_meaning: string | null; usage_note: string | null; base_key_id: number | null; is_active: string; created_date: Date | null; }>( `SELECT key_id, company_code, lang_key, category_id, key_meaning, usage_note, base_key_id, is_active, created_date FROM multi_lang_key_master WHERE company_code = $1 AND base_key_id IS NOT NULL ORDER BY lang_key ASC`, [companyCode] ); return keys.map((k) => ({ keyId: k.key_id, companyCode: k.company_code, langKey: k.lang_key, categoryId: k.category_id ?? undefined, keyMeaning: k.key_meaning ?? undefined, usageNote: k.usage_note ?? undefined, baseKeyId: k.base_key_id ?? undefined, isActive: k.is_active, createdDate: k.created_date ?? undefined, })); } catch (error) { logger.error("오버라이드 키 목록 조회 중 오류 발생:", error); throw new Error( `오버라이드 키 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 키 존재 여부 및 미리보기 확인 */ async previewGeneratedKey(categoryId: number, keyMeaning: string, companyCode: string): Promise<{ langKey: string; exists: boolean; isOverride: boolean; baseKeyId?: number; }> { try { // 카테고리 경로 조회 const categoryPath = await this.getCategoryPath(categoryId); if (categoryPath.length === 0) { throw new Error("존재하지 않는 카테고리입니다"); } // lang_key 생성 const prefixes = categoryPath.map((c) => c.keyPrefix); const langKey = [...prefixes, keyMeaning].join("."); // 공통 키 확인 const commonKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE company_code = '*' AND lang_key = $1`, [langKey] ); // 회사별 키 확인 const companyKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE company_code = $1 AND lang_key = $2`, [companyCode, langKey] ); return { langKey, exists: !!companyKey, isOverride: !!commonKey && !companyKey, baseKeyId: commonKey?.key_id, }; } catch (error) { logger.error("키 미리보기 중 오류 발생:", error); throw new Error( `키 미리보기 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 언어 목록 조회 */ async getLanguages(): Promise { try { logger.info("언어 목록 조회 시작"); const languages = await query<{ lang_code: string; lang_name: string; lang_native: string | null; is_active: string | null; sort_order: number | null; created_date: Date | null; created_by: string | null; updated_date: Date | null; updated_by: string | null; }>( `SELECT lang_code, lang_name, lang_native, is_active, sort_order, created_date, created_by, updated_date, updated_by FROM language_master ORDER BY sort_order ASC, lang_code ASC` ); const mappedLanguages: Language[] = languages.map((lang) => ({ langCode: lang.lang_code, langName: lang.lang_name, langNative: lang.lang_native || "", isActive: lang.is_active || "N", sortOrder: lang.sort_order ?? undefined, createdDate: lang.created_date || undefined, createdBy: lang.created_by || undefined, updatedDate: lang.updated_date || undefined, updatedBy: lang.updated_by || undefined, })); logger.info(`언어 목록 조회 완료: ${mappedLanguages.length}개`); return mappedLanguages; } catch (error) { logger.error("언어 목록 조회 중 오류 발생:", error); throw new Error( `언어 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 언어 생성 */ async createLanguage(languageData: CreateLanguageRequest): Promise { try { logger.info("언어 생성 시작", { languageData }); // 중복 체크 const existingLanguage = await queryOne<{ lang_code: string }>( `SELECT lang_code FROM language_master WHERE lang_code = $1`, [languageData.langCode] ); if (existingLanguage) { throw new Error( `이미 존재하는 언어 코드입니다: ${languageData.langCode}` ); } // 언어 생성 const createdLanguage = await queryOne<{ lang_code: string; lang_name: string; lang_native: string | null; is_active: string | null; sort_order: number | null; created_date: Date | null; created_by: string | null; updated_date: Date | null; updated_by: string | null; }>( `INSERT INTO language_master (lang_code, lang_name, lang_native, is_active, sort_order, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ languageData.langCode, languageData.langName, languageData.langNative, languageData.isActive || "Y", languageData.sortOrder || 0, languageData.createdBy || "system", languageData.updatedBy || "system", ] ); logger.info("언어 생성 완료", { langCode: createdLanguage!.lang_code }); return { langCode: createdLanguage!.lang_code, langName: createdLanguage!.lang_name, langNative: createdLanguage!.lang_native || "", isActive: createdLanguage!.is_active || "N", sortOrder: createdLanguage!.sort_order ?? undefined, createdDate: createdLanguage!.created_date || undefined, createdBy: createdLanguage!.created_by || undefined, updatedDate: createdLanguage!.updated_date || undefined, updatedBy: createdLanguage!.updated_by || undefined, }; } catch (error) { logger.error("언어 생성 중 오류 발생:", error); throw new Error( `언어 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 언어 수정 */ async updateLanguage( langCode: string, languageData: UpdateLanguageRequest ): Promise { try { logger.info("언어 수정 시작", { langCode, languageData }); // 기존 언어 확인 const existingLanguage = await queryOne<{ lang_code: string }>( `SELECT lang_code FROM language_master WHERE lang_code = $1`, [langCode] ); if (!existingLanguage) { throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); } // 동적 UPDATE 쿼리 생성 const updates: string[] = []; const values: any[] = []; let paramIndex = 1; if (languageData.langName) { updates.push(`lang_name = $${paramIndex++}`); values.push(languageData.langName); } if (languageData.langNative) { updates.push(`lang_native = $${paramIndex++}`); values.push(languageData.langNative); } if (languageData.isActive) { updates.push(`is_active = $${paramIndex++}`); values.push(languageData.isActive); } if (languageData.sortOrder !== undefined) { updates.push(`sort_order = $${paramIndex++}`); values.push(languageData.sortOrder); } updates.push(`updated_by = $${paramIndex++}`); values.push(languageData.updatedBy || "system"); values.push(langCode); // WHERE 조건용 // 언어 수정 const updatedLanguage = await queryOne<{ lang_code: string; lang_name: string; lang_native: string | null; is_active: string | null; sort_order: number | null; created_date: Date | null; created_by: string | null; updated_date: Date | null; updated_by: string | null; }>( `UPDATE language_master SET ${updates.join(", ")} WHERE lang_code = $${paramIndex} RETURNING *`, values ); logger.info("언어 수정 완료", { langCode }); return { langCode: updatedLanguage!.lang_code, langName: updatedLanguage!.lang_name, langNative: updatedLanguage!.lang_native || "", isActive: updatedLanguage!.is_active || "N", sortOrder: updatedLanguage!.sort_order ?? undefined, createdDate: updatedLanguage!.created_date || undefined, createdBy: updatedLanguage!.created_by || undefined, updatedDate: updatedLanguage!.updated_date || undefined, updatedBy: updatedLanguage!.updated_by || undefined, }; } catch (error) { logger.error("언어 수정 중 오류 발생:", error); throw new Error( `언어 수정 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 언어 상태 토글 */ async toggleLanguage(langCode: string): Promise { try { logger.info("언어 상태 토글 시작", { langCode }); // 현재 언어 조회 const currentLanguage = await queryOne<{ is_active: string | null }>( `SELECT is_active FROM language_master WHERE lang_code = $1`, [langCode] ); if (!currentLanguage) { throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); } const newStatus = currentLanguage.is_active === "Y" ? "N" : "Y"; // 상태 업데이트 await query( `UPDATE language_master SET is_active = $1, updated_by = $2 WHERE lang_code = $3`, [newStatus, "system", langCode] ); const result = newStatus === "Y" ? "활성화" : "비활성화"; logger.info("언어 상태 토글 완료", { langCode, result }); return result; } catch (error) { logger.error("언어 상태 토글 중 오류 발생:", error); throw new Error( `언어 상태 토글 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 다국어 키 목록 조회 */ async getLangKeys(params: GetLangKeysParams): Promise { try { logger.info("다국어 키 목록 조회 시작", { params }); const whereConditions: string[] = []; const values: any[] = []; let paramIndex = 1; // 회사 코드 필터 if (params.companyCode) { whereConditions.push(`company_code = $${paramIndex++}`); values.push(params.companyCode); } // 메뉴 코드 필터 if (params.menuCode) { whereConditions.push(`usage_note = $${paramIndex++}`); values.push(params.menuCode); } // 카테고리 필터 (하위 카테고리 포함) if (params.categoryId) { whereConditions.push(`category_id IN ( WITH RECURSIVE category_tree AS ( SELECT category_id FROM multi_lang_category WHERE category_id = $${paramIndex} UNION ALL SELECT c.category_id FROM multi_lang_category c INNER JOIN category_tree ct ON c.parent_id = ct.category_id ) SELECT category_id FROM category_tree )`); values.push(params.categoryId); paramIndex++; } // 검색 조건 (OR) if (params.searchText) { whereConditions.push( `(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR usage_note ILIKE $${paramIndex})` ); values.push(`%${params.searchText}%`); paramIndex++; } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; const langKeys = await query<{ key_id: number; company_code: string; usage_note: string | null; lang_key: string; description: string | null; is_active: string | null; category_id: number | null; created_date: Date | null; created_by: string | null; updated_date: Date | null; updated_by: string | null; }>( `SELECT key_id, company_code, usage_note, lang_key, description, is_active, category_id, created_date, created_by, updated_date, updated_by FROM multi_lang_key_master ${whereClause} ORDER BY company_code ASC, usage_note ASC, lang_key ASC`, values ); const mappedKeys: LangKey[] = langKeys.map((key) => ({ keyId: key.key_id, companyCode: key.company_code, menuName: key.usage_note || undefined, langKey: key.lang_key, description: key.description || undefined, isActive: key.is_active || "Y", categoryId: key.category_id || undefined, createdDate: key.created_date || undefined, createdBy: key.created_by || undefined, updatedDate: key.updated_date || undefined, updatedBy: key.updated_by || undefined, })); logger.info(`다국어 키 목록 조회 완료: ${mappedKeys.length}개`); return mappedKeys; } catch (error) { logger.error("다국어 키 목록 조회 중 오류 발생:", error); throw new Error( `다국어 키 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 특정 키의 다국어 텍스트 조회 */ async getLangTexts(keyId: number): Promise { try { logger.info("다국어 텍스트 조회 시작", { keyId }); const langTexts = await query<{ text_id: number; key_id: number; lang_code: string; lang_text: string; is_active: string | null; created_date: Date | null; created_by: string | null; updated_date: Date | null; updated_by: string | null; }>( `SELECT text_id, key_id, lang_code, lang_text, is_active, created_date, created_by, updated_date, updated_by FROM multi_lang_text WHERE key_id = $1 AND is_active = $2 ORDER BY lang_code ASC`, [keyId, "Y"] ); const mappedTexts: LangText[] = langTexts.map((text) => ({ textId: text.text_id, keyId: text.key_id, langCode: text.lang_code, langText: text.lang_text, isActive: text.is_active || "Y", createdDate: text.created_date || undefined, createdBy: text.created_by || undefined, updatedDate: text.updated_date || undefined, updatedBy: text.updated_by || undefined, })); logger.info(`다국어 텍스트 조회 완료: ${mappedTexts.length}개`); return mappedTexts; } catch (error) { logger.error("다국어 텍스트 조회 중 오류 발생:", error); throw new Error( `다국어 텍스트 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 다국어 키 생성 */ async createLangKey(keyData: CreateLangKeyRequest): Promise { try { logger.info("다국어 키 생성 시작", { keyData }); // 중복 체크 const existingKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE company_code = $1 AND lang_key = $2`, [keyData.companyCode, keyData.langKey] ); if (existingKey) { throw new Error( `동일한 회사에 이미 존재하는 언어키입니다: ${keyData.langKey}` ); } // 다국어 키 생성 const createdKey = await queryOne<{ key_id: number }>( `INSERT INTO multi_lang_key_master (company_code, usage_note, lang_key, description, is_active, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING key_id`, [ keyData.companyCode, keyData.menuName || null, keyData.langKey, keyData.description || null, keyData.isActive || "Y", keyData.createdBy || "system", keyData.updatedBy || "system", ] ); logger.info("다국어 키 생성 완료", { keyId: createdKey!.key_id, langKey: keyData.langKey, }); return createdKey!.key_id; } catch (error) { logger.error("다국어 키 생성 중 오류 발생:", error); throw new Error( `다국어 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 다국어 키 수정 */ async updateLangKey( keyId: number, keyData: UpdateLangKeyRequest ): Promise { try { logger.info("다국어 키 수정 시작", { keyId, keyData }); // 기존 키 확인 const existingKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE key_id = $1`, [keyId] ); if (!existingKey) { throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); } // 중복 체크 (자신을 제외하고) if (keyData.companyCode && keyData.langKey) { const duplicateKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE company_code = $1 AND lang_key = $2 AND key_id != $3`, [keyData.companyCode, keyData.langKey, keyId] ); if (duplicateKey) { throw new Error( `동일한 회사에 이미 존재하는 언어키입니다: ${keyData.langKey}` ); } } // 동적 UPDATE 쿼리 생성 const updates: string[] = []; const values: any[] = []; let paramIndex = 1; if (keyData.companyCode) { updates.push(`company_code = $${paramIndex++}`); values.push(keyData.companyCode); } if (keyData.menuName !== undefined) { updates.push(`usage_note = $${paramIndex++}`); values.push(keyData.menuName); } if (keyData.langKey) { updates.push(`lang_key = $${paramIndex++}`); values.push(keyData.langKey); } if (keyData.description !== undefined) { updates.push(`description = $${paramIndex++}`); values.push(keyData.description); } updates.push(`updated_by = $${paramIndex++}`); values.push(keyData.updatedBy || "system"); values.push(keyId); // WHERE 조건용 // 다국어 키 수정 await query( `UPDATE multi_lang_key_master SET ${updates.join(", ")} WHERE key_id = $${paramIndex}`, values ); logger.info("다국어 키 수정 완료", { keyId }); } catch (error) { logger.error("다국어 키 수정 중 오류 발생:", error); throw new Error( `다국어 키 수정 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 다국어 키 삭제 */ async deleteLangKey(keyId: number): Promise { try { logger.info("다국어 키 삭제 시작", { keyId }); // 기존 키 확인 const existingKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE key_id = $1`, [keyId] ); if (!existingKey) { throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); } // 트랜잭션으로 키와 연관된 텍스트 모두 삭제 await transaction(async (client) => { // 관련된 다국어 텍스트 삭제 await client.query(`DELETE FROM multi_lang_text WHERE key_id = $1`, [ keyId, ]); // 다국어 키 삭제 await client.query( `DELETE FROM multi_lang_key_master WHERE key_id = $1`, [keyId] ); }); logger.info("다국어 키 삭제 완료", { keyId }); } catch (error) { logger.error("다국어 키 삭제 중 오류 발생:", error); throw new Error( `다국어 키 삭제 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 다국어 키 상태 토글 */ async toggleLangKey(keyId: number): Promise { try { logger.info("다국어 키 상태 토글 시작", { keyId }); // 현재 키 조회 const currentKey = await queryOne<{ is_active: string | null }>( `SELECT is_active FROM multi_lang_key_master WHERE key_id = $1`, [keyId] ); if (!currentKey) { throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); } const newStatus = currentKey.is_active === "Y" ? "N" : "Y"; // 상태 업데이트 await query( `UPDATE multi_lang_key_master SET is_active = $1, updated_by = $2 WHERE key_id = $3`, [newStatus, "system", keyId] ); const result = newStatus === "Y" ? "활성화" : "비활성화"; logger.info("다국어 키 상태 토글 완료", { keyId, result }); return result; } catch (error) { logger.error("다국어 키 상태 토글 중 오류 발생:", error); throw new Error( `다국어 키 상태 토글 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 다국어 텍스트 저장/수정 */ async saveLangTexts( keyId: number, textData: SaveLangTextsRequest ): Promise { try { logger.info("다국어 텍스트 저장 시작", { keyId, textCount: textData.texts.length, }); // 기존 키 확인 const existingKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE key_id = $1`, [keyId] ); if (!existingKey) { throw new Error(`다국어 키를 찾을 수 없습니다: ${keyId}`); } // 트랜잭션으로 기존 텍스트 삭제 후 새로 생성 await transaction(async (client) => { // 기존 텍스트 삭제 await client.query(`DELETE FROM multi_lang_text WHERE key_id = $1`, [ keyId, ]); // 새로운 텍스트 삽입 if (textData.texts.length > 0) { for (const text of textData.texts) { await client.query( `INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6)`, [ keyId, text.langCode, text.langText, text.isActive || "Y", text.createdBy || "system", text.updatedBy || "system", ] ); } } }); logger.info("다국어 텍스트 저장 완료", { keyId, savedCount: textData.texts.length, }); } catch (error) { logger.error("다국어 텍스트 저장 중 오류 발생:", error); throw new Error( `다국어 텍스트 저장 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 사용자별 다국어 텍스트 조회 */ async getUserText(params: GetUserTextParams): Promise { try { logger.info("사용자별 다국어 텍스트 조회 시작", { params }); const result = await queryOne<{ lang_text: string }>( `SELECT mlt.lang_text FROM multi_lang_text mlt INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id WHERE mlt.lang_code = $1 AND mlt.is_active = $2 AND mlkm.company_code = $3 AND mlkm.usage_note = $4 AND mlkm.lang_key = $5 AND mlkm.is_active = $6`, [ params.userLang, "Y", params.companyCode, params.menuCode, params.langKey, "Y", ] ); if (!result) { logger.warn("사용자별 다국어 텍스트를 찾을 수 없음", { params }); return params.langKey; // 기본값으로 키 반환 } logger.info("사용자별 다국어 텍스트 조회 완료", { params, langText: result.lang_text, }); return result.lang_text; } catch (error) { logger.error("사용자별 다국어 텍스트 조회 중 오류 발생:", error); throw new Error( `사용자별 다국어 텍스트 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 특정 키의 다국어 텍스트 조회 */ async getLangText( companyCode: string, langKey: string, langCode: string ): Promise { try { logger.info("특정 키의 다국어 텍스트 조회 시작", { companyCode, langKey, langCode, }); const result = await queryOne<{ lang_text: string }>( `SELECT mlt.lang_text FROM multi_lang_text mlt INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id WHERE mlt.lang_code = $1 AND mlt.is_active = $2 AND mlkm.company_code = $3 AND mlkm.lang_key = $4 AND mlkm.is_active = $5`, [langCode, "Y", companyCode, langKey, "Y"] ); if (!result) { logger.warn("특정 키의 다국어 텍스트를 찾을 수 없음", { companyCode, langKey, langCode, }); return langKey; // 기본값으로 키 반환 } logger.info("특정 키의 다국어 텍스트 조회 완료", { companyCode, langKey, langCode, langText: result.lang_text, }); return result.lang_text; } catch (error) { logger.error("특정 키의 다국어 텍스트 조회 중 오류 발생:", error); throw new Error( `특정 키의 다국어 텍스트 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 배치 번역 조회 (회사별 우선순위 적용) * 우선순위: 회사별 키 > 공통 키(*) */ async getBatchTranslations( params: BatchTranslationRequest ): Promise> { try { logger.info("배치 번역 조회 시작", { companyCode: params.companyCode, menuCode: params.menuCode, userLang: params.userLang, keyCount: params.langKeys.length, }); if (params.langKeys.length === 0) { return {}; } // 모든 키에 대한 번역 조회 const placeholders = params.langKeys .map((_, i) => `$${i + 4}`) .join(", "); // 회사별 우선순위를 적용하기 위해 정렬 수정 // 회사별 키가 먼저 오도록 DESC 정렬 (company_code가 '*'보다 특정 회사 코드가 알파벳 순으로 앞) // 또는 CASE WHEN을 사용하여 명시적으로 우선순위 설정 const translations = await query<{ lang_text: string; lang_key: string; company_code: string; priority: number; }>( `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code, CASE WHEN mlkm.company_code = $3 THEN 1 ELSE 2 END as priority FROM multi_lang_text mlt INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id WHERE mlt.lang_code = $1 AND mlt.is_active = $2 AND mlkm.lang_key IN (${placeholders}) AND mlkm.company_code IN ($3, '*') AND mlkm.is_active = $2 ORDER BY mlkm.lang_key ASC, priority ASC`, [params.userLang, "Y", params.companyCode, ...params.langKeys] ); const result: Record = {}; // 기본값으로 모든 키 설정 params.langKeys.forEach((key) => { result[key] = key; }); // 우선순위 기반으로 번역 적용 // priority가 낮은 것(회사별)이 먼저 오므로, 먼저 처리된 키는 덮어쓰지 않음 const processedKeys = new Set(); translations.forEach((translation) => { const langKey = translation.lang_key; if (params.langKeys.includes(langKey) && !processedKeys.has(langKey)) { result[langKey] = translation.lang_text; processedKeys.add(langKey); } }); logger.info("배치 번역 조회 완료", { totalKeys: params.langKeys.length, foundTranslations: translations.length, companyOverrides: translations.filter(t => t.company_code !== '*').length, resultKeys: Object.keys(result).length, }); return result; } catch (error) { logger.error("배치 번역 조회 중 오류 발생:", error); throw new Error( `배치 번역 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 언어 삭제 */ async deleteLanguage(langCode: string): Promise { try { logger.info("언어 삭제 시작", { langCode }); // 기존 언어 확인 const existingLanguage = await queryOne<{ lang_code: string }>( `SELECT lang_code FROM language_master WHERE lang_code = $1`, [langCode] ); if (!existingLanguage) { throw new Error(`언어를 찾을 수 없습니다: ${langCode}`); } // 트랜잭션으로 언어와 관련 텍스트 삭제 await transaction(async (client) => { // 해당 언어의 다국어 텍스트 삭제 const deleteResult = await client.query( `DELETE FROM multi_lang_text WHERE lang_code = $1`, [langCode] ); logger.info(`삭제된 다국어 텍스트 수: ${deleteResult.rowCount}`, { langCode, }); // 언어 마스터 삭제 await client.query(`DELETE FROM language_master WHERE lang_code = $1`, [ langCode, ]); }); logger.info("언어 삭제 완료", { langCode }); } catch (error) { logger.error("언어 삭제 중 오류 발생:", error); throw new Error( `언어 삭제 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } // ===================================================== // 회사/메뉴 기반 카테고리 자동 생성 메서드 // ===================================================== /** * 화면(screen) 루트 카테고리 확인 또는 생성 */ async ensureScreenRootCategory(): Promise { try { // 기존 screen 카테고리 확인 const existing = await queryOne<{ category_id: number }>( `SELECT category_id FROM multi_lang_category WHERE category_code = 'screen' AND parent_id IS NULL`, [] ); if (existing) { return existing.category_id; } // 없으면 생성 const result = await queryOne<{ category_id: number }>( `INSERT INTO multi_lang_category (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) VALUES ('screen', '화면', NULL, 1, 'screen', '화면 디자이너에서 자동 생성된 다국어 키', 100, 'Y', NOW()) RETURNING category_id`, [] ); logger.info("화면 루트 카테고리 생성", { categoryId: result?.category_id }); return result!.category_id; } catch (error) { logger.error("화면 루트 카테고리 생성 실패:", error); throw error; } } /** * 회사 카테고리 확인 또는 생성 */ async ensureCompanyCategory(companyCode: string, companyName: string): Promise { try { const screenRootId = await this.ensureScreenRootCategory(); // 기존 회사 카테고리 확인 const existing = await queryOne<{ category_id: number }>( `SELECT category_id FROM multi_lang_category WHERE category_code = $1 AND parent_id = $2`, [companyCode, screenRootId] ); if (existing) { return existing.category_id; } // 회사 카테고리 생성 const displayName = companyCode === "*" ? "공통" : companyName; const keyPrefix = companyCode === "*" ? "common" : companyCode.toLowerCase(); const result = await queryOne<{ category_id: number }>( `INSERT INTO multi_lang_category (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) VALUES ($1, $2, $3, 2, $4, $5, $6, 'Y', NOW()) RETURNING category_id`, [ companyCode, displayName, screenRootId, keyPrefix, `${displayName} 회사의 화면 다국어`, companyCode === "*" ? 0 : 10, ] ); logger.info("회사 카테고리 생성", { companyCode, categoryId: result?.category_id }); return result!.category_id; } catch (error) { logger.error("회사 카테고리 생성 실패:", error); throw error; } } /** * 메뉴 카테고리 확인 또는 생성 (메뉴 경로 전체) */ async ensureMenuCategory( companyCode: string, companyName: string, menuPath: string[] // ["영업관리", "수주관리"] ): Promise { try { if (menuPath.length === 0) { return await this.ensureCompanyCategory(companyCode, companyName); } let parentId = await this.ensureCompanyCategory(companyCode, companyName); let currentLevel = 3; for (const menuName of menuPath) { // 현재 메뉴 카테고리 확인 const existing = await queryOne<{ category_id: number }>( `SELECT category_id FROM multi_lang_category WHERE category_name = $1 AND parent_id = $2`, [menuName, parentId] ); if (existing) { parentId = existing.category_id; } else { // 메뉴 카테고리 생성 const menuCode = `${companyCode}_${menuName}`.replace(/\s+/g, "_"); const keyPrefix = menuName.toLowerCase().replace(/\s+/g, "_"); const result = await queryOne<{ category_id: number }>( `INSERT INTO multi_lang_category (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) VALUES ($1, $2, $3, $4, $5, $6, 0, 'Y', NOW()) RETURNING category_id`, [menuCode, menuName, parentId, currentLevel, keyPrefix, `${menuName} 메뉴의 다국어`] ); logger.info("메뉴 카테고리 생성", { menuName, categoryId: result?.category_id }); parentId = result!.category_id; } currentLevel++; } return parentId; } catch (error) { logger.error("메뉴 카테고리 생성 실패:", error); throw error; } } /** * 메뉴 경로 조회 (menu_info에서 부모 메뉴까지) */ async getMenuPath(menuObjId: string): Promise { try { const menus = await query<{ menu_name_kor: string; level: number }>( `WITH RECURSIVE menu_path AS ( SELECT objid, parent_obj_id, menu_name_kor, 1 as level FROM menu_info WHERE objid = $1 UNION ALL SELECT m.objid, m.parent_obj_id, m.menu_name_kor, mp.level + 1 FROM menu_info m INNER JOIN menu_path mp ON m.objid = mp.parent_obj_id WHERE m.parent_obj_id IS NOT NULL AND m.parent_obj_id != 0 ) SELECT menu_name_kor, level FROM menu_path WHERE menu_name_kor IS NOT NULL ORDER BY level DESC`, [menuObjId] ); return menus.map((m) => m.menu_name_kor); } catch (error) { logger.error("메뉴 경로 조회 실패:", error); return []; } } /** * 화면 라벨 다국어 키 자동 생성 */ async generateScreenLabelKeys(params: { screenId: number; companyCode: string; companyName: string; menuObjId?: string; labels: Array<{ componentId: string; label: string; type?: string }>; }): Promise> { try { logger.info("화면 라벨 다국어 키 자동 생성 시작", { screenId: params.screenId, companyCode: params.companyCode, labelCount: params.labels.length, }); // 메뉴 경로 조회 const menuPath = params.menuObjId ? await this.getMenuPath(params.menuObjId) : []; // 메뉴 카테고리 확보 const categoryId = await this.ensureMenuCategory( params.companyCode, params.companyName, menuPath ); // 카테고리 경로 조회 (키 생성용) const categoryPath = await this.getCategoryPath(categoryId); const keyPrefixParts = categoryPath.map((c) => c.keyPrefix); const results: Array<{ componentId: string; keyId: number; langKey: string }> = []; for (const labelInfo of params.labels) { // 라벨을 키 형태로 변환 (한글 → 스네이크케이스) const keyMeaning = this.labelToKeyMeaning(labelInfo.label); const langKey = [...keyPrefixParts, keyMeaning].join("."); // 기존 키 확인 const existingKey = await queryOne<{ key_id: number }>( `SELECT key_id FROM multi_lang_key_master WHERE lang_key = $1 AND company_code = $2`, [langKey, params.companyCode] ); let keyId: number; if (existingKey) { keyId = existingKey.key_id; logger.info("기존 키 사용", { langKey, keyId }); } else { // 새 키 생성 const keyResult = await queryOne<{ key_id: number }>( `INSERT INTO multi_lang_key_master (company_code, lang_key, description, is_active, category_id, key_meaning, created_date, created_by) VALUES ($1, $2, $3, 'Y', $4, $5, NOW(), 'system') RETURNING key_id`, [ params.companyCode, langKey, `화면 ${params.screenId}의 ${labelInfo.type || "라벨"}: ${labelInfo.label}`, categoryId, keyMeaning, ] ); keyId = keyResult!.key_id; // 한국어 텍스트 저장 (원문) await query( `INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_date, created_by) VALUES ($1, 'KR', $2, 'Y', NOW(), 'system') ON CONFLICT (key_id, lang_code) DO UPDATE SET lang_text = $2, updated_date = NOW()`, [keyId, labelInfo.label] ); logger.info("새 키 생성", { langKey, keyId }); } results.push({ componentId: labelInfo.componentId, keyId, langKey, }); } logger.info("화면 라벨 다국어 키 생성 완료", { screenId: params.screenId, generatedCount: results.length, }); return results; } catch (error) { logger.error("화면 라벨 다국어 키 생성 실패:", error); throw new Error( `화면 라벨 다국어 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * 라벨을 키 의미로 변환 (한글 → 스네이크케이스 또는 영문 유지) */ private labelToKeyMeaning(label: string): string { // 이미 영문 스네이크케이스면 그대로 사용 if (/^[a-z][a-z0-9_]*$/.test(label)) { return label; } // 영문 일반이면 스네이크케이스로 변환 if (/^[A-Za-z][A-Za-z0-9 ]*$/.test(label)) { return label.toLowerCase().replace(/\s+/g, "_"); } // 한글이면 간단한 변환 (특수문자 제거, 공백을 _로) return label .replace(/[^\w가-힣\s]/g, "") .replace(/\s+/g, "_") .toLowerCase(); } }