feat: 화면 그룹 삭제 시 메뉴 및 플로우 데이터 정리 로직 개선

- 화면 그룹 삭제 시 연결된 메뉴를 정리하는 로직을 추가하여, 삭제될 그룹에 연결된 메뉴를 자동으로 삭제하도록 하였습니다.
- 메뉴 삭제 시 관련된 화면 및 플로우 데이터도 함께 정리하여 데이터 일관성을 유지하였습니다.
- 복제 화면 모달에서 원본 회사와 동일한 회사 선택 시 자동으로 다른 회사로 변경하는 기능을 추가하였습니다.
- 삭제 확인 다이얼로그에 경고 메시지를 추가하여 사용자에게 삭제 작업의 영향을 명확히 안내하였습니다.
This commit is contained in:
DDD1542 2026-02-02 20:18:47 +09:00
parent 8c96b2d185
commit 257174d0c6
16 changed files with 472 additions and 442 deletions

View File

@ -10,6 +10,43 @@ import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// ============================================
// 프로세스 레벨 예외 처리 (서버 크래시 방지)
// ============================================
// 처리되지 않은 Promise 거부 핸들러
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
});
// 처리되지 않은 예외 핸들러
process.on("uncaughtException", (error: Error) => {
logger.error("🔥 Uncaught Exception:", {
message: error.message,
stack: error.stack,
});
// 예외 발생 후에도 서버를 유지하되, 상태가 불안정할 수 있으므로 주의
// 심각한 에러의 경우 graceful shutdown 후 재시작 권장
});
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
process.exit(0);
});
// SIGINT 시그널 처리 (Ctrl+C)
process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
process.exit(0);
});
// 라우터 임포트
import authRoutes from "./routes/authRoutes";
import adminRoutes from "./routes/adminRoutes";

View File

@ -1461,11 +1461,8 @@ async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
[menuObjid]
);
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 4. numbering_rules: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)
// 새 스키마: table_name + column_name + company_code 기반
// 5. rel_menu_auth에서 관련 권한 삭제
await query(

View File

@ -344,13 +344,65 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
childGroupIds: groupIdsToDelete
});
// 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리
// 2. 삭제될 그룹에 연결된 메뉴 정리
if (groupIdsToDelete.length > 0) {
await client.query(`
UPDATE menu_info
SET screen_group_id = NULL
// 2-1. 삭제할 메뉴 objid 수집
const menusToDelete = await client.query(`
SELECT objid FROM menu_info
WHERE screen_group_id = ANY($1::int[])
`, [groupIdsToDelete]);
AND company_code = $2
`, [groupIdsToDelete, targetCompanyCode]);
const menuObjids = menusToDelete.rows.map((r: any) => r.objid);
if (menuObjids.length > 0) {
// 2-2. screen_menu_assignments에서 해당 메뉴 관련 데이터 삭제
await client.query(`
DELETE FROM screen_menu_assignments
WHERE menu_objid = ANY($1::bigint[])
AND company_code = $2
`, [menuObjids, targetCompanyCode]);
// 2-3. menu_info에서 해당 메뉴 삭제
await client.query(`
DELETE FROM menu_info
WHERE screen_group_id = ANY($1::int[])
AND company_code = $2
`, [groupIdsToDelete, targetCompanyCode]);
logger.info("그룹 삭제 시 연결된 메뉴 삭제", {
groupIds: groupIdsToDelete,
deletedMenuCount: menuObjids.length,
companyCode: targetCompanyCode
});
}
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시)
// 삭제되는 그룹이 최상위인지 확인
const isRootGroup = await client.query(
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
[id]
);
if (isRootGroup.rows.length > 0) {
// 최상위 그룹 삭제 시 해당 회사의 채번 규칙도 삭제
// 먼저 파트 삭제
await client.query(
`DELETE FROM numbering_rule_parts
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
[targetCompanyCode]
);
// 규칙 삭제
const deletedRules = await client.query(
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
[targetCompanyCode]
);
if (deletedRules.rowCount && deletedRules.rowCount > 0) {
logger.info("그룹 삭제 시 채번 규칙 삭제", {
companyCode: targetCompanyCode,
deletedCount: deletedRules.rowCount
});
}
}
}
// 3. screen_groups 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제)

View File

@ -81,8 +81,26 @@ export const initializePool = (): Pool => {
pool.on("error", (err, client) => {
console.error("❌ PostgreSQL 연결 풀 에러:", err);
// 연결 풀 에러 발생 시 자동 재연결 시도
// Pool은 자동으로 연결을 재생성하므로 별도 처리 불필요
// 다만, 연속 에러 발생 시 알림이 필요할 수 있음
});
// 연결 풀 상태 체크 (5분마다)
setInterval(() => {
if (pool) {
const status = {
totalCount: pool.totalCount,
idleCount: pool.idleCount,
waitingCount: pool.waitingCount,
};
// 대기 중인 연결이 많으면 경고
if (status.waitingCount > 5) {
console.warn("⚠️ PostgreSQL 연결 풀 대기열 증가:", status);
}
}
}, 5 * 60 * 1000);
console.log(
`🚀 PostgreSQL 연결 풀 초기화 완료: ${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`
);

View File

@ -89,7 +89,7 @@ class CategoryTreeService {
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*')
AND table_name = $2
AND column_name = $3
@ -142,7 +142,7 @@ class CategoryTreeService {
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*')
AND table_name = $2
AND column_name = $3
@ -184,7 +184,7 @@ class CategoryTreeService {
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
`;
@ -221,7 +221,7 @@ class CategoryTreeService {
}
const query = `
INSERT INTO category_values_test (
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, updated_by
@ -334,7 +334,7 @@ class CategoryTreeService {
}
const query = `
UPDATE category_values_test
UPDATE category_values
SET
value_code = COALESCE($3, value_code),
value_label = COALESCE($4, value_label),
@ -415,11 +415,11 @@ class CategoryTreeService {
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
const query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM category_values_test
SELECT value_id FROM category_values
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
UNION ALL
SELECT cv.value_id
FROM category_values_test cv
FROM category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2 OR cv.company_code = '*'
)
@ -452,7 +452,7 @@ class CategoryTreeService {
for (const id of reversedIds) {
await pool.query(
`DELETE FROM category_values_test WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
[companyCode, id]
);
}
@ -479,7 +479,7 @@ class CategoryTreeService {
const query = `
SELECT value_id, value_label
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
`;
@ -488,7 +488,7 @@ class CategoryTreeService {
for (const child of result.rows) {
const newPath = `${parentPath}/${child.value_label}`;
await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
await pool.query(`UPDATE category_values SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
newPath,
child.value_id,
]);
@ -550,7 +550,7 @@ class CategoryTreeService {
/**
* ( . )
* category_values_test table_name, column_name
* category_values table_name, column_name
*
*/
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
@ -564,7 +564,7 @@ class CategoryTreeService {
cv.column_name AS "columnName",
COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
COALESCE(ttc.column_label, cv.column_name) AS "columnLabel"
FROM category_values_test cv
FROM category_values cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name AND ttc.column_name = cv.column_name AND ttc.company_code = '*'
WHERE cv.company_code = $1 OR cv.company_code = '*'

View File

@ -851,47 +851,10 @@ export class MenuCopyService {
]);
logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-4. 채번 규칙 처리 (체크 제약조건 고려)
// scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함)
// check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수
const menuScopedRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules
WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`,
[existingMenuIds, targetCompanyCode]
);
if (menuScopedRulesResult.rows.length > 0) {
const menuScopedRuleIds = menuScopedRulesResult.rows.map(
(r) => r.rule_id
);
// 채번 규칙 파트 먼저 삭제
await client.query(
`DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`,
[menuScopedRuleIds]
);
// 채번 규칙 삭제
await client.query(
`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`,
[menuScopedRuleIds]
);
logger.info(
` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}`
);
}
// scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존)
const updatedNumberingRules = await client.query(
`UPDATE numbering_rules
SET menu_objid = NULL
WHERE menu_objid = ANY($1) AND company_code = $2
AND (scope_type IS NULL OR scope_type != 'menu')
RETURNING rule_id`,
[existingMenuIds, targetCompanyCode]
);
if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) {
logger.info(
` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)`
);
}
// 5-4. 채번 규칙 처리 (새 스키마에서는 menu_objid 없음 - 스킵)
// 새 numbering_rules 스키마: table_name + column_name + company_code 기반
// 메뉴와 직접 연결되지 않으므로 메뉴 삭제 시 처리 불필요
logger.info(` ⏭️ 채번 규칙: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)`);
// 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가)
// 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제
@ -2590,8 +2553,9 @@ export class MenuCopyService {
}
/**
* (최적화: 배치 /)
* numberingRuleId
* ( 스키마: table_name + column_name )
* /numbering-rules/copy-for-company API를
* ruleIdMap ( numberingRuleService에서 )
*/
private async copyNumberingRulesWithMap(
menuObjids: number[],
@ -2600,222 +2564,47 @@ export class MenuCopyService {
userId: string,
client: PoolClient
): Promise<{ copiedCount: number; ruleIdMap: Map<string, string> }> {
let copiedCount = 0;
const ruleIdMap = new Map<string, string>();
if (menuObjids.length === 0) {
return { copiedCount, ruleIdMap };
}
// === 최적화: 배치 조회 ===
// 1. 모든 원본 채번 규칙 한 번에 조회
const allRulesResult = await client.query(
`SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`,
[menuObjids]
// 새 스키마에서는 채번규칙이 메뉴와 직접 연결되지 않음
// 프론트엔드에서 /numbering-rules/copy-for-company API를 별도 호출
// 여기서는 기존 규칙 ID를 그대로 매핑 (화면 레이아웃의 numberingRuleId 참조용)
// 원본 회사의 채번규칙 조회 (company_code 기반)
const sourceRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[menuObjids.length > 0 ? (await client.query(
`SELECT company_code FROM menu_info WHERE objid = $1`,
[menuObjids[0]]
)).rows[0]?.company_code : null]
);
if (allRulesResult.rows.length === 0) {
logger.info(` 📭 복사할 채번 규칙 없음`);
return { copiedCount, ruleIdMap };
}
// 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요)
const existingRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`,
// 대상 회사의 채번규칙 조회 (이름 기준 매핑)
const targetRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode]
);
const existingRuleIds = new Set(
existingRulesResult.rows.map((r) => r.rule_id)
const targetRulesByName = new Map(
targetRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id])
);
// 3. 복사할 규칙과 스킵할 규칙 분류
const rulesToCopy: any[] = [];
const originalToNewRuleMap: Array<{ original: string; new: string }> = [];
// 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
for (const rule of allRulesResult.rows) {
// 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가
// 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123
// 예: rule-123 -> rule-123 -> COMPANY_16_rule-123
// 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드
let baseName = rule.rule_id;
// 회사코드 접두사 패턴들을 순서대로 제거 시도
// 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_)
// 2. 일반 접두사_ 패턴 (예: WACE_)
if (baseName.match(/^COMPANY_\d+_/)) {
baseName = baseName.replace(/^COMPANY_\d+_/, "");
} else if (baseName.includes("_")) {
baseName = baseName.replace(/^[^_]+_/, "");
}
const newRuleId = `${targetCompanyCode}_${baseName}`;
if (existingRuleIds.has(rule.rule_id)) {
// 원본 ID가 이미 존재 (동일한 ID로 매핑)
ruleIdMap.set(rule.rule_id, rule.rule_id);
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (newMenuObjid) {
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
}
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
} else if (existingRuleIds.has(newRuleId)) {
// 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑)
ruleIdMap.set(rule.rule_id, newRuleId);
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (newMenuObjid) {
rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid });
}
logger.info(
` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}`
);
} else {
// 새로 복사 필요
ruleIdMap.set(rule.rule_id, newRuleId);
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
rulesToCopy.push({ ...rule, newRuleId });
logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`);
// 이름 기준으로 매핑 생성
for (const sourceRule of sourceRulesResult.rows) {
const targetRuleId = targetRulesByName.get(sourceRule.rule_name);
if (targetRuleId) {
ruleIdMap.set(sourceRule.rule_id, targetRuleId);
logger.info(` 🔗 채번규칙 매핑: ${sourceRule.rule_id} -> ${targetRuleId}`);
}
}
// 4. 배치 INSERT로 채번 규칙 복사
// menu 스코프인데 menu_objid 매핑이 없는 규칙은 제외 (연결 없이 복제하지 않음)
const validRulesToCopy = rulesToCopy.filter((r) => {
if (r.scope_type === "menu") {
const newMenuObjid = menuIdMap.get(r.menu_objid);
if (newMenuObjid === undefined) {
logger.info(` ⏭️ 채번규칙 "${r.rule_name}" 건너뜀: 메뉴 연결 없음 (원본 menu_objid: ${r.menu_objid})`);
// ruleIdMap에서도 제거
ruleIdMap.delete(r.rule_id);
return false; // 복제 대상에서 제외
}
}
return true;
});
if (validRulesToCopy.length > 0) {
const ruleValues = validRulesToCopy
.map(
(_, i) =>
`($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})`
)
.join(", ");
const ruleParams = validRulesToCopy.flatMap((r) => {
const newMenuObjid = menuIdMap.get(r.menu_objid);
// menu 스코프인 경우 반드시 menu_objid가 있음 (위에서 필터링됨)
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
// scope_type은 원본 유지 (menu 스코프는 반드시 menu_objid가 있으므로)
const finalScopeType = r.scope_type;
return [
r.newRuleId,
r.rule_name,
r.description,
r.separator,
r.reset_period,
0,
r.table_name,
r.column_name,
targetCompanyCode,
userId,
finalMenuObjid,
finalScopeType,
null,
];
});
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, created_by, menu_objid, scope_type, last_generated_date
) VALUES ${ruleValues}`,
ruleParams
);
copiedCount = validRulesToCopy.length;
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사 (${rulesToCopy.length - validRulesToCopy.length}개 건너뜀)`);
}
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
if (rulesToUpdate.length > 0) {
// CASE WHEN을 사용한 배치 업데이트
// menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요
const caseWhen = rulesToUpdate
.map(
(_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric`
)
.join(" ");
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
await client.query(
`UPDATE numbering_rules
SET menu_objid = CASE ${caseWhen} END, updated_at = NOW()
WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`,
[...params, ruleIdsForUpdate, targetCompanyCode]
);
logger.info(
` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신`
);
}
// 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상)
if (rulesToCopy.length > 0) {
const originalRuleIds = rulesToCopy.map((r) => r.rule_id);
const allPartsResult = await client.query(
`SELECT * FROM numbering_rule_parts
WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`,
[originalRuleIds]
);
// 6. 배치 INSERT로 채번 규칙 파트 복사
if (allPartsResult.rows.length > 0) {
// 원본 rule_id -> 새 rule_id 매핑
const ruleMapping = new Map(
originalToNewRuleMap.map((m) => [m.original, m.new])
);
const partValues = allPartsResult.rows
.map(
(_, i) =>
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())`
)
.join(", ");
const partParams = allPartsResult.rows.flatMap((p) => [
ruleMapping.get(p.rule_id),
p.part_order,
p.part_type,
p.generation_method,
p.auto_config,
p.manual_config,
targetCompanyCode,
]);
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 ${partValues}`,
partParams
);
logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`);
}
}
logger.info(
`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}`
);
return { copiedCount, ruleIdMap };
logger.info(` 📋 채번규칙 매핑 완료: ${ruleIdMap.size}`);
// 실제 복제는 프론트엔드에서 별도 API 호출로 처리됨
return { copiedCount: 0, ruleIdMap };
}
/**
* + (최적화: 배치 )
*

View File

@ -65,8 +65,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -88,8 +88,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -199,13 +199,13 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE scope_type = 'global'
WHERE 1=1
ORDER BY created_at DESC
`;
params = [];
@ -222,14 +222,13 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1 AND scope_type = 'global'
ORDER BY created_at DESC
WHERE company_code = $1 ORDER BY created_at DESC
`;
params = [companyCode];
}
@ -284,7 +283,7 @@ class NumberingRuleService {
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
// 최고 관리자: 모든 규칙 조회
query = `
SELECT
rule_id AS "ruleId",
@ -296,28 +295,18 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE
scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($1))
OR (scope_type = 'table' AND menu_objid = ANY($1))
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
WHEN scope_type = 'table' THEN 2
WHEN scope_type = 'global' THEN 3
END,
created_at DESC
ORDER BY created_at DESC
`;
params = [menuAndChildObjids];
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
params = [];
logger.info("최고 관리자: 전체 채번 규칙 조회");
} else {
// 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
// 일반 회사: 자신의 규칙만 조회
query = `
SELECT
rule_id AS "ruleId",
@ -329,28 +318,17 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1
AND (
scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($2))
OR (scope_type = 'table' AND menu_objid = ANY($2))
)
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($2)) THEN 1
WHEN scope_type = 'table' THEN 2
WHEN scope_type = 'global' THEN 3
END,
created_at DESC
ORDER BY created_at DESC
`;
params = [companyCode, menuAndChildObjids];
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
params = [companyCode];
logger.info("회사별 채번 규칙 조회", { companyCode });
}
logger.info("🔍 채번 규칙 쿼리 실행", {
@ -475,8 +453,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -500,8 +478,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -577,8 +555,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -599,8 +577,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -676,7 +654,7 @@ class NumberingRuleService {
INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
menu_objid, scope_type, created_by
category_column, category_value_id, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING
rule_id AS "ruleId",
@ -688,8 +666,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -705,8 +683,8 @@ class NumberingRuleService {
config.tableName || null,
config.columnName || null,
companyCode,
config.menuObjid || null,
config.scopeType || "global",
config.categoryColumn || null,
config.categoryValueId || null,
userId,
]);
@ -778,8 +756,8 @@ class NumberingRuleService {
reset_period = COALESCE($4, reset_period),
table_name = COALESCE($5, table_name),
column_name = COALESCE($6, column_name),
menu_objid = COALESCE($7, menu_objid),
scope_type = COALESCE($8, scope_type),
category_column = COALESCE($7, category_column),
category_value_id = COALESCE($8, category_value_id),
updated_at = NOW()
WHERE rule_id = $9 AND company_code = $10
RETURNING
@ -792,8 +770,8 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
@ -806,8 +784,8 @@ class NumberingRuleService {
updates.resetPeriod,
updates.tableName,
updates.columnName,
updates.menuObjid,
updates.scopeType,
updates.categoryColumn,
updates.categoryValueId,
ruleId,
companyCode,
]);
@ -1198,7 +1176,7 @@ class NumberingRuleService {
/**
* []
* numbering_rules_test
* numbering_rules
*/
async getRulesFromTest(
companyCode: string,
@ -1231,7 +1209,7 @@ class NumberingRuleService {
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules_test
FROM numbering_rules
ORDER BY created_at DESC
`;
params = [];
@ -1253,7 +1231,7 @@ class NumberingRuleService {
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules_test
FROM numbering_rules
WHERE company_code = $1
ORDER BY created_at DESC
`;
@ -1272,7 +1250,7 @@ class NumberingRuleService {
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
@ -1300,8 +1278,8 @@ class NumberingRuleService {
}
/**
* [] + (menu_objid )
* numbering_rules_test
* +
* numbering_rules
*/
async getNumberingRuleByColumn(
companyCode: string,
@ -1333,8 +1311,8 @@ class NumberingRuleService {
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
FROM numbering_rules r
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
@ -1365,7 +1343,7 @@ class NumberingRuleService {
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
@ -1391,7 +1369,7 @@ class NumberingRuleService {
/**
* []
* numbering_rules_test
* numbering_rules
*/
async saveRuleToTest(
config: NumberingRuleConfig,
@ -1414,7 +1392,7 @@ class NumberingRuleService {
// 기존 규칙 확인
const existingQuery = `
SELECT rule_id FROM numbering_rules_test
SELECT rule_id FROM numbering_rules
WHERE rule_id = $1 AND company_code = $2
`;
const existingResult = await client.query(existingQuery, [config.ruleId, companyCode]);
@ -1422,7 +1400,7 @@ class NumberingRuleService {
if (existingResult.rows.length > 0) {
// 업데이트
const updateQuery = `
UPDATE numbering_rules_test SET
UPDATE numbering_rules SET
rule_name = $1,
description = $2,
separator = $3,
@ -1449,13 +1427,13 @@ class NumberingRuleService {
// 기존 파트 삭제
await client.query(
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
[config.ruleId, companyCode]
);
} else {
// 신규 등록
const insertQuery = `
INSERT INTO numbering_rules_test (
INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
category_column, category_value_id,
@ -1482,7 +1460,7 @@ class NumberingRuleService {
if (config.parts && config.parts.length > 0) {
for (const part of config.parts) {
const partInsertQuery = `
INSERT INTO numbering_rule_parts_test (
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, NOW())
@ -1523,7 +1501,7 @@ class NumberingRuleService {
/**
* []
* numbering_rules_test
* numbering_rules
*/
async deleteRuleFromTest(ruleId: string, companyCode: string): Promise<void> {
const pool = getPool();
@ -1536,13 +1514,13 @@ class NumberingRuleService {
// 파트 먼저 삭제
await client.query(
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
// 규칙 삭제
const result = await client.query(
"DELETE FROM numbering_rules_test WHERE rule_id = $1 AND company_code = $2",
"DELETE FROM numbering_rules WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
@ -1608,8 +1586,8 @@ class NumberingRuleService {
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
FROM numbering_rules r
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
@ -1636,7 +1614,7 @@ class NumberingRuleService {
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
@ -1668,7 +1646,7 @@ class NumberingRuleService {
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
FROM numbering_rules r
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
@ -1688,7 +1666,7 @@ class NumberingRuleService {
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
@ -1745,8 +1723,8 @@ class NumberingRuleService {
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
FROM numbering_rules r
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
@ -1764,7 +1742,7 @@ class NumberingRuleService {
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
@ -1783,7 +1761,7 @@ class NumberingRuleService {
/**
* ( )
* numbering_rules_test, numbering_rule_parts_test
* numbering_rules, numbering_rule_parts
* numberingRuleId
*/
async copyRulesForCompany(
@ -1798,9 +1776,28 @@ class NumberingRuleService {
try {
await client.query("BEGIN");
// 1. 원본 회사의 채번규칙 조회 - numbering_rules_test 사용
// 0. 대상 회사의 기존 채번규칙 삭제 (깨끗하게 복제하기 위해)
// 먼저 파트 삭제
await client.query(
`DELETE FROM numbering_rule_parts
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
[targetCompanyCode]
);
// 규칙 삭제
const deleteResult = await client.query(
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
[targetCompanyCode]
);
if (deleteResult.rowCount && deleteResult.rowCount > 0) {
logger.info("기존 채번규칙 삭제", {
targetCompanyCode,
deletedCount: deleteResult.rowCount
});
}
// 1. 원본 회사의 채번규칙 조회 - numbering_rules 사용
const sourceRulesResult = await client.query(
`SELECT * FROM numbering_rules_test WHERE company_code = $1`,
`SELECT * FROM numbering_rules WHERE company_code = $1`,
[sourceCompanyCode]
);
@ -1814,9 +1811,9 @@ class NumberingRuleService {
// 새 rule_id 생성
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// 이미 존재하는지 확인 (이름 기반) - numbering_rules_test 사용
// 이미 존재하는지 확인 (이름 기반) - numbering_rules 사용
const existsCheck = await client.query(
`SELECT rule_id FROM numbering_rules_test
`SELECT rule_id FROM numbering_rules
WHERE company_code = $1 AND rule_name = $2`,
[targetCompanyCode, rule.rule_name]
);
@ -1829,9 +1826,9 @@ class NumberingRuleService {
continue;
}
// 채번규칙 복제 - numbering_rules_test 사용
// 채번규칙 복제 - numbering_rules 사용
await client.query(
`INSERT INTO numbering_rules_test (
`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, category_column, category_value_id
@ -1852,15 +1849,15 @@ class NumberingRuleService {
]
);
// 채번규칙 파트 복제 - numbering_rule_parts_test 사용
// 채번규칙 파트 복제 - numbering_rule_parts 사용
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`,
`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_test (
`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, NOW())`,

View File

@ -635,7 +635,76 @@ export class ScreenManagementService {
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 (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',
@ -655,7 +724,7 @@ export class ScreenManagementService {
],
);
// 메뉴 할당도 비활성화
// 4. 메뉴 할당도 비활성화
await client.query(
`UPDATE screen_menu_assignments
SET is_active = 'N'
@ -2946,7 +3015,7 @@ export class ScreenManagementService {
* - current_sequence는 0
*/
/**
* (numbering_rules_test )
* (numbering_rules )
* - menu_objid
* - table_name + column_name + company_code
*/
@ -2964,10 +3033,10 @@ export class ScreenManagementService {
console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`);
// 1. 원본 채번 규칙 조회 (numbering_rules_test 테이블)
// 1. 원본 채번 규칙 조회 (numbering_rules 테이블)
const ruleIdArray = Array.from(ruleIds);
const sourceRulesResult = await client.query(
`SELECT * FROM numbering_rules_test WHERE rule_id = ANY($1)`,
`SELECT * FROM numbering_rules WHERE rule_id = ANY($1)`,
[ruleIdArray],
);
@ -2980,7 +3049,7 @@ export class ScreenManagementService {
// 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준)
const existingRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules_test WHERE company_code = $1`,
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode],
);
const existingRulesByName = new Map<string, string>(
@ -3001,9 +3070,9 @@ export class ScreenManagementService {
// 새로 복사 - 새 rule_id 생성
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// numbering_rules_test 복사 (current_sequence = 0으로 초기화)
// numbering_rules 복사 (current_sequence = 0으로 초기화)
await client.query(
`INSERT INTO numbering_rules_test (
`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,
@ -3028,15 +3097,15 @@ export class ScreenManagementService {
],
);
// numbering_rule_parts_test 복사
// numbering_rule_parts 복사
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`,
`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_test (
`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)`,
@ -4542,7 +4611,8 @@ export class ScreenManagementService {
);
if (menuInfo.rows.length > 0) {
const isAdminMenu = menuInfo.rows[0].menu_type === "1";
// menu_type: "0" = 관리자 메뉴, "1" = 사용자 메뉴
const isAdminMenu = menuInfo.rows[0].menu_type === "0";
const newMenuUrl = isAdminMenu
? `/screens/${newScreenId}?mode=admin`
: `/screens/${newScreenId}`;
@ -4707,7 +4777,7 @@ export class ScreenManagementService {
}
/**
* (category_values_test )
* (category_values )
* - menu_objid
* - table_name + column_name + company_code
*/
@ -4741,13 +4811,13 @@ export class ScreenManagementService {
// 1. 기존 대상 회사 데이터 삭제 (다른 회사로 복제 시에만)
await client.query(
`DELETE FROM category_values_test WHERE company_code = $1`,
`DELETE FROM category_values WHERE company_code = $1`,
[targetCompanyCode],
);
// 2. category_values_test 복제
// 2. category_values 복제
const values = await client.query(
`SELECT * FROM category_values_test WHERE company_code = $1`,
`SELECT * FROM category_values WHERE company_code = $1`,
[sourceCompanyCode],
);
@ -4756,7 +4826,7 @@ export class ScreenManagementService {
for (const v of values.rows) {
const insertResult = await client.query(
`INSERT INTO category_values_test
`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)
@ -4791,7 +4861,7 @@ export class ScreenManagementService {
const newValueId = valueIdMap.get(v.value_id);
if (newParentId && newValueId) {
await client.query(
`UPDATE category_values_test SET parent_value_id = $1 WHERE value_id = $2`,
`UPDATE category_values SET parent_value_id = $1 WHERE value_id = $2`,
[newParentId, newValueId],
);
}

View File

@ -212,22 +212,22 @@ class TableCategoryValueService {
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM category_values_test
FROM category_values
WHERE table_name = $1
AND column_name = $2
`;
// category_values_test 테이블 사용 (menu_objid 없음)
// category_values 테이블 사용 (menu_objid 없음)
if (companyCode === "*") {
// 최고 관리자: 모든 값 조회
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)");
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
} else {
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode });
logger.info("회사별 카테고리 값 조회 (category_values)", { companyCode });
}
if (!includeInactive) {

View File

@ -108,7 +108,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
const loadAllCategoryOptions = async () => {
try {
// category_values_test 테이블에서 고유한 테이블.컬럼 조합 조회
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options: CategoryOption[] = response.data.map((item) => ({
@ -341,7 +341,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
ruleToSave,
});
// 테스트 테이블에 저장 (numbering_rules_test)
// 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {

View File

@ -253,6 +253,24 @@ export default function CopyScreenModal({
}
}, [useBulkRename, removeText, addPrefix]);
// 원본 회사가 선택된 경우 다른 회사로 자동 변경
useEffect(() => {
if (!companies.length || !isOpen) return;
const sourceCompanyCode = mode === "group"
? sourceGroup?.company_code
: sourceScreen?.companyCode;
// 원본 회사와 같은 회사가 선택되어 있으면 다른 회사로 변경
if (sourceCompanyCode && targetCompanyCode === sourceCompanyCode) {
const otherCompany = companies.find(c => c.companyCode !== sourceCompanyCode);
if (otherCompany) {
console.log("🔄 원본 회사 선택됨 → 다른 회사로 자동 변경:", otherCompany.companyCode);
setTargetCompanyCode(otherCompany.companyCode);
}
}
}, [companies, isOpen, mode, sourceGroup, sourceScreen, targetCompanyCode]);
// 대상 회사 변경 시 기존 코드 초기화
useEffect(() => {
if (targetCompanyCode) {
@ -1182,31 +1200,36 @@ export default function CopyScreenModal({
// 그룹 복제 모드 렌더링
if (mode === "group") {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
{/* 로딩 오버레이 */}
{isCopying && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="mt-4 text-sm font-medium">{copyProgress.message}</p>
<>
{/* 로딩 오버레이 - Dialog 바깥에서 화면 전체 고정 */}
{isCopying && (
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background/95 backdrop-blur-md">
<div className="rounded-lg bg-card p-8 shadow-lg border flex flex-col items-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-base font-medium">{copyProgress.message}</p>
{copyProgress.total > 0 && (
<>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
<div className="mt-4 h-3 w-64 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${Math.round((copyProgress.current / copyProgress.total) * 100)}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{copyProgress.current} / {copyProgress.total}
<p className="mt-3 text-sm text-muted-foreground">
{copyProgress.current} / {copyProgress.total} ...
</p>
</>
)}
<p className="mt-4 text-xs text-muted-foreground">
</p>
</div>
)}
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
</div>
)}
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FolderTree className="h-5 w-5" />
</DialogTitle>
@ -1486,15 +1509,22 @@ export default function CopyScreenModal({
onChange={(e) => setTargetCompanyCode(e.target.value)}
className="mt-1 flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
>
{companies.map((company) => (
<option key={company.companyCode} value={company.companyCode}>
{company.companyName} ({company.companyCode})
</option>
))}
{companies
.filter((company) => company.companyCode !== sourceGroup?.company_code)
.map((company) => (
<option key={company.companyCode} value={company.companyCode}>
{company.companyName} ({company.companyCode})
</option>
))}
</select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
{sourceGroup && (
<p className="mt-1 text-[10px] text-amber-600 sm:text-xs">
* ({sourceGroup.company_code})
</p>
)}
</div>
)}
@ -1590,14 +1620,25 @@ export default function CopyScreenModal({
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// 화면 복제 모드 렌더링
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<>
{/* 로딩 오버레이 - Dialog 바깥에서 화면 전체 고정 */}
{isCopying && (
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background/95 backdrop-blur-md">
<div className="rounded-lg bg-card p-8 shadow-lg border flex flex-col items-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-base font-medium"> </p>
</div>
</div>
)}
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{sourceScreen?.screenName}" .
@ -1694,13 +1735,20 @@ export default function CopyScreenModal({
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.companyCode} value={company.companyCode}>
{company.companyName}
</SelectItem>
))}
{companies
.filter((company) => company.companyCode !== sourceScreen?.companyCode)
.map((company) => (
<SelectItem key={company.companyCode} value={company.companyCode}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
{sourceScreen && (
<p className="mt-1 text-[10px] text-amber-600">
* ({sourceScreen.companyCode})
</p>
)}
</div>
)}
@ -1840,6 +1888,7 @@ export default function CopyScreenModal({
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -18,6 +18,7 @@ import {
Loader2,
RefreshCw,
Building2,
AlertTriangle,
} from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import {
@ -1463,16 +1464,26 @@ export function ScreenGroupTreeView({
{/* 그룹 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px] border-destructive/50">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingGroup?.group_name}" ?
<br />
{deleteScreensWithGroup
? <span className="text-destructive font-medium"> .</span>
: "그룹에 속한 화면들은 미분류로 이동됩니다."
}
<AlertDialogTitle className="text-base sm:text-lg flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-xs sm:text-sm">
<div className="mt-2 rounded-md bg-destructive/10 border border-destructive/30 p-3">
<p className="font-semibold text-destructive">
&quot;{deletingGroup?.group_name}&quot; ?
</p>
<p className="mt-2 text-destructive/80">
{deleteScreensWithGroup
? "⚠️ 그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
: "그룹에 속한 화면들은 미분류로 이동됩니다."
}
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@ -1570,11 +1581,21 @@ export function ScreenGroupTreeView({
)}
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingScreen?.screenName}" ?
<br />
.
<AlertDialogTitle className="text-base sm:text-lg flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-xs sm:text-sm">
<div className="mt-2 rounded-md bg-destructive/10 border border-destructive/30 p-3">
<p className="font-semibold text-destructive">
&quot;{deletingScreen?.screenName}&quot; ?
</p>
<p className="mt-2 text-destructive/80">
, . .
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">

View File

@ -492,7 +492,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
const categoryTable = (config as any).categoryTable;
const categoryColumn = (config as any).categoryColumn;
// category 소스 유지 (category_values_test 테이블에서 로드)
// category 소스 유지 (category_values 테이블에서 로드)
const source = rawSource;
const codeGroup = config.codeGroup;
@ -612,7 +612,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
fetchedOptions = data;
}
} else if (source === "category") {
// 카테고리에서 로드 (category_values_test 테이블)
// 카테고리에서 로드 (category_values 테이블)
// tableName, columnName은 props에서 가져옴
const catTable = categoryTable || tableName;
const catColumn = categoryColumn || columnName;

View File

@ -470,7 +470,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const categoryTable = (config as any).categoryTable;
const categoryColumn = (config as any).categoryColumn;
// category 소스 유지 (category_values_test 테이블에서 로드)
// category 소스 유지 (category_values 테이블에서 로드)
const source = rawSource;
const codeGroup = config.codeGroup;
@ -590,7 +590,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
fetchedOptions = data;
}
} else if (source === "category") {
// 카테고리에서 로드 (category_values_test 테이블)
// 카테고리에서 로드 (category_values 테이블)
// tableName, columnName은 props에서 가져옴
const catTable = categoryTable || tableName;
const catColumn = categoryColumn || columnName;

View File

@ -173,11 +173,11 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
}
}
// ====== 테스트용 API (numbering_rules_test 테이블 사용) ======
// ====== 테스트용 API (numbering_rules 테이블 사용) ======
/**
* []
* numbering_rules_test
* numbering_rules
* @param menuObjid OBJID () -
*/
export async function getNumberingRulesFromTest(
@ -199,7 +199,7 @@ export async function getNumberingRulesFromTest(
/**
* [] +
* numbering_rules_test
* numbering_rules
*/
export async function getNumberingRuleByColumn(
tableName: string,
@ -220,7 +220,7 @@ export async function getNumberingRuleByColumn(
/**
* []
* numbering_rules_test
* numbering_rules
*/
export async function saveNumberingRuleToTest(
config: NumberingRuleConfig
@ -238,7 +238,7 @@ export async function saveNumberingRuleToTest(
/**
* []
* numbering_rules_test
* numbering_rules
*/
export async function deleteNumberingRuleFromTest(
ruleId: string

View File

@ -109,7 +109,7 @@ export interface NumberingRuleConfig {
// 카테고리 조건 (특정 카테고리 값일 때만 이 규칙 적용)
categoryColumn?: string; // 카테고리 조건 컬럼명 (예: 'type', 'material')
categoryValueId?: number; // 카테고리 값 ID (category_values_test.value_id)
categoryValueId?: number; // 카테고리 값 ID (category_values.value_id)
categoryValueLabel?: string; // 카테고리 값 라벨 (조회 시 조인)
// 메타 정보