970 lines
33 KiB
TypeScript
970 lines
33 KiB
TypeScript
import { getPool } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
|
|
const pool = getPool();
|
|
|
|
/**
|
|
* 메뉴-화면그룹 동기화 서비스
|
|
*
|
|
* 양방향 동기화:
|
|
* 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화
|
|
* 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화
|
|
*/
|
|
|
|
// ============================================================
|
|
// 타입 정의
|
|
// ============================================================
|
|
|
|
interface SyncResult {
|
|
success: boolean;
|
|
created: number;
|
|
linked: number;
|
|
skipped: number;
|
|
errors: string[];
|
|
details: SyncDetail[];
|
|
}
|
|
|
|
interface SyncDetail {
|
|
action: 'created' | 'linked' | 'skipped' | 'error';
|
|
sourceName: string;
|
|
sourceId: number | string;
|
|
targetId?: number | string;
|
|
reason?: string;
|
|
}
|
|
|
|
// ============================================================
|
|
// 화면관리 → 메뉴 동기화
|
|
// ============================================================
|
|
|
|
/**
|
|
* screen_groups를 menu_info로 동기화
|
|
*
|
|
* 로직:
|
|
* 1. 해당 회사의 screen_groups 조회 (폴더 구조)
|
|
* 2. 이미 menu_objid가 연결된 것은 제외
|
|
* 3. 이름으로 기존 menu_info 매칭 시도
|
|
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
|
* - 매칭 안되면: menu_info에 새로 생성
|
|
* 4. 계층 구조(parent) 유지
|
|
*/
|
|
export async function syncScreenGroupsToMenu(
|
|
companyCode: string,
|
|
userId: string
|
|
): Promise<SyncResult> {
|
|
const result: SyncResult = {
|
|
success: true,
|
|
created: 0,
|
|
linked: 0,
|
|
skipped: 0,
|
|
errors: [],
|
|
details: [],
|
|
};
|
|
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
|
|
|
|
// 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
|
|
const screenGroupsQuery = `
|
|
SELECT
|
|
sg.id,
|
|
sg.group_name,
|
|
sg.group_code,
|
|
sg.parent_group_id,
|
|
sg.group_level,
|
|
sg.display_order,
|
|
sg.description,
|
|
sg.icon,
|
|
sg.menu_objid,
|
|
-- 부모 그룹의 menu_objid도 조회 (계층 연결용)
|
|
parent.menu_objid as parent_menu_objid
|
|
FROM screen_groups sg
|
|
LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
|
|
WHERE sg.company_code = $1
|
|
ORDER BY sg.group_level ASC, sg.display_order ASC
|
|
`;
|
|
const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
|
|
|
|
// 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
|
|
// 경로 기반 매칭을 위해 부모 이름도 조회
|
|
const existingMenusQuery = `
|
|
SELECT
|
|
m.objid,
|
|
m.menu_name_kor,
|
|
m.parent_obj_id,
|
|
m.screen_group_id,
|
|
p.menu_name_kor as parent_name
|
|
FROM menu_info m
|
|
LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
|
|
WHERE m.company_code = $1 AND m.menu_type = 1
|
|
`;
|
|
const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
|
|
|
|
// 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
|
|
// 단순 이름 매칭도 유지 (하위 호환)
|
|
const menuByPath: Map<string, any> = new Map();
|
|
const menuByName: Map<string, any> = new Map();
|
|
existingMenusResult.rows.forEach((menu: any) => {
|
|
if (!menu.screen_group_id) {
|
|
const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
|
|
const parentName = menu.parent_name?.trim().toLowerCase() || '';
|
|
const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
|
|
|
|
menuByPath.set(pathKey, menu);
|
|
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
|
if (!menuByName.has(menuName)) {
|
|
menuByName.set(menuName, menu);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
|
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
|
|
|
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
|
// 없으면 생성
|
|
let userMenuRootObjid: number | null = null;
|
|
const rootMenuQuery = `
|
|
SELECT objid FROM menu_info
|
|
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
|
|
ORDER BY seq ASC
|
|
LIMIT 1
|
|
`;
|
|
const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
|
|
|
|
if (rootMenuResult.rows.length > 0) {
|
|
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
|
} else {
|
|
// 루트 메뉴가 없으면 생성
|
|
const newObjid = Date.now();
|
|
const createRootQuery = `
|
|
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
|
|
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
|
|
RETURNING objid
|
|
`;
|
|
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
|
|
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
|
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
|
}
|
|
|
|
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
|
const groupToMenuMap: Map<number, number> = new Map();
|
|
|
|
// screen_groups의 부모 이름 조회를 위한 매핑
|
|
const groupIdToName: Map<number, string> = new Map();
|
|
screenGroupsResult.rows.forEach((g: any) => {
|
|
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
|
|
});
|
|
|
|
// 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
|
|
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
|
|
const topLevelCompanyFolderIds = new Set<number>();
|
|
for (const group of screenGroupsResult.rows) {
|
|
if (group.group_level === 0 && group.parent_group_id === null) {
|
|
topLevelCompanyFolderIds.add(group.id);
|
|
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
|
|
groupToMenuMap.set(group.id, userMenuRootObjid!);
|
|
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
|
|
}
|
|
}
|
|
|
|
// 6. 각 screen_group 처리
|
|
for (const group of screenGroupsResult.rows) {
|
|
const groupId = group.id;
|
|
const groupName = group.group_name?.trim();
|
|
const groupNameLower = groupName?.toLowerCase() || '';
|
|
|
|
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
|
|
if (topLevelCompanyFolderIds.has(groupId)) {
|
|
result.skipped++;
|
|
result.details.push({
|
|
action: 'skipped',
|
|
sourceName: groupName,
|
|
sourceId: groupId,
|
|
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
|
|
if (group.menu_objid) {
|
|
const menuExists = existingMenuObjids.has(Number(group.menu_objid));
|
|
|
|
if (menuExists) {
|
|
// 메뉴가 존재하면 스킵
|
|
result.skipped++;
|
|
result.details.push({
|
|
action: 'skipped',
|
|
sourceName: groupName,
|
|
sourceId: groupId,
|
|
targetId: group.menu_objid,
|
|
reason: '이미 메뉴와 연결됨',
|
|
});
|
|
groupToMenuMap.set(groupId, Number(group.menu_objid));
|
|
continue;
|
|
} else {
|
|
// 메뉴가 삭제되었으면 연결 해제하고 재생성
|
|
logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
|
|
await client.query(
|
|
`UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
|
|
[groupId]
|
|
);
|
|
// 계속 진행하여 재생성 또는 재연결
|
|
}
|
|
}
|
|
|
|
// 부모 그룹 이름 조회 (경로 기반 매칭용)
|
|
const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
|
|
const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
|
|
|
|
// 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
|
let matchedMenu = menuByPath.get(pathKey);
|
|
if (!matchedMenu) {
|
|
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
|
matchedMenu = menuByName.get(groupNameLower);
|
|
}
|
|
|
|
if (matchedMenu) {
|
|
// 매칭된 메뉴와 연결
|
|
const menuObjid = Number(matchedMenu.objid);
|
|
|
|
// screen_groups에 menu_objid 업데이트
|
|
await client.query(
|
|
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
|
[menuObjid, groupId]
|
|
);
|
|
|
|
// menu_info에 screen_group_id 업데이트
|
|
await client.query(
|
|
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
|
[groupId, menuObjid]
|
|
);
|
|
|
|
groupToMenuMap.set(groupId, menuObjid);
|
|
result.linked++;
|
|
result.details.push({
|
|
action: 'linked',
|
|
sourceName: groupName,
|
|
sourceId: groupId,
|
|
targetId: menuObjid,
|
|
});
|
|
|
|
// 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
|
|
menuByPath.delete(pathKey);
|
|
menuByName.delete(groupNameLower);
|
|
|
|
} else {
|
|
// 새 메뉴 생성
|
|
const newObjid = Date.now() + groupId; // 고유 ID 보장
|
|
|
|
// 부모 메뉴 objid 결정
|
|
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
|
let parentMenuObjid = userMenuRootObjid;
|
|
if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
|
|
// 현재 트랜잭션에서 생성된 부모 메뉴 사용
|
|
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
|
|
} else if (group.parent_group_id && group.parent_menu_objid) {
|
|
// 기존 parent_menu_objid가 실제로 존재하는지 확인
|
|
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
|
|
if (parentMenuExists) {
|
|
parentMenuObjid = Number(group.parent_menu_objid);
|
|
}
|
|
}
|
|
|
|
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1
|
|
let nextSeq = 1;
|
|
const maxSeqQuery = `
|
|
SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
|
|
FROM menu_info
|
|
WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
|
|
`;
|
|
const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
|
|
if (maxSeqResult.rows.length > 0) {
|
|
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
|
|
}
|
|
|
|
// menu_info에 삽입
|
|
const insertMenuQuery = `
|
|
INSERT INTO menu_info (
|
|
objid, parent_obj_id, menu_name_kor, menu_name_eng,
|
|
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
|
|
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
|
|
RETURNING objid
|
|
`;
|
|
await client.query(insertMenuQuery, [
|
|
newObjid,
|
|
parentMenuObjid,
|
|
groupName,
|
|
group.group_code || groupName,
|
|
nextSeq,
|
|
companyCode,
|
|
userId,
|
|
groupId,
|
|
group.description || null,
|
|
]);
|
|
|
|
// screen_groups에 menu_objid 업데이트
|
|
await client.query(
|
|
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
|
[newObjid, groupId]
|
|
);
|
|
|
|
groupToMenuMap.set(groupId, newObjid);
|
|
result.created++;
|
|
result.details.push({
|
|
action: 'created',
|
|
sourceName: groupName,
|
|
sourceId: groupId,
|
|
targetId: newObjid,
|
|
});
|
|
}
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
logger.info("화면관리 → 메뉴 동기화 완료", {
|
|
companyCode,
|
|
created: result.created,
|
|
linked: result.linked,
|
|
skipped: result.skipped
|
|
});
|
|
|
|
return result;
|
|
|
|
} catch (error: any) {
|
|
await client.query('ROLLBACK');
|
|
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
|
|
result.success = false;
|
|
result.errors.push(error.message);
|
|
return result;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 메뉴 → 화면관리 동기화
|
|
// ============================================================
|
|
|
|
/**
|
|
* menu_info를 screen_groups로 동기화
|
|
*
|
|
* 로직:
|
|
* 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회
|
|
* 2. 이미 screen_group_id가 연결된 것은 제외
|
|
* 3. 이름으로 기존 screen_groups 매칭 시도
|
|
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
|
* - 매칭 안되면: screen_groups에 새로 생성 (폴더로)
|
|
* 4. 계층 구조(parent) 유지
|
|
*/
|
|
export async function syncMenuToScreenGroups(
|
|
companyCode: string,
|
|
userId: string
|
|
): Promise<SyncResult> {
|
|
const result: SyncResult = {
|
|
success: true,
|
|
created: 0,
|
|
linked: 0,
|
|
skipped: 0,
|
|
errors: [],
|
|
details: [],
|
|
};
|
|
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
|
|
|
|
// 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
|
|
const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
|
|
const companyNameResult = await client.query(companyNameQuery, [companyCode]);
|
|
const companyName = companyNameResult.rows[0]?.company_name || companyCode;
|
|
|
|
// 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
|
|
const menusQuery = `
|
|
SELECT
|
|
m.objid,
|
|
m.menu_name_kor,
|
|
m.menu_name_eng,
|
|
m.parent_obj_id,
|
|
m.seq,
|
|
m.menu_url,
|
|
m.menu_desc,
|
|
m.screen_group_id,
|
|
-- 부모 메뉴의 screen_group_id도 조회 (계층 연결용)
|
|
parent.screen_group_id as parent_screen_group_id
|
|
FROM menu_info m
|
|
LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
|
|
WHERE m.company_code = $1 AND m.menu_type = 1
|
|
ORDER BY
|
|
CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
|
|
m.parent_obj_id,
|
|
m.seq
|
|
`;
|
|
const menusResult = await client.query(menusQuery, [companyCode]);
|
|
|
|
// 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
|
|
const existingGroupsQuery = `
|
|
SELECT
|
|
g.id,
|
|
g.group_name,
|
|
g.menu_objid,
|
|
g.parent_group_id,
|
|
p.group_name as parent_name
|
|
FROM screen_groups g
|
|
LEFT JOIN screen_groups p ON g.parent_group_id = p.id
|
|
WHERE g.company_code = $1
|
|
`;
|
|
const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
|
|
|
|
// 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
|
|
// 단순 이름 매칭도 유지 (하위 호환)
|
|
const groupByPath: Map<string, any> = new Map();
|
|
const groupByName: Map<string, any> = new Map();
|
|
existingGroupsResult.rows.forEach((group: any) => {
|
|
if (!group.menu_objid) {
|
|
const groupName = group.group_name?.trim().toLowerCase() || '';
|
|
const parentName = group.parent_name?.trim().toLowerCase() || '';
|
|
const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
|
|
|
|
groupByPath.set(pathKey, group);
|
|
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
|
if (!groupByName.has(groupName)) {
|
|
groupByName.set(groupName, group);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 모든 그룹의 id 집합 (삭제 확인용)
|
|
const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
|
|
|
|
// 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
|
|
let companyFolderId: number | null = null;
|
|
const companyFolderQuery = `
|
|
SELECT id FROM screen_groups
|
|
WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
|
|
ORDER BY id ASC
|
|
LIMIT 1
|
|
`;
|
|
const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
|
|
|
|
if (companyFolderResult.rows.length > 0) {
|
|
companyFolderId = companyFolderResult.rows[0].id;
|
|
logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
|
|
} else {
|
|
// 회사 폴더가 없으면 생성
|
|
// 루트 레벨에서 가장 높은 display_order 조회 후 +1
|
|
let nextRootOrder = 1;
|
|
const maxRootOrderQuery = `
|
|
SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
|
|
FROM screen_groups
|
|
WHERE parent_group_id IS NULL
|
|
`;
|
|
const maxRootOrderResult = await client.query(maxRootOrderQuery);
|
|
if (maxRootOrderResult.rows.length > 0) {
|
|
nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
|
|
}
|
|
|
|
const createFolderQuery = `
|
|
INSERT INTO screen_groups (
|
|
group_name, group_code, parent_group_id, group_level,
|
|
display_order, company_code, writer, hierarchy_path
|
|
) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
|
|
RETURNING id
|
|
`;
|
|
const createFolderResult = await client.query(createFolderQuery, [
|
|
companyName,
|
|
companyCode.toLowerCase(),
|
|
nextRootOrder,
|
|
companyCode,
|
|
userId,
|
|
]);
|
|
companyFolderId = createFolderResult.rows[0].id;
|
|
|
|
// hierarchy_path 업데이트
|
|
await client.query(
|
|
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
|
[`/${companyFolderId}/`, companyFolderId]
|
|
);
|
|
|
|
logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
|
|
}
|
|
|
|
// 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
|
|
const menuToGroupMap: Map<number, number> = new Map();
|
|
|
|
// 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
|
|
menusResult.rows.forEach((menu: any) => {
|
|
if (menu.screen_group_id) {
|
|
menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
|
|
}
|
|
});
|
|
|
|
// 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
|
|
let rootMenuObjid: number | null = null;
|
|
for (const menu of menusResult.rows) {
|
|
if (Number(menu.parent_obj_id) === 0) {
|
|
rootMenuObjid = Number(menu.objid);
|
|
// 루트 메뉴는 회사 폴더와 연결
|
|
if (companyFolderId) {
|
|
menuToGroupMap.set(rootMenuObjid, companyFolderId);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 5. 각 메뉴 처리
|
|
for (const menu of menusResult.rows) {
|
|
const menuObjid = Number(menu.objid);
|
|
const menuName = menu.menu_name_kor?.trim();
|
|
|
|
// 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
|
|
if (Number(menu.parent_obj_id) === 0) {
|
|
result.skipped++;
|
|
result.details.push({
|
|
action: 'skipped',
|
|
sourceName: menuName,
|
|
sourceId: menuObjid,
|
|
targetId: companyFolderId || undefined,
|
|
reason: '루트 메뉴 → 회사 폴더와 매핑됨',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
|
|
if (menu.screen_group_id) {
|
|
const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
|
|
|
|
if (groupExists) {
|
|
// 그룹이 존재하면 스킵
|
|
result.skipped++;
|
|
result.details.push({
|
|
action: 'skipped',
|
|
sourceName: menuName,
|
|
sourceId: menuObjid,
|
|
targetId: menu.screen_group_id,
|
|
reason: '이미 화면그룹과 연결됨',
|
|
});
|
|
menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
|
|
continue;
|
|
} else {
|
|
// 그룹이 삭제되었으면 연결 해제하고 재생성
|
|
logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
|
|
await client.query(
|
|
`UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
|
|
[menuObjid]
|
|
);
|
|
// 계속 진행하여 재생성 또는 재연결
|
|
}
|
|
}
|
|
|
|
const menuNameLower = menuName?.toLowerCase() || '';
|
|
|
|
// 부모 메뉴 이름 조회 (경로 기반 매칭용)
|
|
const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
|
|
const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
|
|
const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
|
|
|
|
// 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
|
let matchedGroup = groupByPath.get(pathKey);
|
|
if (!matchedGroup) {
|
|
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
|
matchedGroup = groupByName.get(menuNameLower);
|
|
}
|
|
|
|
if (matchedGroup) {
|
|
// 매칭된 그룹과 연결
|
|
const groupId = Number(matchedGroup.id);
|
|
|
|
try {
|
|
// menu_info에 screen_group_id 업데이트
|
|
await client.query(
|
|
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
|
[groupId, menuObjid]
|
|
);
|
|
|
|
// screen_groups에 menu_objid 업데이트
|
|
await client.query(
|
|
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
|
[menuObjid, groupId]
|
|
);
|
|
|
|
menuToGroupMap.set(menuObjid, groupId);
|
|
result.linked++;
|
|
result.details.push({
|
|
action: 'linked',
|
|
sourceName: menuName,
|
|
sourceId: menuObjid,
|
|
targetId: groupId,
|
|
});
|
|
|
|
// 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
|
|
groupByPath.delete(pathKey);
|
|
groupByName.delete(menuNameLower);
|
|
} catch (linkError: any) {
|
|
logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
|
|
throw linkError;
|
|
}
|
|
|
|
} else {
|
|
// 새 screen_group 생성
|
|
// 부모 그룹 ID 결정
|
|
let parentGroupId: number | null = null;
|
|
let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
|
|
|
|
// 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
|
|
if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
|
|
parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
|
|
}
|
|
// 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
|
|
else if (Number(menu.parent_obj_id) === rootMenuObjid) {
|
|
parentGroupId = companyFolderId;
|
|
}
|
|
// 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
|
|
else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
|
|
parentGroupId = Number(menu.parent_screen_group_id);
|
|
}
|
|
|
|
// 부모 그룹의 레벨 조회
|
|
if (parentGroupId) {
|
|
const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
|
|
const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
|
|
if (parentLevelResult.rows.length > 0) {
|
|
groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
|
|
}
|
|
}
|
|
|
|
// 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
|
|
let nextDisplayOrder = 1;
|
|
const maxOrderQuery = parentGroupId
|
|
? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
|
|
: `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
|
|
const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
|
|
const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
|
|
if (maxOrderResult.rows.length > 0) {
|
|
nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
|
|
}
|
|
|
|
// group_code 생성 (영문명 또는 이름 기반)
|
|
const groupCode = (menu.menu_name_eng || menuName || 'group')
|
|
.replace(/\s+/g, '_')
|
|
.toLowerCase()
|
|
.substring(0, 50);
|
|
|
|
// screen_groups에 삽입
|
|
const insertGroupQuery = `
|
|
INSERT INTO screen_groups (
|
|
group_name, group_code, parent_group_id, group_level,
|
|
display_order, company_code, writer, menu_objid, description
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id
|
|
`;
|
|
|
|
let newGroupId: number;
|
|
try {
|
|
logger.info("새 그룹 생성 시도", {
|
|
menuName,
|
|
menuObjid,
|
|
groupCode: groupCode + '_' + menuObjid,
|
|
parentGroupId,
|
|
groupLevel,
|
|
nextDisplayOrder,
|
|
companyCode,
|
|
});
|
|
|
|
const insertResult = await client.query(insertGroupQuery, [
|
|
menuName,
|
|
groupCode + '_' + menuObjid, // 고유성 보장
|
|
parentGroupId,
|
|
groupLevel,
|
|
nextDisplayOrder,
|
|
companyCode,
|
|
userId,
|
|
menuObjid,
|
|
menu.menu_desc || null,
|
|
]);
|
|
|
|
newGroupId = insertResult.rows[0].id;
|
|
} catch (insertError: any) {
|
|
logger.error("그룹 생성 중 에러", {
|
|
menuName,
|
|
menuObjid,
|
|
parentGroupId,
|
|
groupLevel,
|
|
error: insertError.message,
|
|
stack: insertError.stack,
|
|
code: insertError.code,
|
|
detail: insertError.detail,
|
|
});
|
|
throw insertError;
|
|
}
|
|
|
|
// hierarchy_path 업데이트
|
|
let hierarchyPath = `/${newGroupId}/`;
|
|
if (parentGroupId) {
|
|
const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
|
|
const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
|
|
if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
|
|
hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
|
|
}
|
|
}
|
|
await client.query(
|
|
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
|
[hierarchyPath, newGroupId]
|
|
);
|
|
|
|
// menu_info에 screen_group_id 업데이트
|
|
await client.query(
|
|
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
|
[newGroupId, menuObjid]
|
|
);
|
|
|
|
menuToGroupMap.set(menuObjid, newGroupId);
|
|
result.created++;
|
|
result.details.push({
|
|
action: 'created',
|
|
sourceName: menuName,
|
|
sourceId: menuObjid,
|
|
targetId: newGroupId,
|
|
});
|
|
}
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
logger.info("메뉴 → 화면관리 동기화 완료", {
|
|
companyCode,
|
|
created: result.created,
|
|
linked: result.linked,
|
|
skipped: result.skipped
|
|
});
|
|
|
|
return result;
|
|
|
|
} catch (error: any) {
|
|
await client.query('ROLLBACK');
|
|
logger.error("메뉴 → 화면관리 동기화 실패", {
|
|
companyCode,
|
|
error: error.message,
|
|
stack: error.stack,
|
|
code: error.code,
|
|
detail: error.detail,
|
|
});
|
|
result.success = false;
|
|
result.errors.push(error.message);
|
|
return result;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 동기화 상태 조회
|
|
// ============================================================
|
|
|
|
/**
|
|
* 동기화 상태 조회
|
|
*
|
|
* - 연결된 항목 수
|
|
* - 연결 안 된 항목 수
|
|
* - 양방향 비교
|
|
*/
|
|
export async function getSyncStatus(companyCode: string): Promise<{
|
|
screenGroups: { total: number; linked: number; unlinked: number };
|
|
menuItems: { total: number; linked: number; unlinked: number };
|
|
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
|
|
}> {
|
|
// screen_groups 상태
|
|
const sgQuery = `
|
|
SELECT
|
|
COUNT(*) as total,
|
|
COUNT(menu_objid) as linked
|
|
FROM screen_groups
|
|
WHERE company_code = $1
|
|
`;
|
|
const sgResult = await pool.query(sgQuery, [companyCode]);
|
|
|
|
// menu_info 상태 (사용자 메뉴만, 루트 제외)
|
|
const menuQuery = `
|
|
SELECT
|
|
COUNT(*) as total,
|
|
COUNT(screen_group_id) as linked
|
|
FROM menu_info
|
|
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
|
|
`;
|
|
const menuResult = await pool.query(menuQuery, [companyCode]);
|
|
|
|
// 이름이 같은 잠재적 매칭 후보 조회
|
|
const matchQuery = `
|
|
SELECT
|
|
m.menu_name_kor as menu_name,
|
|
sg.group_name
|
|
FROM menu_info m
|
|
JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
|
|
WHERE m.company_code = $1
|
|
AND sg.company_code = $1
|
|
AND m.menu_type = 1
|
|
AND m.screen_group_id IS NULL
|
|
AND sg.menu_objid IS NULL
|
|
LIMIT 10
|
|
`;
|
|
const matchResult = await pool.query(matchQuery, [companyCode]);
|
|
|
|
const sgTotal = parseInt(sgResult.rows[0].total);
|
|
const sgLinked = parseInt(sgResult.rows[0].linked);
|
|
const menuTotal = parseInt(menuResult.rows[0].total);
|
|
const menuLinked = parseInt(menuResult.rows[0].linked);
|
|
|
|
return {
|
|
screenGroups: {
|
|
total: sgTotal,
|
|
linked: sgLinked,
|
|
unlinked: sgTotal - sgLinked,
|
|
},
|
|
menuItems: {
|
|
total: menuTotal,
|
|
linked: menuLinked,
|
|
unlinked: menuTotal - menuLinked,
|
|
},
|
|
potentialMatches: matchResult.rows.map((row: any) => ({
|
|
menuName: row.menu_name,
|
|
groupName: row.group_name,
|
|
similarity: 'exact',
|
|
})),
|
|
};
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 전체 동기화 (모든 회사)
|
|
// ============================================================
|
|
|
|
interface AllCompaniesSyncResult {
|
|
success: boolean;
|
|
totalCompanies: number;
|
|
successCount: number;
|
|
failedCount: number;
|
|
results: Array<{
|
|
companyCode: string;
|
|
companyName: string;
|
|
direction: 'screens-to-menus' | 'menus-to-screens';
|
|
created: number;
|
|
linked: number;
|
|
skipped: number;
|
|
success: boolean;
|
|
error?: string;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* 모든 회사에 대해 양방향 동기화 수행
|
|
*
|
|
* 로직:
|
|
* 1. 모든 회사 조회
|
|
* 2. 각 회사별로 양방향 동기화 수행
|
|
* - 화면관리 → 메뉴 동기화
|
|
* - 메뉴 → 화면관리 동기화
|
|
* 3. 결과 집계
|
|
*/
|
|
export async function syncAllCompanies(
|
|
userId: string
|
|
): Promise<AllCompaniesSyncResult> {
|
|
const result: AllCompaniesSyncResult = {
|
|
success: true,
|
|
totalCompanies: 0,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
results: [],
|
|
};
|
|
|
|
try {
|
|
logger.info("전체 동기화 시작", { userId });
|
|
|
|
// 모든 회사 조회 (최고 관리자 전용 회사 제외)
|
|
const companiesQuery = `
|
|
SELECT company_code, company_name
|
|
FROM company_mng
|
|
WHERE company_code != '*'
|
|
ORDER BY company_name
|
|
`;
|
|
const companiesResult = await pool.query(companiesQuery);
|
|
|
|
result.totalCompanies = companiesResult.rows.length;
|
|
|
|
// 각 회사별로 양방향 동기화
|
|
for (const company of companiesResult.rows) {
|
|
const companyCode = company.company_code;
|
|
const companyName = company.company_name;
|
|
|
|
try {
|
|
// 1. 화면관리 → 메뉴 동기화
|
|
const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
|
|
result.results.push({
|
|
companyCode,
|
|
companyName,
|
|
direction: 'screens-to-menus',
|
|
created: screensToMenusResult.created,
|
|
linked: screensToMenusResult.linked,
|
|
skipped: screensToMenusResult.skipped,
|
|
success: screensToMenusResult.success,
|
|
error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
|
|
});
|
|
|
|
// 2. 메뉴 → 화면관리 동기화
|
|
const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
|
|
result.results.push({
|
|
companyCode,
|
|
companyName,
|
|
direction: 'menus-to-screens',
|
|
created: menusToScreensResult.created,
|
|
linked: menusToScreensResult.linked,
|
|
skipped: menusToScreensResult.skipped,
|
|
success: menusToScreensResult.success,
|
|
error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
|
|
});
|
|
|
|
if (screensToMenusResult.success && menusToScreensResult.success) {
|
|
result.successCount++;
|
|
} else {
|
|
result.failedCount++;
|
|
}
|
|
|
|
} catch (error: any) {
|
|
logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
|
|
result.results.push({
|
|
companyCode,
|
|
companyName,
|
|
direction: 'screens-to-menus',
|
|
created: 0,
|
|
linked: 0,
|
|
skipped: 0,
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
result.failedCount++;
|
|
}
|
|
}
|
|
|
|
logger.info("전체 동기화 완료", {
|
|
totalCompanies: result.totalCompanies,
|
|
successCount: result.successCount,
|
|
failedCount: result.failedCount,
|
|
});
|
|
|
|
return result;
|
|
|
|
} catch (error: any) {
|
|
logger.error("전체 동기화 실패", { error: error.message });
|
|
result.success = false;
|
|
return result;
|
|
}
|
|
}
|
|
|