다국어 자동생성

This commit is contained in:
kjs
2026-01-14 10:20:27 +09:00
parent 18b5161398
commit 61a7f585b4
8 changed files with 591 additions and 1 deletions

View File

@@ -1165,6 +1165,33 @@ export async function saveMenu(
logger.info("메뉴 저장 성공", { savedMenu });
// 다국어 메뉴 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
// 메뉴 경로 조회 및 카테고리 생성
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
logger.info("메뉴 다국어 카테고리 생성 완료", {
menuObjId: savedMenu.objid.toString(),
menuPath,
});
} catch (categoryError) {
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
menuObjId: savedMenu.objid.toString(),
error: categoryError,
});
}
const response: ApiResponse<any> = {
success: true,
message: "메뉴가 성공적으로 저장되었습니다.",
@@ -2649,6 +2676,24 @@ export const createCompany = async (
});
}
// 다국어 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
await multilangService.ensureCompanyCategory(
createdCompany.company_code,
createdCompany.company_name
);
logger.info("회사 다국어 카테고리 생성 완료", {
companyCode: createdCompany.company_code,
});
} catch (categoryError) {
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
companyCode: createdCompany.company_code,
error: categoryError,
});
}
logger.info("회사 등록 성공", {
companyCode: createdCompany.company_code,
companyName: createdCompany.company_name,

View File

@@ -1098,3 +1098,80 @@ export const getBatchTranslations = async (
});
}
};
/**
* POST /api/multilang/screen-labels
* 화면 라벨 다국어 키 자동 생성 API
*/
export const generateScreenLabelKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, menuObjId, labels } = req.body;
logger.info("화면 라벨 다국어 키 생성 요청", {
screenId,
menuObjId,
labelCount: labels?.length,
user: req.user,
});
// 필수 파라미터 검증
if (!screenId) {
res.status(400).json({
success: false,
message: "screenId는 필수입니다.",
error: { code: "MISSING_SCREEN_ID" },
});
return;
}
if (!labels || !Array.isArray(labels) || labels.length === 0) {
res.status(400).json({
success: false,
message: "labels 배열이 필요합니다.",
error: { code: "MISSING_LABELS" },
});
return;
}
// 사용자 회사 정보
const companyCode = req.user?.companyCode || "*";
// 회사명 조회
const { queryOne } = await import("../database/db");
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
const multiLangService = new MultiLangService();
const results = await multiLangService.generateScreenLabelKeys({
screenId: Number(screenId),
companyCode,
companyName,
menuObjId,
labels,
});
const response: ApiResponse<typeof results> = {
success: true,
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
data: results,
};
res.status(200).json(response);
} catch (error) {
logger.error("화면 라벨 다국어 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};

View File

@@ -32,6 +32,9 @@ import {
previewKey,
createOverrideKey,
getOverrideKeys,
// 화면 라벨 다국어 API
generateScreenLabelKeys,
} from "../controllers/multilangController";
const router = express.Router();
@@ -73,4 +76,7 @@ router.post("/keys/preview", previewKey); // 키 미리보기
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
// 화면 라벨 다국어 자동 생성 API
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
export default router;

View File

@@ -1293,4 +1293,291 @@ export class MultiLangService {
);
}
}
// =====================================================
// 회사/메뉴 기반 카테고리 자동 생성 메서드
// =====================================================
/**
* 화면(screen) 루트 카테고리 확인 또는 생성
*/
async ensureScreenRootCategory(): Promise<number> {
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<number> {
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<number> {
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<string[]> {
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<Array<{ componentId: string; keyId: number; langKey: string }>> {
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();
}
}