Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/src"],
|
||||
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/tests/**"],
|
||||
coverageDirectory: "coverage",
|
||||
coverageReporters: ["text", "lcov", "html"],
|
||||
setupFilesAfterEnv: ["<rootDir>/src/tests/setup.ts"],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
// 환경 변수 설정
|
||||
setupFiles: ["<rootDir>/src/tests/env.setup.ts"],
|
||||
};
|
||||
+26
-13
@@ -16,14 +16,17 @@ 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(
|
||||
"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) => {
|
||||
@@ -115,11 +118,14 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import entitySearchRoutes, {
|
||||
entityOptionsRouter,
|
||||
} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
@@ -131,6 +137,7 @@ import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
@@ -139,6 +146,8 @@ import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
// 기본 미들웨어
|
||||
app.use(
|
||||
helmet({
|
||||
@@ -152,7 +161,7 @@ app.use(
|
||||
], // 프론트엔드 도메인 허용
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
@@ -175,13 +184,13 @@ app.use(
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
"Content-Type, Authorization",
|
||||
);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(process.cwd(), "uploads"))
|
||||
express.static(path.join(process.cwd(), "uploads")),
|
||||
);
|
||||
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
@@ -201,7 +210,7 @@ app.use(
|
||||
],
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 200,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Rate Limiting (개발 환경에서는 완화)
|
||||
@@ -316,9 +325,11 @@ app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
@@ -352,11 +363,13 @@ app.listen(PORT, HOST, async () => {
|
||||
runDashboardMigration,
|
||||
runTableHistoryActionMigration,
|
||||
runDtgManagementLogMigration,
|
||||
runApprovalSystemMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
await runTableHistoryActionMigration();
|
||||
await runDtgManagementLogMigration();
|
||||
await runApprovalSystemMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
||||
import { MenuCopyService } from "../services/menuCopyService";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회
|
||||
@@ -1120,8 +1121,8 @@ export async function saveMenu(
|
||||
`INSERT INTO menu_info (
|
||||
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_url, menu_desc, writer, regdate, status,
|
||||
system_name, company_code, lang_key, lang_key_desc, screen_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
system_name, company_code, lang_key, lang_key_desc, screen_code, menu_icon
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING *`,
|
||||
[
|
||||
objid,
|
||||
@@ -1140,6 +1141,7 @@ export async function saveMenu(
|
||||
menuData.langKey || null,
|
||||
menuData.langKeyDesc || null,
|
||||
screenCode,
|
||||
menuData.menuIcon || null,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1176,7 +1178,7 @@ export async function saveMenu(
|
||||
success: true,
|
||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||
data: {
|
||||
objid: savedMenu.objid.toString(), // BigInt를 문자열로 변환
|
||||
objid: savedMenu.objid.toString(),
|
||||
menuNameKor: savedMenu.menu_name_kor,
|
||||
menuNameEng: savedMenu.menu_name_eng,
|
||||
menuUrl: savedMenu.menu_url,
|
||||
@@ -1187,6 +1189,20 @@ export async function saveMenu(
|
||||
},
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "CREATE",
|
||||
resourceType: "MENU",
|
||||
resourceId: savedMenu.objid?.toString(),
|
||||
resourceName: savedMenu.menu_name_kor,
|
||||
summary: `메뉴 "${savedMenu.menu_name_kor}" 생성`,
|
||||
changes: { after: { menuNameKor: savedMenu.menu_name_kor, menuUrl: savedMenu.menu_url, status: savedMenu.status } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("메뉴 저장 실패:", error);
|
||||
@@ -1323,8 +1339,9 @@ export async function updateMenu(
|
||||
company_code = $10,
|
||||
lang_key = $11,
|
||||
lang_key_desc = $12,
|
||||
screen_code = $13
|
||||
WHERE objid = $14
|
||||
screen_code = $13,
|
||||
menu_icon = $14
|
||||
WHERE objid = $15
|
||||
RETURNING *`,
|
||||
[
|
||||
menuData.menuType ? Number(menuData.menuType) : null,
|
||||
@@ -1340,6 +1357,7 @@ export async function updateMenu(
|
||||
menuData.langKey || null,
|
||||
menuData.langKeyDesc || null,
|
||||
screenCode,
|
||||
menuData.menuIcon || null,
|
||||
Number(menuId),
|
||||
]
|
||||
);
|
||||
@@ -1372,6 +1390,23 @@ export async function updateMenu(
|
||||
},
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || updatedMenu.company_code || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "MENU",
|
||||
resourceId: updatedMenu.objid?.toString(),
|
||||
resourceName: updatedMenu.menu_name_kor,
|
||||
summary: `메뉴 "${updatedMenu.menu_name_kor}" 수정`,
|
||||
changes: {
|
||||
before: { menuNameKor: currentMenu.menu_name_kor, menuUrl: currentMenu.menu_url, status: currentMenu.status },
|
||||
after: { menuNameKor: updatedMenu.menu_name_kor, menuUrl: updatedMenu.menu_url, status: updatedMenu.status },
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("메뉴 수정 실패:", error);
|
||||
@@ -1551,6 +1586,20 @@ export async function deleteMenu(
|
||||
},
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: currentMenu.company_code || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "MENU",
|
||||
resourceId: menuObjid.toString(),
|
||||
resourceName: currentMenu.menu_name_kor,
|
||||
summary: `메뉴 "${currentMenu.menu_name_kor}" 삭제 (하위 ${childMenuIds.length}개 포함, 총 ${allMenuIdsToDelete.length}건)`,
|
||||
changes: { before: { menuNameKor: currentMenu.menu_name_kor } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("메뉴 삭제 실패:", error);
|
||||
@@ -1714,6 +1763,20 @@ export async function deleteMenusBatch(
|
||||
},
|
||||
};
|
||||
|
||||
if (deletedCount > 0) {
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "MENU",
|
||||
summary: `메뉴 일괄 삭제: ${deletedCount}개 삭제, ${failedCount}개 실패`,
|
||||
changes: { before: { deletedMenus, failedMenuIds } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("메뉴 일괄 삭제 실패:", error);
|
||||
@@ -1810,6 +1873,20 @@ export async function toggleMenuStatus(
|
||||
data: result,
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: currentMenu.company_code || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "STATUS_CHANGE",
|
||||
resourceType: "MENU",
|
||||
resourceId: String(menuId),
|
||||
resourceName: currentMenu.menu_name_kor,
|
||||
summary: `메뉴 "${currentMenu.menu_name_kor}" 상태 변경: ${currentStatus} → ${newStatus}`,
|
||||
changes: { before: { status: currentStatus }, after: { status: newStatus }, fields: ["status"] },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("메뉴 상태 토글 실패:", error);
|
||||
@@ -2439,6 +2516,20 @@ export const changeUserStatus = async (
|
||||
updatedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: currentUser.company_code || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "STATUS_CHANGE",
|
||||
resourceType: "USER",
|
||||
resourceId: userId,
|
||||
resourceName: currentUser.user_name,
|
||||
summary: `사용자 "${currentUser.user_name}" 상태 변경: ${currentUser.status} → ${status}`,
|
||||
changes: { before: { status: currentUser.status }, after: { status }, fields: ["status"] },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
result: true,
|
||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
@@ -2576,6 +2667,20 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
},
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userData.companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: isExistingUser ? "UPDATE" : "CREATE",
|
||||
resourceType: "USER",
|
||||
resourceId: userData.userId,
|
||||
resourceName: userData.userName,
|
||||
summary: isExistingUser ? `사용자 "${userData.userName}" 정보 수정` : `사용자 "${userData.userName}" 등록`,
|
||||
changes: { after: { userId: userData.userId, userName: userData.userName, deptName: userData.deptName, status: userData.status } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("사용자 저장 실패", { error, userData: req.body });
|
||||
@@ -2766,6 +2871,20 @@ export const createCompany = async (
|
||||
},
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: createdCompany.company_code,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "CREATE",
|
||||
resourceType: "COMPANY",
|
||||
resourceId: createdCompany.company_code,
|
||||
resourceName: createdCompany.company_name,
|
||||
summary: `회사 "${createdCompany.company_name}" (${createdCompany.company_code}) 등록`,
|
||||
changes: { after: { company_code: createdCompany.company_code, company_name: createdCompany.company_name } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(201).json(response);
|
||||
} finally {
|
||||
await client.end();
|
||||
@@ -2935,7 +3054,11 @@ export const updateCompany = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Raw Query로 회사 정보 수정
|
||||
const beforeCompany = await queryOne<any>(
|
||||
`SELECT company_name, status FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
const result = await query<any>(
|
||||
`UPDATE company_mng
|
||||
SET
|
||||
@@ -2991,6 +3114,23 @@ export const updateCompany = async (
|
||||
},
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: updatedCompany.company_code,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "COMPANY",
|
||||
resourceId: updatedCompany.company_code,
|
||||
resourceName: updatedCompany.company_name,
|
||||
summary: `회사 "${updatedCompany.company_name}" 정보 수정`,
|
||||
changes: {
|
||||
before: { company_name: beforeCompany?.company_name, status: beforeCompany?.status },
|
||||
after: { company_name: updatedCompany.company_name, status: updatedCompany.status },
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("회사 정보 수정 실패", { error, body: req.body });
|
||||
@@ -3052,6 +3192,20 @@ export const deleteCompany = async (
|
||||
},
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: deletedCompany.company_code,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "COMPANY",
|
||||
resourceId: deletedCompany.company_code,
|
||||
resourceName: deletedCompany.company_name,
|
||||
summary: `회사 "${deletedCompany.company_name}" (${deletedCompany.company_code}) 삭제`,
|
||||
changes: { before: { company_code: deletedCompany.company_code, company_name: deletedCompany.company_name } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("회사 삭제 실패", { error });
|
||||
@@ -3218,6 +3372,20 @@ export const updateProfile = async (
|
||||
: null,
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "USER",
|
||||
resourceId: userId,
|
||||
resourceName: updatedUser?.user_name || "",
|
||||
summary: `프로필 수정 (${updateFields.length}개 항목)`,
|
||||
changes: { after: { userName, email, tel, cellPhone, locale } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
result: true,
|
||||
message: "프로필이 성공적으로 업데이트되었습니다.",
|
||||
@@ -3331,6 +3499,20 @@ export const resetUserPassword = async (
|
||||
updatedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "USER",
|
||||
resourceId: userId,
|
||||
resourceName: currentUser.user_name,
|
||||
summary: `사용자 "${currentUser.user_name}" 비밀번호 초기화`,
|
||||
changes: { fields: ["user_password"] },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
@@ -3508,6 +3690,8 @@ export async function copyMenu(
|
||||
? {
|
||||
removeText: req.body.screenNameConfig.removeText,
|
||||
addPrefix: req.body.screenNameConfig.addPrefix,
|
||||
replaceFrom: req.body.screenNameConfig.replaceFrom,
|
||||
replaceTo: req.body.screenNameConfig.replaceTo,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -3532,6 +3716,19 @@ export async function copyMenu(
|
||||
|
||||
logger.info("✅ 메뉴 복사 API 성공");
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || userId,
|
||||
userName: req.user?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "MENU",
|
||||
resourceId: menuObjid,
|
||||
summary: `메뉴(${menuObjid}) → 회사 "${targetCompanyCode}"로 복사`,
|
||||
changes: { after: { targetCompanyCode, menuObjid } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "메뉴 복사 완료",
|
||||
@@ -3846,6 +4043,20 @@ export const saveUserWithDept = async (
|
||||
isUpdate: isExistingUser,
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: isExistingUser ? "UPDATE" : "CREATE",
|
||||
resourceType: "USER",
|
||||
resourceId: userInfo.user_id,
|
||||
resourceName: userInfo.user_name,
|
||||
summary: `사용자 "${userInfo.user_name}" ${isExistingUser ? "수정" : "등록"} (부서: ${mainDept?.dept_name || "없음"})`,
|
||||
changes: { after: { userName: userInfo.user_name, email: userInfo.email, deptName: mainDept?.dept_name, status: userInfo.status } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
|
||||
// ============================================================
|
||||
// 결재 정의 (Approval Definitions) CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalDefinitionController {
|
||||
// 결재 유형 목록 조회
|
||||
static async getDefinitions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { is_active, search } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (is_active) {
|
||||
conditions.push(`is_active = $${idx}`);
|
||||
params.push(is_active);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY definition_id ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 상세 조회
|
||||
static async getDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const row = await queryOne<any>(
|
||||
"SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: row });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 생성
|
||||
static async createDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const {
|
||||
definition_name,
|
||||
definition_name_eng,
|
||||
description,
|
||||
default_template_id,
|
||||
max_steps = 5,
|
||||
allow_self_approval = false,
|
||||
allow_cancel = true,
|
||||
is_active = "Y",
|
||||
} = req.body;
|
||||
|
||||
if (!definition_name) {
|
||||
return res.status(400).json({ success: false, message: "결재 유형명은 필수입니다." });
|
||||
}
|
||||
|
||||
const userId = req.user?.userId || "system";
|
||||
const [row] = await query<any>(
|
||||
`INSERT INTO approval_definitions (
|
||||
definition_name, definition_name_eng, description, default_template_id,
|
||||
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||
company_code, created_by, updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
|
||||
RETURNING *`,
|
||||
[
|
||||
definition_name, definition_name_eng, description, default_template_id,
|
||||
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||
companyCode, userId,
|
||||
]
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: row, message: "결재 유형이 생성되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 수정
|
||||
static async updateDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const {
|
||||
definition_name, definition_name_eng, description, default_template_id,
|
||||
max_steps, allow_self_approval, allow_cancel, is_active,
|
||||
} = req.body;
|
||||
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (definition_name !== undefined) { fields.push(`definition_name = $${idx++}`); params.push(definition_name); }
|
||||
if (definition_name_eng !== undefined) { fields.push(`definition_name_eng = $${idx++}`); params.push(definition_name_eng); }
|
||||
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
|
||||
if (default_template_id !== undefined) { fields.push(`default_template_id = $${idx++}`); params.push(default_template_id); }
|
||||
if (max_steps !== undefined) { fields.push(`max_steps = $${idx++}`); params.push(max_steps); }
|
||||
if (allow_self_approval !== undefined) { fields.push(`allow_self_approval = $${idx++}`); params.push(allow_self_approval); }
|
||||
if (allow_cancel !== undefined) { fields.push(`allow_cancel = $${idx++}`); params.push(allow_cancel); }
|
||||
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
|
||||
|
||||
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
|
||||
params.push(req.user?.userId || "system");
|
||||
params.push(id, companyCode);
|
||||
|
||||
const [row] = await query<any>(
|
||||
`UPDATE approval_definitions SET ${fields.join(", ")}
|
||||
WHERE definition_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: row, message: "결재 유형이 수정되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 유형 삭제
|
||||
static async deleteDefinition(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
await query<any>(
|
||||
"DELETE FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, message: "결재 유형이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 유형 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 유형 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재선 템플릿 (Approval Line Templates) CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalTemplateController {
|
||||
// 템플릿 목록 조회
|
||||
static async getTemplates(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { definition_id, is_active } = req.query;
|
||||
|
||||
const conditions: string[] = ["t.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (definition_id) {
|
||||
conditions.push(`t.definition_id = $${idx++}`);
|
||||
params.push(definition_id);
|
||||
}
|
||||
if (is_active) {
|
||||
conditions.push(`t.is_active = $${idx++}`);
|
||||
params.push(is_active);
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT t.*, d.definition_name
|
||||
FROM approval_line_templates t
|
||||
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY t.template_id ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 상세 조회 (단계 포함)
|
||||
static async getTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const template = await queryOne<any>(
|
||||
`SELECT t.*, d.definition_name
|
||||
FROM approval_line_templates t
|
||||
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
|
||||
WHERE t.template_id = $1 AND t.company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const steps = await query<any>(
|
||||
"SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: { ...template, steps } });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 생성 (단계 포함 트랜잭션)
|
||||
static async createTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body;
|
||||
|
||||
if (!template_name) {
|
||||
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||
}
|
||||
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
let result: any;
|
||||
await transaction(async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`,
|
||||
[template_name, description, definition_id, is_active, companyCode, userId]
|
||||
);
|
||||
result = rows[0];
|
||||
|
||||
// 단계 일괄 삽입
|
||||
if (Array.isArray(steps) && steps.length > 0) {
|
||||
for (const step of steps) {
|
||||
await client.query(
|
||||
`INSERT INTO approval_line_template_steps
|
||||
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
result.template_id,
|
||||
step.step_order,
|
||||
step.approver_type || "user",
|
||||
step.approver_user_id || null,
|
||||
step.approver_position || null,
|
||||
step.approver_dept_code || null,
|
||||
step.approver_label || null,
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: result, message: "결재선 템플릿이 생성되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 수정
|
||||
static async updateTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const { template_name, description, definition_id, is_active, steps } = req.body;
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
let result: any;
|
||||
await transaction(async (client) => {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); }
|
||||
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
|
||||
if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); }
|
||||
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
|
||||
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
|
||||
params.push(userId, id, companyCode);
|
||||
|
||||
const { rows } = await client.query(
|
||||
`UPDATE approval_line_templates SET ${fields.join(", ")}
|
||||
WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
|
||||
params
|
||||
);
|
||||
result = rows[0];
|
||||
|
||||
// 단계 재등록 (steps 배열이 주어진 경우 전체 교체)
|
||||
if (Array.isArray(steps)) {
|
||||
await client.query(
|
||||
"DELETE FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
for (const step of steps) {
|
||||
await client.query(
|
||||
`INSERT INTO approval_line_template_steps
|
||||
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[id, step.step_order, step.approver_type || "user", step.approver_user_id || null,
|
||||
step.approver_position || null, step.approver_dept_code || null, step.approver_label || null, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result, message: "결재선 템플릿이 수정되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 삭제
|
||||
static async deleteTemplate(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const existing = await queryOne<any>(
|
||||
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
await query<any>(
|
||||
"DELETE FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, message: "결재선 템플릿이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재선 템플릿 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재선 템플릿 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재 요청 (Approval Requests) CRUD
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalRequestController {
|
||||
// 결재 요청 목록 조회
|
||||
static async getRequests(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
|
||||
|
||||
const conditions: string[] = ["r.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`r.status = $${idx++}`);
|
||||
params.push(status);
|
||||
}
|
||||
if (target_table) {
|
||||
conditions.push(`r.target_table = $${idx++}`);
|
||||
params.push(target_table);
|
||||
}
|
||||
if (target_record_id) {
|
||||
conditions.push(`r.target_record_id = $${idx++}`);
|
||||
params.push(target_record_id);
|
||||
}
|
||||
if (requester_id) {
|
||||
conditions.push(`r.requester_id = $${idx++}`);
|
||||
params.push(requester_id);
|
||||
}
|
||||
|
||||
// 내 결재 대기 목록: 현재 사용자가 결재자인 라인만 조회
|
||||
if (my_approvals === "true") {
|
||||
conditions.push(
|
||||
`EXISTS (SELECT 1 FROM approval_lines l WHERE l.request_id = r.request_id AND l.approver_id = $${idx++} AND l.status = 'pending' AND l.company_code = r.company_code)`
|
||||
);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
params.push(parseInt(limit as string), offset);
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT r.*, d.definition_name
|
||||
FROM approval_requests r
|
||||
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||
params
|
||||
);
|
||||
|
||||
// 전체 건수 조회
|
||||
const countParams = params.slice(0, params.length - 2);
|
||||
const [countRow] = await query<any>(
|
||||
`SELECT COUNT(*) as total FROM approval_requests r
|
||||
WHERE ${conditions.join(" AND ")}`,
|
||||
countParams
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: rows,
|
||||
total: parseInt(countRow?.total || "0"),
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("결재 요청 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 요청 상세 조회 (라인 포함)
|
||||
static async getRequest(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const request = await queryOne<any>(
|
||||
`SELECT r.*, d.definition_name
|
||||
FROM approval_requests r
|
||||
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
|
||||
WHERE r.request_id = $1 AND r.company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const lines = await query<any>(
|
||||
"SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: { ...request, lines } });
|
||||
} catch (error) {
|
||||
console.error("결재 요청 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 요청 생성 (결재 라인 자동 생성)
|
||||
static async createRequest(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const {
|
||||
title, description, definition_id, target_table, target_record_id,
|
||||
target_record_data, screen_id, button_component_id,
|
||||
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
|
||||
approval_mode, // "sequential" | "parallel"
|
||||
} = req.body;
|
||||
|
||||
if (!title || !target_table) {
|
||||
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
|
||||
}
|
||||
|
||||
if (!Array.isArray(approvers) || approvers.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." });
|
||||
}
|
||||
|
||||
const userId = req.user?.userId || "system";
|
||||
const userName = req.user?.userName || "";
|
||||
const deptName = req.user?.deptName || "";
|
||||
|
||||
const isParallel = approval_mode === "parallel";
|
||||
const totalSteps = approvers.length;
|
||||
|
||||
// approval_mode를 target_record_data에 병합 저장
|
||||
const mergedRecordData = {
|
||||
...(target_record_data || {}),
|
||||
approval_mode: approval_mode || "sequential",
|
||||
};
|
||||
|
||||
let result: any;
|
||||
await transaction(async (client) => {
|
||||
// 결재 요청 생성
|
||||
const { rows: reqRows } = await client.query(
|
||||
`INSERT INTO approval_requests (
|
||||
title, description, definition_id, target_table, target_record_id,
|
||||
target_record_data, status, current_step, total_steps,
|
||||
requester_id, requester_name, requester_dept,
|
||||
screen_id, button_component_id, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
title, description, definition_id, target_table, target_record_id || null,
|
||||
JSON.stringify(mergedRecordData),
|
||||
totalSteps,
|
||||
userId, userName, deptName,
|
||||
screen_id, button_component_id, companyCode,
|
||||
]
|
||||
);
|
||||
result = reqRows[0];
|
||||
|
||||
// 결재 라인 생성
|
||||
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
|
||||
for (let i = 0; i < approvers.length; i++) {
|
||||
const approver = approvers[i];
|
||||
const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting");
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO approval_lines (
|
||||
request_id, step_order, approver_id, approver_name, approver_position,
|
||||
approver_dept, approver_label, status, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
result.request_id,
|
||||
i + 1,
|
||||
approver.approver_id,
|
||||
approver.approver_name || null,
|
||||
approver.approver_position || null,
|
||||
approver.approver_dept || null,
|
||||
approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`),
|
||||
lineStatus,
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 상태를 in_progress로 업데이트
|
||||
await client.query(
|
||||
"UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1",
|
||||
[result.request_id]
|
||||
);
|
||||
result.status = "in_progress";
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 요청 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 요청 회수 (cancel)
|
||||
static async cancelRequest(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const request = await queryOne<any>(
|
||||
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
if (request.requester_id !== userId) {
|
||||
return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." });
|
||||
}
|
||||
|
||||
if (!["requested", "in_progress"].includes(request.status)) {
|
||||
return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." });
|
||||
}
|
||||
|
||||
await query<any>(
|
||||
"UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, message: "결재 요청이 회수되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 요청 회수 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 요청 회수 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 결재 라인 처리 (Approval Lines - 승인/반려)
|
||||
// ============================================================
|
||||
|
||||
export class ApprovalLineController {
|
||||
// 결재 처리 (승인/반려)
|
||||
static async processApproval(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { lineId } = req.params;
|
||||
const { action, comment } = req.body; // action: 'approved' | 'rejected'
|
||||
|
||||
if (!["approved", "rejected"].includes(action)) {
|
||||
return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." });
|
||||
}
|
||||
|
||||
const line = await queryOne<any>(
|
||||
"SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2",
|
||||
[lineId, companyCode]
|
||||
);
|
||||
|
||||
if (!line) {
|
||||
return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
if (line.approver_id !== userId) {
|
||||
return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." });
|
||||
}
|
||||
|
||||
if (line.status !== "pending") {
|
||||
return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." });
|
||||
}
|
||||
|
||||
await transaction(async (client) => {
|
||||
// 현재 라인 처리
|
||||
await client.query(
|
||||
`UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW()
|
||||
WHERE line_id = $3`,
|
||||
[action, comment || null, lineId]
|
||||
);
|
||||
|
||||
const { rows: reqRows } = await client.query(
|
||||
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
|
||||
[line.request_id, companyCode]
|
||||
);
|
||||
const request = reqRows[0];
|
||||
|
||||
if (!request) return;
|
||||
|
||||
if (action === "rejected") {
|
||||
// 반려: 전체 요청 반려 처리
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2,
|
||||
completed_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = $3`,
|
||||
[userId, comment || null, line.request_id]
|
||||
);
|
||||
// 남은 pending/waiting 라인도 skipped 처리
|
||||
await client.query(
|
||||
`UPDATE approval_lines SET status = 'skipped'
|
||||
WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`,
|
||||
[line.request_id, lineId]
|
||||
);
|
||||
} else {
|
||||
// 승인: 동시결재 vs 다단결재 분기
|
||||
const recordData = request.target_record_data;
|
||||
const isParallelMode = recordData?.approval_mode === "parallel";
|
||||
|
||||
if (isParallelMode) {
|
||||
// 동시결재: 남은 pending 라인이 있는지 확인
|
||||
const { rows: remainingLines } = await client.query(
|
||||
`SELECT COUNT(*) as cnt FROM approval_lines
|
||||
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`,
|
||||
[line.request_id, lineId, companyCode]
|
||||
);
|
||||
const remaining = parseInt(remainingLines[0]?.cnt || "0");
|
||||
|
||||
if (remaining === 0) {
|
||||
// 모든 동시 결재자 승인 완료 → 최종 승인
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||
completed_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = $3`,
|
||||
[userId, comment || null, line.request_id]
|
||||
);
|
||||
}
|
||||
// 아직 남은 결재자 있으면 대기 (상태 변경 없음)
|
||||
} else {
|
||||
// 다단결재: 다음 단계 활성화 또는 최종 완료
|
||||
const nextStep = line.step_order + 1;
|
||||
|
||||
if (nextStep <= request.total_steps) {
|
||||
await client.query(
|
||||
`UPDATE approval_lines SET status = 'pending'
|
||||
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
|
||||
[line.request_id, nextStep, companyCode]
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`,
|
||||
[nextStep, line.request_id]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||
completed_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = $3`,
|
||||
[userId, comment || null, line.request_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("결재 처리 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "결재 처리 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 내 결재 대기 목록 조회
|
||||
static async getMyPendingLines(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const rows = await query<any>(
|
||||
`SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at
|
||||
FROM approval_lines l
|
||||
JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code
|
||||
WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2
|
||||
ORDER BY r.created_at ASC`,
|
||||
[userId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
console.error("내 결재 대기 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "내 결재 대기 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
import { query } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export const getAuditLogs = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const isSuperAdmin = userCompanyCode === "*";
|
||||
|
||||
const {
|
||||
companyCode,
|
||||
userId,
|
||||
resourceType,
|
||||
action,
|
||||
tableName,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const result = await auditLogService.queryLogs(
|
||||
{
|
||||
companyCode: (companyCode as string) || (isSuperAdmin ? undefined : userCompanyCode),
|
||||
userId: userId as string,
|
||||
resourceType: resourceType as string,
|
||||
action: action as string,
|
||||
tableName: tableName as string,
|
||||
dateFrom: dateFrom as string,
|
||||
dateTo: dateTo as string,
|
||||
search: search as string,
|
||||
page: page ? parseInt(page as string, 10) : 1,
|
||||
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||
},
|
||||
isSuperAdmin
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: page ? parseInt(page as string, 10) : 1,
|
||||
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "감사 로그 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getAuditLogStats = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const isSuperAdmin = userCompanyCode === "*";
|
||||
const { companyCode, days } = req.query;
|
||||
|
||||
const targetCompany = isSuperAdmin
|
||||
? (companyCode as string) || undefined
|
||||
: userCompanyCode;
|
||||
|
||||
const stats = await auditLogService.getStats(
|
||||
targetCompany,
|
||||
days ? parseInt(days as string, 10) : 30
|
||||
);
|
||||
|
||||
res.json({ success: true, data: stats });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 통계 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "감사 로그 통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getAuditLogUsers = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const isSuperAdmin = userCompanyCode === "*";
|
||||
const { companyCode } = req.query;
|
||||
|
||||
const conditions: string[] = ["LOWER(u.status) = 'active'"];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
conditions.push(`u.company_code = $${paramIndex++}`);
|
||||
params.push(userCompanyCode);
|
||||
} else if (companyCode) {
|
||||
conditions.push(`u.company_code = $${paramIndex++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
conditions.push(`u.company_code != '*'`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const users = await query<{ user_id: string; user_name: string; count: number }>(
|
||||
`SELECT
|
||||
u.user_id,
|
||||
u.user_name,
|
||||
COALESCE(sal.log_count, 0)::int as count
|
||||
FROM user_info u
|
||||
LEFT JOIN (
|
||||
SELECT user_id, COUNT(*) as log_count
|
||||
FROM system_audit_log
|
||||
GROUP BY user_id
|
||||
) sal ON u.user_id = sal.user_id
|
||||
${whereClause}
|
||||
ORDER BY count DESC, u.user_name ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: users });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 사용자 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사용자 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -205,6 +205,31 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
* GET /api/category-tree/test/value/:valueId/can-delete
|
||||
*/
|
||||
router.get("/test/value/:valueId/can-delete", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const result = await categoryTreeService.checkCanDelete(companyCode, Number(valueId));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 삭제 가능 여부 확인 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제
|
||||
* DELETE /api/category-tree/test/value/:valueId
|
||||
@@ -229,6 +254,16 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
|
||||
if (err.message.startsWith("VALIDATION:")) {
|
||||
const validationMessage = err.message.replace("VALIDATION:", "");
|
||||
logger.warn("카테고리 값 삭제 검증 실패", { valueId: req.params.valueId, reason: validationMessage });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: validationMessage,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error("카테고리 값 삭제 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../services/commonCodeService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
export class CommonCodeController {
|
||||
private commonCodeService: CommonCodeService;
|
||||
@@ -163,6 +164,18 @@ export class CommonCodeController {
|
||||
Number(menuObjid)
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: userId || "",
|
||||
action: "CREATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: category?.category_code,
|
||||
resourceName: category?.category_name || categoryData.categoryName,
|
||||
summary: `코드 카테고리 "${category?.category_name || categoryData.categoryName}" 생성`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: category,
|
||||
@@ -208,6 +221,18 @@ export class CommonCodeController {
|
||||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: categoryCode,
|
||||
resourceName: category?.category_name,
|
||||
summary: `코드 카테고리 "${categoryCode}" 수정`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: category,
|
||||
@@ -245,6 +270,17 @@ export class CommonCodeController {
|
||||
|
||||
await this.commonCodeService.deleteCategory(categoryCode, companyCode);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: categoryCode,
|
||||
summary: `코드 카테고리 "${categoryCode}" 삭제`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "카테고리 삭제 성공",
|
||||
@@ -303,6 +339,18 @@ export class CommonCodeController {
|
||||
effectiveMenuObjid
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: userId || "",
|
||||
action: "CREATE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeData.codeValue,
|
||||
resourceName: codeData.codeName,
|
||||
summary: `코드 "${codeData.codeName}" (${categoryCode}) 생성`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: code,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DDLExecutionService } from "../services/ddlExecutionService";
|
||||
import { DDLAuditLogger } from "../services/ddlAuditLogger";
|
||||
import { CreateTableRequest, AddColumnRequest } from "../types/ddl";
|
||||
import { logger } from "../utils/logger";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
export class DDLController {
|
||||
/**
|
||||
@@ -59,6 +60,20 @@ export class DDLController {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId,
|
||||
action: "CREATE",
|
||||
resourceType: "TABLE",
|
||||
resourceId: tableName,
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `테이블 "${tableName}" 생성 (${columns.length}개 컬럼)`,
|
||||
changes: { after: { tableName, columnCount: columns.length, description } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별)
|
||||
@@ -170,6 +171,21 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response)
|
||||
|
||||
logger.info("부서 생성 성공", { deptCode, dept_name });
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "CREATE",
|
||||
resourceType: "DATA",
|
||||
resourceId: deptCode,
|
||||
resourceName: dept_name.trim(),
|
||||
tableName: "dept_info",
|
||||
summary: `부서 "${dept_name.trim()}" 생성`,
|
||||
changes: { after: { deptCode, deptName: dept_name.trim(), parentDeptCode: parent_dept_code } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "부서가 생성되었습니다.",
|
||||
@@ -219,6 +235,21 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response)
|
||||
|
||||
logger.info("부서 수정 성공", { deptCode });
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "DATA",
|
||||
resourceId: deptCode,
|
||||
resourceName: dept_name.trim(),
|
||||
tableName: "dept_info",
|
||||
summary: `부서 "${dept_name.trim()}" 수정`,
|
||||
changes: { after: { deptName: dept_name.trim(), parentDeptCode: parent_dept_code } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "부서가 수정되었습니다.",
|
||||
@@ -285,6 +316,21 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response)
|
||||
deletedMemberCount: memberCount
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "DATA",
|
||||
resourceId: deptCode,
|
||||
resourceName: result[0].dept_name,
|
||||
tableName: "dept_info",
|
||||
summary: `부서 "${result[0].dept_name}" 삭제${memberCount > 0 ? ` (부서원 ${memberCount}명 제외)` : ""}`,
|
||||
changes: { before: { deptCode, deptName: result[0].dept_name } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: memberCount > 0
|
||||
|
||||
@@ -9,6 +9,8 @@ import { FlowStepService } from "../services/flowStepService";
|
||||
import { FlowConnectionService } from "../services/flowConnectionService";
|
||||
import { FlowExecutionService } from "../services/flowExecutionService";
|
||||
import { FlowDataMoveService } from "../services/flowDataMoveService";
|
||||
import { FlowProcedureService } from "../services/flowProcedureService";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
export class FlowController {
|
||||
private flowDefinitionService: FlowDefinitionService;
|
||||
@@ -16,6 +18,7 @@ export class FlowController {
|
||||
private flowConnectionService: FlowConnectionService;
|
||||
private flowExecutionService: FlowExecutionService;
|
||||
private flowDataMoveService: FlowDataMoveService;
|
||||
private flowProcedureService: FlowProcedureService;
|
||||
|
||||
constructor() {
|
||||
this.flowDefinitionService = new FlowDefinitionService();
|
||||
@@ -23,6 +26,7 @@ export class FlowController {
|
||||
this.flowConnectionService = new FlowConnectionService();
|
||||
this.flowExecutionService = new FlowExecutionService();
|
||||
this.flowDataMoveService = new FlowDataMoveService();
|
||||
this.flowProcedureService = new FlowProcedureService();
|
||||
}
|
||||
|
||||
// ==================== 플로우 정의 ====================
|
||||
@@ -83,12 +87,25 @@ export class FlowController {
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
||||
restApiConnections: req.body.restApiConnections,
|
||||
},
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: userId || "",
|
||||
action: "CREATE",
|
||||
resourceType: "FLOW",
|
||||
resourceId: String(flowDef?.id || ""),
|
||||
resourceName: flowDef?.name || name,
|
||||
summary: `플로우 "${flowDef?.name || name}" 생성`,
|
||||
changes: { after: { name, tableName } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flowDef,
|
||||
@@ -144,8 +161,9 @@ export class FlowController {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
const definition = await this.flowDefinitionService.findById(flowId);
|
||||
const definition = await this.flowDefinitionService.findById(flowId, userCompanyCode);
|
||||
if (!definition) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -182,12 +200,14 @@ export class FlowController {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
const { name, description, isActive } = req.body;
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
const beforeFlow = await this.flowDefinitionService.findById(flowId);
|
||||
const flowDef = await this.flowDefinitionService.update(flowId, {
|
||||
name,
|
||||
description,
|
||||
isActive,
|
||||
});
|
||||
}, userCompanyCode);
|
||||
|
||||
if (!flowDef) {
|
||||
res.status(404).json({
|
||||
@@ -197,6 +217,22 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: (req as any).user?.userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "FLOW",
|
||||
resourceId: String(flowId),
|
||||
resourceName: flowDef?.name || name,
|
||||
summary: `플로우 "${flowDef?.name || name}" 수정`,
|
||||
changes: {
|
||||
before: { name: beforeFlow?.name, description: beforeFlow?.description, isActive: beforeFlow?.isActive },
|
||||
after: { name, description, isActive },
|
||||
},
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: flowDef,
|
||||
@@ -217,8 +253,9 @@ export class FlowController {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const flowId = parseInt(id);
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
const success = await this.flowDefinitionService.delete(flowId);
|
||||
const success = await this.flowDefinitionService.delete(flowId, userCompanyCode);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
@@ -228,6 +265,17 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: (req as any).user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "FLOW",
|
||||
resourceId: String(flowId),
|
||||
summary: `플로우(ID:${flowId}) 삭제`,
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Flow definition deleted successfully",
|
||||
@@ -275,6 +323,7 @@ export class FlowController {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const flowDefinitionId = parseInt(flowId);
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
const {
|
||||
stepName,
|
||||
stepOrder,
|
||||
@@ -293,6 +342,16 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
// 플로우 소유권 검증
|
||||
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
|
||||
if (!flowDef) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found or access denied",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const step = await this.flowStepService.create({
|
||||
flowDefinitionId,
|
||||
stepName,
|
||||
@@ -304,6 +363,19 @@ export class FlowController {
|
||||
positionY,
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: (req as any).user?.userId || "",
|
||||
action: "CREATE",
|
||||
resourceType: "FLOW_STEP",
|
||||
resourceId: String(step?.id || ""),
|
||||
resourceName: stepName,
|
||||
summary: `플로우 스텝 "${stepName}" 생성 (플로우 ID:${flowDefinitionId})`,
|
||||
changes: { after: { stepName, tableName, stepOrder } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: step,
|
||||
@@ -324,6 +396,7 @@ export class FlowController {
|
||||
try {
|
||||
const { stepId } = req.params;
|
||||
const id = parseInt(stepId);
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
const {
|
||||
stepName,
|
||||
stepOrder,
|
||||
@@ -342,6 +415,20 @@ export class FlowController {
|
||||
displayConfig,
|
||||
} = req.body;
|
||||
|
||||
// 스텝 소유권 검증: 스텝이 속한 플로우가 사용자 회사 소유인지 확인
|
||||
const existingStep = await this.flowStepService.findById(id);
|
||||
if (existingStep) {
|
||||
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
|
||||
if (!flowDef) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "Access denied: flow does not belong to your company",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const beforeStep = existingStep;
|
||||
const step = await this.flowStepService.update(id, {
|
||||
stepName,
|
||||
stepOrder,
|
||||
@@ -368,6 +455,22 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: (req as any).user?.userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "FLOW_STEP",
|
||||
resourceId: String(id),
|
||||
resourceName: step?.stepName || stepName,
|
||||
summary: `플로우 스텝 "${step?.stepName || stepName}" 수정`,
|
||||
changes: {
|
||||
before: { stepName: beforeStep?.stepName, tableName: beforeStep?.tableName, stepOrder: beforeStep?.stepOrder },
|
||||
after: { stepName, tableName, stepOrder },
|
||||
},
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: step,
|
||||
@@ -388,6 +491,20 @@ export class FlowController {
|
||||
try {
|
||||
const { stepId } = req.params;
|
||||
const id = parseInt(stepId);
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
// 스텝 소유권 검증
|
||||
const existingStep = await this.flowStepService.findById(id);
|
||||
if (existingStep) {
|
||||
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
|
||||
if (!flowDef) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "Access denied: flow does not belong to your company",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const success = await this.flowStepService.delete(id);
|
||||
|
||||
@@ -399,6 +516,18 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: (req as any).user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "FLOW_STEP",
|
||||
resourceId: String(id),
|
||||
resourceName: existingStep?.stepName,
|
||||
summary: `플로우 스텝 "${existingStep?.stepName || id}" 삭제`,
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Flow step deleted successfully",
|
||||
@@ -446,6 +575,7 @@ export class FlowController {
|
||||
createConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
if (!flowDefinitionId || !fromStepId || !toStepId) {
|
||||
res.status(400).json({
|
||||
@@ -455,6 +585,28 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
// 플로우 소유권 검증
|
||||
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
|
||||
if (!flowDef) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found or access denied",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// fromStepId, toStepId가 해당 flow에 속하는지 검증
|
||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||
const toStep = await this.flowStepService.findById(toStepId);
|
||||
if (!fromStep || fromStep.flowDefinitionId !== flowDefinitionId ||
|
||||
!toStep || toStep.flowDefinitionId !== flowDefinitionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "fromStepId and toStepId must belong to the specified flow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await this.flowConnectionService.create({
|
||||
flowDefinitionId,
|
||||
fromStepId,
|
||||
@@ -462,6 +614,19 @@ export class FlowController {
|
||||
label,
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: (req as any).user?.userId || "",
|
||||
action: "CREATE",
|
||||
resourceType: "FLOW",
|
||||
resourceId: String(flowDefinitionId),
|
||||
resourceName: flowDef?.name || "",
|
||||
summary: `플로우 "${flowDef?.name}" 연결 생성 (${fromStep?.stepName} → ${toStep?.stepName})`,
|
||||
changes: { after: { fromStepId, toStepId, label } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: connection,
|
||||
@@ -482,6 +647,20 @@ export class FlowController {
|
||||
try {
|
||||
const { connectionId } = req.params;
|
||||
const id = parseInt(connectionId);
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
// 연결 소유권 검증
|
||||
const existingConn = await this.flowConnectionService.findById(id);
|
||||
if (existingConn) {
|
||||
const flowDef = await this.flowDefinitionService.findById(existingConn.flowDefinitionId, userCompanyCode);
|
||||
if (!flowDef) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "Access denied: flow does not belong to your company",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const success = await this.flowConnectionService.delete(id);
|
||||
|
||||
@@ -493,6 +672,18 @@ export class FlowController {
|
||||
return;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId: (req as any).user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "FLOW",
|
||||
resourceId: String(existingConn?.flowDefinitionId || id),
|
||||
summary: `플로우 연결 삭제 (ID: ${id})`,
|
||||
changes: { before: { connectionId: id } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Connection deleted successfully",
|
||||
@@ -670,23 +861,24 @@ export class FlowController {
|
||||
*/
|
||||
moveData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, recordId, toStepId, note } = req.body;
|
||||
const { flowId, fromStepId, recordId, toStepId, note } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
if (!flowId || !recordId || !toStepId) {
|
||||
if (!flowId || !fromStepId || !recordId || !toStepId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowId, recordId, and toStepId are required",
|
||||
message: "flowId, fromStepId, recordId, and toStepId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.flowDataMoveService.moveDataToStep(
|
||||
flowId,
|
||||
recordId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
recordId,
|
||||
userId,
|
||||
note
|
||||
note ? { note } : undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -856,4 +1048,94 @@ export class FlowController {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 프로시저/함수 ====================
|
||||
|
||||
/**
|
||||
* 프로시저/함수 목록 조회
|
||||
*/
|
||||
listProcedures = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dbSource = (req.query.dbSource as string) || "internal";
|
||||
const connectionId = req.query.connectionId
|
||||
? parseInt(req.query.connectionId as string)
|
||||
: undefined;
|
||||
const schema = req.query.schema as string | undefined;
|
||||
|
||||
if (dbSource !== "internal" && dbSource !== "external") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "dbSource는 internal 또는 external이어야 합니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbSource === "external" && !connectionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "외부 DB 조회 시 connectionId가 필요합니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const procedures = await this.flowProcedureService.listProcedures(
|
||||
dbSource,
|
||||
connectionId,
|
||||
schema
|
||||
);
|
||||
|
||||
res.json({ success: true, data: procedures });
|
||||
} catch (error: any) {
|
||||
console.error("프로시저 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "프로시저 목록 조회에 실패했습니다",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 프로시저/함수 파라미터 조회
|
||||
*/
|
||||
getProcedureParameters = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const dbSource = (req.query.dbSource as string) || "internal";
|
||||
const connectionId = req.query.connectionId
|
||||
? parseInt(req.query.connectionId as string)
|
||||
: undefined;
|
||||
const schema = req.query.schema as string | undefined;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "프로시저 이름이 필요합니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbSource !== "internal" && dbSource !== "external") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "dbSource는 internal 또는 external이어야 합니다",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parameters = await this.flowProcedureService.getProcedureParameters(
|
||||
name,
|
||||
dbSource as "internal" | "external",
|
||||
connectionId,
|
||||
schema
|
||||
);
|
||||
|
||||
res.json({ success: true, data: parameters });
|
||||
} catch (error: any) {
|
||||
console.error("프로시저 파라미터 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "프로시저 파라미터 조회에 실패했습니다",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../middleware/authMiddleware";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -189,6 +190,19 @@ router.post(
|
||||
menuObjid: newRule.menuObjid,
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
action: "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(newRule.ruleId),
|
||||
resourceName: ruleConfig.ruleName,
|
||||
summary: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||
changes: { after: { ruleName: ruleConfig.ruleName, prefix: ruleConfig.prefix } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: newRule });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
@@ -218,12 +232,29 @@ router.put(
|
||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||
|
||||
try {
|
||||
const beforeRule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||
const updatedRule = await numberingRuleService.updateRule(
|
||||
ruleId,
|
||||
updates,
|
||||
companyCode
|
||||
);
|
||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
summary: `채번 규칙(ID:${ruleId}) 수정`,
|
||||
changes: {
|
||||
before: { ruleName: beforeRule?.ruleName, separator: beforeRule?.separator },
|
||||
after: updates,
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
@@ -250,6 +281,18 @@ router.delete(
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isCompanyAdmin,
|
||||
canAccessCompanyData,
|
||||
} from "../utils/permissionUtils";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
/**
|
||||
* 권한 그룹 목록 조회
|
||||
@@ -179,6 +180,20 @@ export const createRoleGroup = async (
|
||||
data: roleGroup,
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "CREATE",
|
||||
resourceType: "ROLE",
|
||||
resourceId: String(roleGroup?.objid || ""),
|
||||
resourceName: authName,
|
||||
summary: `권한 그룹 "${authName}" 생성`,
|
||||
changes: { after: { authName, authCode, companyCode } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 생성 실패", { error });
|
||||
@@ -243,6 +258,23 @@ export const updateRoleGroup = async (
|
||||
data: roleGroup,
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "ROLE",
|
||||
resourceId: String(objid),
|
||||
resourceName: authName,
|
||||
summary: `권한 그룹 "${authName}" 수정`,
|
||||
changes: {
|
||||
before: { authName: existingRoleGroup.authName, authCode: existingRoleGroup.authCode, status: existingRoleGroup.status },
|
||||
after: { authName, authCode, status },
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 수정 실패", { error });
|
||||
@@ -302,6 +334,19 @@ export const deleteRoleGroup = async (
|
||||
data: null,
|
||||
};
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: existingRoleGroup.companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "ROLE",
|
||||
resourceId: String(objid),
|
||||
resourceName: existingRoleGroup.authName,
|
||||
summary: `권한 그룹 "${existingRoleGroup.authName}" 삭제`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("권한 그룹 삭제 실패", { error });
|
||||
|
||||
@@ -313,6 +313,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const deleteNumberingRules = req.query.deleteNumberingRules === "true";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
await client.query('BEGIN');
|
||||
@@ -385,31 +386,29 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
||||
});
|
||||
}
|
||||
|
||||
// 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]
|
||||
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 + 사용자가 명시적으로 요청한 경우에만)
|
||||
if (deleteNumberingRules) {
|
||||
const isRootGroup = await client.query(
|
||||
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
|
||||
[id]
|
||||
);
|
||||
// 규칙 삭제
|
||||
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
|
||||
});
|
||||
|
||||
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.warn("최상위 그룹 삭제 시 채번 규칙 삭제 (사용자 명시 요청)", {
|
||||
companyCode: targetCompanyCode,
|
||||
deletedCount: deletedRules.rowCount
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Response } from "express";
|
||||
import { screenManagementService } from "../services/screenManagementService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
// 화면 목록 조회
|
||||
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
||||
@@ -109,6 +110,21 @@ export const createScreen = async (
|
||||
screenData,
|
||||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: (req.user as any)?.userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "CREATE",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: String(newScreen?.screenId || ""),
|
||||
resourceName: newScreen?.screenName || screenData.screenName,
|
||||
summary: `화면 "${newScreen?.screenName || screenData.screenName}" 생성`,
|
||||
changes: { after: { screenName: newScreen?.screenName, tableName: newScreen?.tableName } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: newScreen });
|
||||
} catch (error) {
|
||||
console.error("화면 생성 실패:", error);
|
||||
@@ -126,12 +142,31 @@ export const updateScreen = async (
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const beforeScreen = await screenManagementService.getScreenById(parseInt(id));
|
||||
const updateData = { ...req.body, companyCode };
|
||||
const updatedScreen = await screenManagementService.updateScreen(
|
||||
parseInt(id),
|
||||
updateData,
|
||||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: (req.user as any)?.userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: id,
|
||||
resourceName: updatedScreen?.screenName || updateData.screenName,
|
||||
summary: `화면 "${updatedScreen?.screenName || updateData.screenName}" 수정`,
|
||||
changes: {
|
||||
before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, isActive: beforeScreen?.isActive },
|
||||
after: { screenName: updateData.screenName, tableName: updateData.tableName, isActive: updateData.isActive },
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: updatedScreen });
|
||||
} catch (error) {
|
||||
console.error("화면 수정 실패:", error);
|
||||
@@ -141,6 +176,33 @@ export const updateScreen = async (
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 테이블명 변경
|
||||
export const updateScreenTableName = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { tableName } = req.body;
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
|
||||
}
|
||||
|
||||
await screenManagementService.updateScreenTableName(
|
||||
parseInt(screenId),
|
||||
tableName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "테이블명이 변경되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("테이블명 변경 실패:", error);
|
||||
res.status(500).json({ success: false, message: "테이블명 변경에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 정보 수정 (메타데이터만)
|
||||
export const updateScreenInfo = async (
|
||||
req: AuthenticatedRequest,
|
||||
@@ -171,6 +233,8 @@ export const updateScreenInfo = async (
|
||||
restApiJsonPath,
|
||||
});
|
||||
|
||||
const beforeScreen = await screenManagementService.getScreenById(parseInt(id));
|
||||
|
||||
await screenManagementService.updateScreenInfo(
|
||||
parseInt(id),
|
||||
{
|
||||
@@ -187,6 +251,24 @@ export const updateScreenInfo = async (
|
||||
},
|
||||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: (req.user as any)?.userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: id,
|
||||
resourceName: screenName,
|
||||
summary: `화면 "${screenName}" 정보 수정`,
|
||||
changes: {
|
||||
before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, description: beforeScreen?.description, isActive: beforeScreen?.isActive },
|
||||
after: { screenName, tableName, description, isActive },
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("화면 정보 수정 실패:", error);
|
||||
@@ -228,6 +310,9 @@ export const deleteScreen = async (
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { deleteReason, force } = req.body;
|
||||
|
||||
const screenInfo = await screenManagementService.getScreenById(parseInt(id));
|
||||
const screenName = screenInfo?.screenName || "";
|
||||
|
||||
await screenManagementService.deleteScreen(
|
||||
parseInt(id),
|
||||
companyCode,
|
||||
@@ -235,6 +320,21 @@ export const deleteScreen = async (
|
||||
deleteReason,
|
||||
force || false
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: id,
|
||||
resourceName: screenName,
|
||||
summary: `화면(ID:${id}, ${screenName}) 삭제 (사유: ${deleteReason || "없음"})`,
|
||||
changes: { before: { deleteReason, force } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "화면이 휴지통으로 이동되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("화면 삭제 실패:", error);
|
||||
@@ -514,6 +614,20 @@ export const copyScreenWithModals = async (
|
||||
modalScreens: modalScreens || [],
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: targetCompanyCode || companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: id,
|
||||
resourceName: mainScreen?.screenName,
|
||||
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
@@ -549,6 +663,20 @@ export const copyScreen = async (
|
||||
}
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: String(copiedScreen?.screenId || ""),
|
||||
resourceName: screenName,
|
||||
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, screenName, screenCode } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: copiedScreen,
|
||||
@@ -648,6 +776,21 @@ export const saveLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||
layoutData,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const screenInfo = await screenManagementService.getScreenById(parseInt(screenId));
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: (req.user as any)?.userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "SCREEN_LAYOUT",
|
||||
resourceId: screenId,
|
||||
resourceName: screenInfo?.screenName || "",
|
||||
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) 레이아웃 저장`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: savedLayout });
|
||||
} catch (error) {
|
||||
console.error("레이아웃 저장 실패:", error);
|
||||
@@ -724,6 +867,21 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
|
||||
layoutData,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const screenInfo = await screenManagementService.getScreenById(parseInt(screenId));
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: (req.user as any)?.userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "SCREEN_LAYOUT",
|
||||
resourceId: screenId,
|
||||
resourceName: screenInfo?.screenName || "",
|
||||
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) V2 레이아웃 저장`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "V2 레이아웃이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("V2 레이아웃 저장 실패:", error);
|
||||
@@ -896,6 +1054,21 @@ export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) =>
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
const screenInfo = await screenManagementService.getScreenById(parseInt(screenId));
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "SCREEN_LAYOUT",
|
||||
resourceId: screenId,
|
||||
resourceName: screenInfo?.screenName || "",
|
||||
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) POP 레이아웃 저장`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 저장 실패:", error);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ColumnListResponse,
|
||||
ColumnSettingsResponse,
|
||||
} from "../types/tableManagement";
|
||||
import { query } from "../database/db"; // 🆕 query 함수 import
|
||||
import { query } from "../database/db";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
/**
|
||||
* 테이블 목록 조회
|
||||
@@ -962,6 +963,21 @@ export async function addTableData(
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "CREATE",
|
||||
resourceType: "DATA",
|
||||
resourceId: result.insertedId || "",
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `${tableName} 데이터 추가`,
|
||||
changes: { after: data },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
const response: ApiResponse<{ id: string | null }> = {
|
||||
success: true,
|
||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||
@@ -1080,6 +1096,16 @@ export async function editTableData(
|
||||
return;
|
||||
}
|
||||
|
||||
// 변경된 필드만 추출
|
||||
const changedBefore: Record<string, any> = {};
|
||||
const changedAfter: Record<string, any> = {};
|
||||
for (const key of Object.keys(updatedData)) {
|
||||
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||
changedBefore[key] = originalData[key];
|
||||
changedAfter[key] = updatedData[key];
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 수정
|
||||
await tableManagementService.editTableData(
|
||||
tableName,
|
||||
@@ -1089,6 +1115,23 @@ export async function editTableData(
|
||||
|
||||
logger.info(`테이블 데이터 수정 완료: ${tableName}`);
|
||||
|
||||
if (Object.keys(changedAfter).length > 0) {
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "DATA",
|
||||
resourceId: originalData.id?.toString() || "",
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `${tableName} 데이터 수정`,
|
||||
changes: { before: changedBefore, after: changedAfter },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "테이블 데이터를 성공적으로 수정했습니다.",
|
||||
@@ -1406,6 +1449,22 @@ export async function deleteTableData(
|
||||
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
|
||||
);
|
||||
|
||||
const deleteItems = Array.isArray(data) ? data : [data];
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "DATA",
|
||||
resourceId: deleteItems[0]?.id?.toString() || "",
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `${tableName} 데이터 삭제 (${deletedCount}건)`,
|
||||
changes: { before: { deletedCount, items: deleteItems.length } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
const response: ApiResponse<{ deletedCount: number }> = {
|
||||
success: true,
|
||||
message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`,
|
||||
@@ -2285,6 +2344,21 @@ export async function multiTableSave(
|
||||
subTableResultsCount: subTableResults.length,
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: isUpdate ? "UPDATE" : "CREATE",
|
||||
resourceType: "DATA",
|
||||
resourceId: savedPkValue?.toString() || "",
|
||||
resourceName: mainTableName,
|
||||
tableName: mainTableName,
|
||||
summary: `${mainTableName} 데이터 ${isUpdate ? "수정" : "생성"}${subTableResults.length > 0 ? ` (서브 테이블 ${subTableResults.length}건)` : ""}`,
|
||||
changes: { after: mainData },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "다중 테이블 저장이 완료되었습니다.",
|
||||
|
||||
@@ -2,6 +2,37 @@ import { PostgreSQLService } from "./PostgreSQLService";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* 결재 시스템 테이블 마이그레이션
|
||||
* approval_definitions, approval_line_templates, approval_line_template_steps,
|
||||
* approval_requests, approval_lines 테이블 생성
|
||||
*/
|
||||
export async function runApprovalSystemMigration() {
|
||||
try {
|
||||
console.log("🔄 결재 시스템 마이그레이션 시작...");
|
||||
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/100_create_approval_system.sql"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
|
||||
console.log("✅ 결재 시스템 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 결재 시스템 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 마이그레이션 실행
|
||||
* dashboard_elements 테이블에 custom_title, show_header 컬럼 추가
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import express from "express";
|
||||
import {
|
||||
ApprovalDefinitionController,
|
||||
ApprovalTemplateController,
|
||||
ApprovalRequestController,
|
||||
ApprovalLineController,
|
||||
} from "../controllers/approvalController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ==================== 결재 유형 (Definitions) ====================
|
||||
router.get("/definitions", ApprovalDefinitionController.getDefinitions);
|
||||
router.get("/definitions/:id", ApprovalDefinitionController.getDefinition);
|
||||
router.post("/definitions", ApprovalDefinitionController.createDefinition);
|
||||
router.put("/definitions/:id", ApprovalDefinitionController.updateDefinition);
|
||||
router.delete("/definitions/:id", ApprovalDefinitionController.deleteDefinition);
|
||||
|
||||
// ==================== 결재선 템플릿 (Templates) ====================
|
||||
router.get("/templates", ApprovalTemplateController.getTemplates);
|
||||
router.get("/templates/:id", ApprovalTemplateController.getTemplate);
|
||||
router.post("/templates", ApprovalTemplateController.createTemplate);
|
||||
router.put("/templates/:id", ApprovalTemplateController.updateTemplate);
|
||||
router.delete("/templates/:id", ApprovalTemplateController.deleteTemplate);
|
||||
|
||||
// ==================== 결재 요청 (Requests) ====================
|
||||
router.get("/requests", ApprovalRequestController.getRequests);
|
||||
router.get("/requests/:id", ApprovalRequestController.getRequest);
|
||||
router.post("/requests", ApprovalRequestController.createRequest);
|
||||
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
|
||||
|
||||
// ==================== 결재 라인 처리 (Lines) ====================
|
||||
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
|
||||
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", authenticateToken, getAuditLogs);
|
||||
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||
|
||||
export default router;
|
||||
@@ -2,7 +2,6 @@
|
||||
// Phase 2-1B: 핵심 인증 API 구현
|
||||
|
||||
import { Router } from "express";
|
||||
import { checkAuthStatus } from "../middleware/authMiddleware";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
|
||||
const router = Router();
|
||||
@@ -12,7 +11,7 @@ const router = Router();
|
||||
* 인증 상태 확인 API
|
||||
* 기존 Java ApiLoginController.checkAuthStatus() 포팅
|
||||
*/
|
||||
router.get("/status", checkAuthStatus);
|
||||
router.get("/status", AuthController.checkAuthStatus);
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dataService } from "../services/dataService";
|
||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -736,17 +737,39 @@ router.post(
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
const inserted = result.data?.inserted || 0;
|
||||
const updated = result.data?.updated || 0;
|
||||
const deleted = result.data?.deleted || 0;
|
||||
|
||||
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
|
||||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
deleted: result.data?.deleted || 0,
|
||||
inserted, updated, deleted,
|
||||
});
|
||||
|
||||
const parts: string[] = [];
|
||||
if (inserted > 0) parts.push(`${inserted}건 생성`);
|
||||
if (updated > 0) parts.push(`${updated}건 수정`);
|
||||
if (deleted > 0) parts.push(`${deleted}건 삭제`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: inserted > 0 && updated === 0 && deleted === 0 ? "BATCH_CREATE" : "UPDATE",
|
||||
resourceType: "DATA",
|
||||
tableName,
|
||||
summary: `${tableName} 테이블 배치 처리: ${parts.join(", ")}`,
|
||||
changes: { after: { inserted, updated, deleted } },
|
||||
ipAddress: (req as any).ip,
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "데이터가 저장되었습니다.",
|
||||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
inserted,
|
||||
updated,
|
||||
deleted: result.data?.deleted || 0,
|
||||
savedIds: result.data?.savedIds || [],
|
||||
});
|
||||
@@ -824,6 +847,19 @@ router.post(
|
||||
|
||||
console.log(`✅ 레코드 생성 성공: ${tableName}`);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "CREATE",
|
||||
resourceType: "DATA",
|
||||
resourceId: result.data?.id ? String(result.data.id) : undefined,
|
||||
tableName,
|
||||
summary: `${tableName} 테이블에 데이터 1건 생성`,
|
||||
ipAddress: (req as any).ip,
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
@@ -880,6 +916,20 @@ router.put(
|
||||
|
||||
console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "DATA",
|
||||
resourceId: String(id),
|
||||
tableName,
|
||||
summary: `${tableName} 테이블 데이터 수정 (ID:${id})`,
|
||||
changes: { after: data, fields: Object.keys(data || {}) },
|
||||
ipAddress: (req as any).ip,
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
@@ -940,6 +990,20 @@ router.post(
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 삭제 성공: ${tableName}`);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "DATA",
|
||||
tableName,
|
||||
summary: `${tableName} 테이블 데이터 삭제 (복합키)`,
|
||||
changes: { before: compositeKey },
|
||||
ipAddress: (req as any).ip,
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error(`레코드 삭제 오류 (${req.params.tableName}):`, error);
|
||||
@@ -1032,6 +1096,19 @@ router.delete(
|
||||
|
||||
console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "DATA",
|
||||
resourceId: String(id),
|
||||
tableName,
|
||||
summary: `${tableName} 테이블 데이터 삭제 (ID:${id})`,
|
||||
ipAddress: (req as any).ip,
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레코드가 삭제되었습니다.",
|
||||
|
||||
@@ -50,4 +50,8 @@ router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData
|
||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
||||
|
||||
// ==================== 프로시저/함수 ====================
|
||||
router.get("/procedures", flowController.listProcedures);
|
||||
router.get("/procedures/:name/parameters", flowController.getProcedureParameters);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -12,9 +13,26 @@ function isSafeIdentifier(name: string): boolean {
|
||||
return SAFE_IDENTIFIER.test(name);
|
||||
}
|
||||
|
||||
interface AutoGenMappingInfo {
|
||||
numberingRuleId: string;
|
||||
targetColumn: string;
|
||||
showResultModal?: boolean;
|
||||
}
|
||||
|
||||
interface HiddenMappingInfo {
|
||||
valueSource: "json_extract" | "db_column" | "static";
|
||||
targetColumn: string;
|
||||
staticValue?: string;
|
||||
sourceJsonColumn?: string;
|
||||
sourceJsonKey?: string;
|
||||
sourceDbColumn?: string;
|
||||
}
|
||||
|
||||
interface MappingInfo {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
autoGenMappings?: AutoGenMappingInfo[];
|
||||
hiddenMappings?: HiddenMappingInfo[];
|
||||
}
|
||||
|
||||
interface StatusConditionRule {
|
||||
@@ -44,7 +62,8 @@ interface StatusChangeRuleBody {
|
||||
}
|
||||
|
||||
interface ExecuteActionBody {
|
||||
action: string;
|
||||
action?: string;
|
||||
tasks?: TaskBody[];
|
||||
data: {
|
||||
items?: Record<string, unknown>[];
|
||||
fieldValues?: Record<string, unknown>;
|
||||
@@ -54,6 +73,36 @@ interface ExecuteActionBody {
|
||||
field?: MappingInfo | null;
|
||||
};
|
||||
statusChanges?: StatusChangeRuleBody[];
|
||||
cartChanges?: {
|
||||
toCreate?: Record<string, unknown>[];
|
||||
toUpdate?: Record<string, unknown>[];
|
||||
toDelete?: (string | number)[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TaskBody {
|
||||
id: string;
|
||||
type: string;
|
||||
targetTable?: string;
|
||||
targetColumn?: string;
|
||||
operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional";
|
||||
valueSource?: "fixed" | "linked" | "reference";
|
||||
fixedValue?: string;
|
||||
sourceField?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
referenceJoinKey?: string;
|
||||
conditionalValue?: ConditionalValueRule;
|
||||
// db-conditional 전용 (DB 컬럼 간 비교 후 값 판정)
|
||||
compareColumn?: string;
|
||||
compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||
compareWith?: string;
|
||||
dbThenValue?: string;
|
||||
dbElseValue?: string;
|
||||
lookupMode?: "auto" | "manual";
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
cartScreenId?: string;
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
@@ -96,26 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
|
||||
const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody;
|
||||
const items = data?.items ?? [];
|
||||
const fieldValues = data?.fieldValues ?? {};
|
||||
|
||||
logger.info("[pop/execute-action] 요청", {
|
||||
action,
|
||||
action: action ?? "task-list",
|
||||
companyCode,
|
||||
userId,
|
||||
itemCount: items.length,
|
||||
hasFieldValues: Object.keys(fieldValues).length > 0,
|
||||
hasMappings: !!mappings,
|
||||
statusChangeCount: statusChanges?.length ?? 0,
|
||||
taskCount: tasks?.length ?? 0,
|
||||
hasCartChanges: !!cartChanges,
|
||||
});
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
let processedCount = 0;
|
||||
let insertedCount = 0;
|
||||
let deletedCount = 0;
|
||||
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
|
||||
|
||||
if (action === "inbound-confirm") {
|
||||
// ======== v2: tasks 배열 기반 처리 ========
|
||||
if (tasks && tasks.length > 0) {
|
||||
for (const task of tasks) {
|
||||
switch (task.type) {
|
||||
case "data-save": {
|
||||
// 매핑 기반 INSERT (기존 inbound-confirm INSERT 로직 재사용)
|
||||
const cardMapping = mappings?.cardList;
|
||||
const fieldMapping = mappings?.field;
|
||||
|
||||
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
|
||||
if (!isSafeIdentifier(cardMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(item[sourceField] ?? null);
|
||||
}
|
||||
|
||||
if (fieldMapping?.targetTable === cardMapping.targetTable) {
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
if (columns.includes(`"${targetColumn}"`)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
const allHidden = [
|
||||
...(fieldMapping?.hiddenMappings ?? []),
|
||||
...(cardMapping?.hiddenMappings ?? []),
|
||||
];
|
||||
for (const hm of allHidden) {
|
||||
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||
let value: unknown = null;
|
||||
if (hm.valueSource === "static") {
|
||||
value = hm.staticValue ?? null;
|
||||
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||
const jsonCol = item[hm.sourceJsonColumn];
|
||||
if (typeof jsonCol === "object" && jsonCol !== null) {
|
||||
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
|
||||
} else if (typeof jsonCol === "string") {
|
||||
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
|
||||
}
|
||||
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
|
||||
}
|
||||
columns.push(`"${hm.targetColumn}"`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||
);
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(generatedCode);
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await client.query(
|
||||
`INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
values,
|
||||
);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-update": {
|
||||
if (!task.targetTable || !task.targetColumn) break;
|
||||
if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break;
|
||||
|
||||
const opType = task.operationType ?? "assign";
|
||||
const valSource = task.valueSource ?? "fixed";
|
||||
const lookupMode = task.lookupMode ?? "auto";
|
||||
|
||||
let itemField: string;
|
||||
let pkColumn: string;
|
||||
|
||||
if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) {
|
||||
if (!isSafeIdentifier(task.manualPkColumn)) break;
|
||||
itemField = task.manualItemField;
|
||||
pkColumn = task.manualPkColumn;
|
||||
} else if (task.targetTable === "cart_items") {
|
||||
itemField = "__cart_id";
|
||||
pkColumn = "id";
|
||||
} else {
|
||||
itemField = "__cart_row_key";
|
||||
const pkResult = await client.query(
|
||||
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[task.targetTable],
|
||||
);
|
||||
pkColumn = pkResult.rows[0]?.attname || "id";
|
||||
}
|
||||
|
||||
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
|
||||
if (lookupValues.length === 0) break;
|
||||
|
||||
if (opType === "conditional" && task.conditionalValue) {
|
||||
for (let i = 0; i < lookupValues.length; i++) {
|
||||
const item = items[i] ?? {};
|
||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolved, companyCode, lookupValues[i]],
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
} else if (opType === "db-conditional") {
|
||||
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
||||
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||
|
||||
const thenVal = task.dbThenValue ?? "";
|
||||
const elseVal = task.dbElseValue ?? "";
|
||||
const op = task.compareOperator;
|
||||
const validOps = ["=", "!=", ">", "<", ">=", "<="];
|
||||
if (!validOps.includes(op)) break;
|
||||
|
||||
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
|
||||
|
||||
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
|
||||
[thenVal, elseVal, companyCode, ...lookupValues],
|
||||
);
|
||||
processedCount += lookupValues.length;
|
||||
} else {
|
||||
for (let i = 0; i < lookupValues.length; i++) {
|
||||
const item = items[i] ?? {};
|
||||
let value: unknown;
|
||||
|
||||
if (valSource === "linked") {
|
||||
value = item[task.sourceField ?? ""] ?? null;
|
||||
} else {
|
||||
value = task.fixedValue ?? "";
|
||||
}
|
||||
|
||||
let setSql: string;
|
||||
if (opType === "add") {
|
||||
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`;
|
||||
} else if (opType === "subtract") {
|
||||
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`;
|
||||
} else if (opType === "multiply") {
|
||||
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`;
|
||||
} else if (opType === "divide") {
|
||||
setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`;
|
||||
} else {
|
||||
setSql = `"${task.targetColumn}" = $1`;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[value, companyCode, lookupValues[i]],
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[pop/execute-action] data-update 실행", {
|
||||
table: task.targetTable,
|
||||
column: task.targetColumn,
|
||||
opType,
|
||||
count: lookupValues.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "data-delete": {
|
||||
if (!task.targetTable) break;
|
||||
if (!isSafeIdentifier(task.targetTable)) break;
|
||||
|
||||
const pkResult = await client.query(
|
||||
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[task.targetTable],
|
||||
);
|
||||
const pkCol = pkResult.rows[0]?.attname || "id";
|
||||
const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean);
|
||||
|
||||
if (deleteKeys.length > 0) {
|
||||
const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", ");
|
||||
await client.query(
|
||||
`DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`,
|
||||
[companyCode, ...deleteKeys],
|
||||
);
|
||||
deletedCount += deleteKeys.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "cart-save": {
|
||||
// cartChanges 처리 (M-9에서 확장)
|
||||
if (!cartChanges) break;
|
||||
const { toCreate, toUpdate, toDelete } = cartChanges;
|
||||
|
||||
if (toCreate && toCreate.length > 0) {
|
||||
for (const item of toCreate) {
|
||||
const cols = Object.keys(item).filter(isSafeIdentifier);
|
||||
if (cols.length === 0) continue;
|
||||
const allCols = ["company_code", ...cols.map((c) => `"${c}"`)];
|
||||
const allVals = [companyCode, ...cols.map((c) => item[c])];
|
||||
const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await client.query(
|
||||
`INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`,
|
||||
allVals,
|
||||
);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (toUpdate && toUpdate.length > 0) {
|
||||
for (const item of toUpdate) {
|
||||
const id = item.id;
|
||||
if (!id) continue;
|
||||
const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c));
|
||||
if (cols.length === 0) continue;
|
||||
const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", ");
|
||||
await client.query(
|
||||
`UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode, ...cols.map((c) => item[c])],
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (toDelete && toDelete.length > 0) {
|
||||
const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", ");
|
||||
await client.query(
|
||||
`DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`,
|
||||
[companyCode, ...toDelete],
|
||||
);
|
||||
deletedCount += toDelete.length;
|
||||
}
|
||||
|
||||
logger.info("[pop/execute-action] cart-save 실행", {
|
||||
created: toCreate?.length ?? 0,
|
||||
updated: toUpdate?.length ?? 0,
|
||||
deleted: toDelete?.length ?? 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type });
|
||||
}
|
||||
}
|
||||
}
|
||||
// ======== v1 레거시: action 기반 처리 ========
|
||||
else if (action === "inbound-confirm") {
|
||||
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
||||
const cardMapping = mappings?.cardList;
|
||||
const fieldMapping = mappings?.field;
|
||||
@@ -144,6 +467,64 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
}
|
||||
}
|
||||
|
||||
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
|
||||
const allHidden = [
|
||||
...(fieldMapping?.hiddenMappings ?? []),
|
||||
...(cardMapping?.hiddenMappings ?? []),
|
||||
];
|
||||
for (const hm of allHidden) {
|
||||
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||
|
||||
let value: unknown = null;
|
||||
if (hm.valueSource === "static") {
|
||||
value = hm.staticValue ?? null;
|
||||
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||
const jsonCol = item[hm.sourceJsonColumn];
|
||||
if (typeof jsonCol === "object" && jsonCol !== null) {
|
||||
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
|
||||
} else if (typeof jsonCol === "string") {
|
||||
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
|
||||
}
|
||||
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
|
||||
}
|
||||
|
||||
columns.push(`"${hm.targetColumn}"`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
||||
const allAutoGen = [
|
||||
...(fieldMapping?.autoGenMappings ?? []),
|
||||
...(cardMapping?.autoGenMappings ?? []),
|
||||
];
|
||||
for (const ag of allAutoGen) {
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId,
|
||||
companyCode,
|
||||
{ ...fieldValues, ...item },
|
||||
);
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(generatedCode);
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||
logger.info("[pop/execute-action] 채번 완료", {
|
||||
ruleId: ag.numberingRuleId,
|
||||
targetColumn: ag.targetColumn,
|
||||
generatedCode,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 채번 실패", {
|
||||
ruleId: ag.numberingRuleId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
@@ -254,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/execute-action] 완료", {
|
||||
action,
|
||||
action: action ?? "task-list",
|
||||
companyCode,
|
||||
processedCount,
|
||||
insertedCount,
|
||||
deletedCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
|
||||
data: { processedCount, insertedCount },
|
||||
message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`,
|
||||
data: { processedCount, insertedCount, deletedCount, generatedCodes },
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createScreen,
|
||||
updateScreen,
|
||||
updateScreenInfo,
|
||||
updateScreenTableName,
|
||||
deleteScreen,
|
||||
bulkDeleteScreens,
|
||||
checkScreenDependencies,
|
||||
@@ -67,6 +68,7 @@ router.get("/screens/:id/menu", getScreenMenu); // 화면에 할당된 메뉴
|
||||
router.post("/screens", createScreen);
|
||||
router.put("/screens/:id", updateScreen);
|
||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||
router.patch("/screens/:screenId/table-name", updateScreenTableName); // 화면 테이블명 변경
|
||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
|
||||
@@ -227,7 +227,8 @@ export class AdminService {
|
||||
PATH,
|
||||
CYCLE,
|
||||
TRANSLATED_NAME,
|
||||
TRANSLATED_DESC
|
||||
TRANSLATED_DESC,
|
||||
MENU_ICON
|
||||
) AS (
|
||||
SELECT
|
||||
1 AS LEVEL,
|
||||
@@ -282,7 +283,8 @@ export class AdminService {
|
||||
AND MLT.lang_code = $1
|
||||
LIMIT 1),
|
||||
MENU.MENU_DESC
|
||||
)
|
||||
),
|
||||
MENU.MENU_ICON
|
||||
FROM MENU_INFO MENU
|
||||
WHERE ${menuTypeCondition}
|
||||
AND ${statusCondition}
|
||||
@@ -348,7 +350,8 @@ export class AdminService {
|
||||
AND MLT.lang_code = $1
|
||||
LIMIT 1),
|
||||
MENU_SUB.MENU_DESC
|
||||
)
|
||||
),
|
||||
MENU_SUB.MENU_ICON
|
||||
FROM MENU_INFO MENU_SUB
|
||||
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
|
||||
WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH)
|
||||
@@ -374,6 +377,7 @@ export class AdminService {
|
||||
COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME,
|
||||
A.TRANSLATED_NAME,
|
||||
A.TRANSLATED_DESC,
|
||||
A.MENU_ICON,
|
||||
CASE UPPER(A.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
@@ -514,7 +518,8 @@ export class AdminService {
|
||||
LANG_KEY,
|
||||
LANG_KEY_DESC,
|
||||
PATH,
|
||||
CYCLE
|
||||
CYCLE,
|
||||
MENU_ICON
|
||||
) AS (
|
||||
SELECT
|
||||
1 AS LEVEL,
|
||||
@@ -532,7 +537,8 @@ export class AdminService {
|
||||
LANG_KEY,
|
||||
LANG_KEY_DESC,
|
||||
ARRAY [MENU.OBJID],
|
||||
FALSE
|
||||
FALSE,
|
||||
MENU.MENU_ICON
|
||||
FROM MENU_INFO MENU
|
||||
WHERE PARENT_OBJ_ID = 0
|
||||
AND MENU_TYPE = 1
|
||||
@@ -558,7 +564,8 @@ export class AdminService {
|
||||
MENU_SUB.LANG_KEY,
|
||||
MENU_SUB.LANG_KEY_DESC,
|
||||
PATH || MENU_SUB.SEQ::numeric,
|
||||
MENU_SUB.OBJID = ANY(PATH)
|
||||
MENU_SUB.OBJID = ANY(PATH),
|
||||
MENU_SUB.MENU_ICON
|
||||
FROM MENU_INFO MENU_SUB
|
||||
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
|
||||
WHERE MENU_SUB.STATUS = 'active'
|
||||
@@ -584,10 +591,9 @@ export class AdminService {
|
||||
A.COMPANY_CODE,
|
||||
A.LANG_KEY,
|
||||
A.LANG_KEY_DESC,
|
||||
A.MENU_ICON,
|
||||
COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME,
|
||||
-- 번역된 메뉴명 (우선순위: 번역 > 기본명)
|
||||
COALESCE(MLT_NAME.lang_text, A.MENU_NAME_KOR) AS TRANSLATED_NAME,
|
||||
-- 번역된 설명 (우선순위: 번역 > 기본명)
|
||||
COALESCE(MLT_DESC.lang_text, A.MENU_DESC) AS TRANSLATED_DESC,
|
||||
CASE UPPER(A.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Request } from "express";
|
||||
import { query, pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export function getClientIp(req: Request): string {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
if (forwarded) {
|
||||
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
|
||||
return first.trim();
|
||||
}
|
||||
const realIp = req.headers["x-real-ip"];
|
||||
if (realIp) {
|
||||
return Array.isArray(realIp) ? realIp[0] : realIp;
|
||||
}
|
||||
return req.ip || req.socket?.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
export type AuditAction =
|
||||
| "CREATE"
|
||||
| "UPDATE"
|
||||
| "DELETE"
|
||||
| "COPY"
|
||||
| "LOGIN"
|
||||
| "STATUS_CHANGE"
|
||||
| "BATCH_CREATE"
|
||||
| "BATCH_UPDATE"
|
||||
| "BATCH_DELETE";
|
||||
|
||||
export type AuditResourceType =
|
||||
| "MENU"
|
||||
| "SCREEN"
|
||||
| "SCREEN_LAYOUT"
|
||||
| "FLOW"
|
||||
| "FLOW_STEP"
|
||||
| "USER"
|
||||
| "ROLE"
|
||||
| "PERMISSION"
|
||||
| "COMPANY"
|
||||
| "CODE_CATEGORY"
|
||||
| "CODE"
|
||||
| "DATA"
|
||||
| "TABLE"
|
||||
| "NUMBERING_RULE"
|
||||
| "BATCH";
|
||||
|
||||
export interface AuditLogParams {
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
action: AuditAction;
|
||||
resourceType: AuditResourceType;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
tableName?: string;
|
||||
summary?: string;
|
||||
changes?: {
|
||||
before?: Record<string, any>;
|
||||
after?: Record<string, any>;
|
||||
fields?: string[];
|
||||
};
|
||||
ipAddress?: string;
|
||||
requestPath?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string | null;
|
||||
resource_name: string | null;
|
||||
table_name: string | null;
|
||||
summary: string | null;
|
||||
changes: any;
|
||||
ip_address: string | null;
|
||||
request_path: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
companyCode?: string;
|
||||
userId?: string;
|
||||
resourceType?: string;
|
||||
action?: string;
|
||||
tableName?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogStats {
|
||||
dailyCounts: Array<{ date: string; count: number }>;
|
||||
resourceTypeCounts: Array<{ resource_type: string; count: number }>;
|
||||
actionCounts: Array<{ action: string; count: number }>;
|
||||
topUsers: Array<{ user_id: string; user_name: string; count: number }>;
|
||||
}
|
||||
|
||||
class AuditLogService {
|
||||
/**
|
||||
* 감사 로그 1건 기록 (fire-and-forget)
|
||||
* 본 작업에 영향을 주지 않도록 에러를 내부에서 처리
|
||||
*/
|
||||
async log(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO system_audit_log
|
||||
(company_code, user_id, user_name, action, resource_type,
|
||||
resource_id, resource_name, table_name, summary, changes,
|
||||
ip_address, request_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
params.companyCode,
|
||||
params.userId,
|
||||
params.userName || null,
|
||||
params.action,
|
||||
params.resourceType,
|
||||
params.resourceId || null,
|
||||
params.resourceName || null,
|
||||
params.tableName || null,
|
||||
params.summary || null,
|
||||
params.changes ? JSON.stringify(params.changes) : null,
|
||||
params.ipAddress || null,
|
||||
params.requestPath || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 로그 다건 기록 (배치)
|
||||
*/
|
||||
async logBatch(entries: AuditLogParams[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
try {
|
||||
const values = entries
|
||||
.map(
|
||||
(_, i) =>
|
||||
`($${i * 12 + 1}, $${i * 12 + 2}, $${i * 12 + 3}, $${i * 12 + 4}, $${i * 12 + 5}, $${i * 12 + 6}, $${i * 12 + 7}, $${i * 12 + 8}, $${i * 12 + 9}, $${i * 12 + 10}, $${i * 12 + 11}, $${i * 12 + 12})`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const params = entries.flatMap((e) => [
|
||||
e.companyCode,
|
||||
e.userId,
|
||||
e.userName || null,
|
||||
e.action,
|
||||
e.resourceType,
|
||||
e.resourceId || null,
|
||||
e.resourceName || null,
|
||||
e.tableName || null,
|
||||
e.summary || null,
|
||||
e.changes ? JSON.stringify(e.changes) : null,
|
||||
e.ipAddress || null,
|
||||
e.requestPath || null,
|
||||
]);
|
||||
|
||||
await query(
|
||||
`INSERT INTO system_audit_log
|
||||
(company_code, user_id, user_name, action, resource_type,
|
||||
resource_id, resource_name, table_name, summary, changes,
|
||||
ip_address, request_path)
|
||||
VALUES ${values}`,
|
||||
params
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("감사 로그 배치 기록 실패 (무시됨)", { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 로그 조회 (페이징, 필터)
|
||||
*/
|
||||
async queryLogs(
|
||||
filters: AuditLogFilters,
|
||||
isSuperAdmin: boolean = false
|
||||
): Promise<{ data: AuditLogEntry[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (!isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
} else if (isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`);
|
||||
params.push(filters.resourceType);
|
||||
}
|
||||
if (filters.action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
if (filters.tableName) {
|
||||
conditions.push(`table_name = $${paramIndex++}`);
|
||||
params.push(filters.tableName);
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateFrom);
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateTo);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult[0].count, 10);
|
||||
|
||||
const data = await query<AuditLogEntry>(
|
||||
`SELECT * FROM system_audit_log ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
async getStats(
|
||||
companyCode?: string,
|
||||
days: number = 30
|
||||
): Promise<AuditLogStats> {
|
||||
const companyFilter = companyCode
|
||||
? "AND company_code = $1"
|
||||
: "";
|
||||
const params = companyCode ? [companyCode] : [];
|
||||
|
||||
const dailyCounts = await query<{ date: string; count: number }>(
|
||||
`SELECT DATE(created_at) as date, COUNT(*)::int as count
|
||||
FROM system_audit_log
|
||||
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const resourceTypeCounts = await query<{
|
||||
resource_type: string;
|
||||
count: number;
|
||||
}>(
|
||||
`SELECT resource_type, COUNT(*)::int as count
|
||||
FROM system_audit_log
|
||||
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||
GROUP BY resource_type
|
||||
ORDER BY count DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const actionCounts = await query<{ action: string; count: number }>(
|
||||
`SELECT action, COUNT(*)::int as count
|
||||
FROM system_audit_log
|
||||
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||
GROUP BY action
|
||||
ORDER BY count DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const topUsers = await query<{
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
count: number;
|
||||
}>(
|
||||
`SELECT user_id, COALESCE(MAX(user_name), user_id) as user_name, COUNT(*)::int as count
|
||||
FROM system_audit_log
|
||||
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||
GROUP BY user_id
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`,
|
||||
params
|
||||
);
|
||||
|
||||
return { dailyCounts, resourceTypeCounts, actionCounts, topUsers };
|
||||
}
|
||||
}
|
||||
|
||||
export const auditLogService = new AuditLogService();
|
||||
@@ -405,69 +405,169 @@ class CategoryTreeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 하위 카테고리 값 ID 재귀 수집
|
||||
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
|
||||
*/
|
||||
private async collectAllChildValueIds(
|
||||
private async checkCategoryValueInUse(
|
||||
companyCode: string,
|
||||
valueId: number
|
||||
): Promise<number[]> {
|
||||
value: CategoryValue
|
||||
): Promise<{ inUse: boolean; count: number }> {
|
||||
const pool = getPool();
|
||||
|
||||
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
|
||||
const query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
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 cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
WHERE cv.company_code = $2 OR cv.company_code = '*'
|
||||
)
|
||||
SELECT value_id FROM category_tree
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [valueId, companyCode]);
|
||||
return result.rows.map(row => row.value_id);
|
||||
|
||||
try {
|
||||
const tableExists = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
) AS exists`,
|
||||
[value.tableName]
|
||||
);
|
||||
|
||||
if (!tableExists.rows[0].exists) {
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
|
||||
const columnExists = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2
|
||||
) AS exists`,
|
||||
[value.tableName, value.columnName]
|
||||
);
|
||||
|
||||
if (!columnExists.rows[0].exists) {
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
|
||||
const hasCompanyCode = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'
|
||||
) AS exists`,
|
||||
[value.tableName]
|
||||
);
|
||||
|
||||
let countQuery: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode.rows[0].exists && companyCode !== "*") {
|
||||
countQuery = `
|
||||
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||
WHERE company_code = $1
|
||||
AND ($2 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||
OR "${value.columnName}"::text = $2)
|
||||
`;
|
||||
params = [companyCode, value.valueCode];
|
||||
} else {
|
||||
countQuery = `
|
||||
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||
WHERE $1 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||
OR "${value.columnName}"::text = $1
|
||||
`;
|
||||
params = [value.valueCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(countQuery, params);
|
||||
const count = parseInt(result.rows[0].count);
|
||||
|
||||
return { inUse: count > 0, count };
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용)", {
|
||||
error: err.message,
|
||||
tableName: value.tableName,
|
||||
columnName: value.columnName,
|
||||
});
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
async checkCanDelete(
|
||||
companyCode: string,
|
||||
valueId: number
|
||||
): Promise<{ canDelete: boolean; reason?: string }> {
|
||||
const pool = getPool();
|
||||
|
||||
const value = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!value) {
|
||||
return { canDelete: false, reason: "카테고리 값을 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||
[valueId, companyCode]
|
||||
);
|
||||
const childCount = parseInt(childCheck.rows[0].count);
|
||||
|
||||
if (childCount > 0) {
|
||||
return {
|
||||
canDelete: false,
|
||||
reason: `하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||
if (usageCheck.inUse) {
|
||||
return {
|
||||
canDelete: false,
|
||||
reason: `이 카테고리 값(${value.valueLabel})은 ${usageCheck.count}건의 데이터에서 사용 중이므로 삭제할 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { canDelete: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (자식 존재 및 사용 중 검증 후 삭제)
|
||||
*/
|
||||
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// 1. 모든 하위 카테고리 ID 수집
|
||||
const childValueIds = await this.collectAllChildValueIds(companyCode, valueId);
|
||||
const allValueIds = [valueId, ...childValueIds];
|
||||
|
||||
logger.info("삭제 대상 카테고리 값 수집 완료", {
|
||||
valueId,
|
||||
childCount: childValueIds.length,
|
||||
totalCount: allValueIds.length,
|
||||
});
|
||||
const value = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
|
||||
const reversedIds = [...allValueIds].reverse();
|
||||
|
||||
for (const id of reversedIds) {
|
||||
await pool.query(
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, id]
|
||||
// 1. 자식 카테고리 존재 여부 확인
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||
[valueId, companyCode]
|
||||
);
|
||||
const childCount = parseInt(childCheck.rows[0].count);
|
||||
|
||||
if (childCount > 0) {
|
||||
throw new Error(
|
||||
`VALIDATION:하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("카테고리 값 삭제 완료", {
|
||||
valueId,
|
||||
deletedCount: allValueIds.length,
|
||||
deletedChildCount: childValueIds.length,
|
||||
});
|
||||
|
||||
// 2. 실제 데이터에서 사용 중인지 확인
|
||||
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||
if (usageCheck.inUse) {
|
||||
throw new Error(
|
||||
`VALIDATION:이 카테고리 값(${value.valueLabel})은 ${value.tableName} 테이블에서 ${usageCheck.count}건의 데이터가 사용 중이므로 삭제할 수 없습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 삭제
|
||||
await pool.query(
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, valueId]
|
||||
);
|
||||
|
||||
logger.info("카테고리 값 삭제 완료", { valueId, valueLabel: value.valueLabel });
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
if (!err.message.startsWith("VALIDATION:")) {
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,7 +765,7 @@ export class EntityJoinService {
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
// 1. 테이블의 기본 컬럼 정보 조회
|
||||
// 1. 테이블의 기본 컬럼 정보 조회 (모든 데이터 타입 포함)
|
||||
const columns = await query<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
@@ -775,7 +775,7 @@ export class EntityJoinService {
|
||||
data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
@@ -132,14 +132,23 @@ export class FlowConditionParser {
|
||||
/**
|
||||
* SQL 인젝션 방지를 위한 컬럼명 검증
|
||||
*/
|
||||
private static sanitizeColumnName(columnName: string): string {
|
||||
// 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원)
|
||||
static sanitizeColumnName(columnName: string): string {
|
||||
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
|
||||
throw new Error(`Invalid column name: ${columnName}`);
|
||||
}
|
||||
return columnName;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 인젝션 방지를 위한 테이블명 검증
|
||||
*/
|
||||
static sanitizeTableName(tableName: string): string {
|
||||
if (!/^[a-zA-Z0-9_.]+$/.test(tableName)) {
|
||||
throw new Error(`Invalid table name: ${tableName}`);
|
||||
}
|
||||
return tableName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 검증
|
||||
*/
|
||||
|
||||
@@ -25,16 +25,21 @@ import {
|
||||
buildInsertQuery,
|
||||
buildSelectQuery,
|
||||
} from "./dbQueryBuilder";
|
||||
import { FlowConditionParser } from "./flowConditionParser";
|
||||
import { FlowProcedureService } from "./flowProcedureService";
|
||||
import { FlowProcedureConfig } from "../types/flow";
|
||||
|
||||
export class FlowDataMoveService {
|
||||
private flowDefinitionService: FlowDefinitionService;
|
||||
private flowStepService: FlowStepService;
|
||||
private externalDbIntegrationService: FlowExternalDbIntegrationService;
|
||||
private flowProcedureService: FlowProcedureService;
|
||||
|
||||
constructor() {
|
||||
this.flowDefinitionService = new FlowDefinitionService();
|
||||
this.flowStepService = new FlowStepService();
|
||||
this.externalDbIntegrationService = new FlowExternalDbIntegrationService();
|
||||
this.flowProcedureService = new FlowProcedureService();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +94,64 @@ export class FlowDataMoveService {
|
||||
let sourceTable = fromStep.tableName;
|
||||
let targetTable = toStep.tableName || fromStep.tableName;
|
||||
|
||||
// 1.5. 프로시저 호출 (스텝 이동 전 실행, 실패 시 전체 롤백)
|
||||
if (
|
||||
toStep.integrationType === "procedure" &&
|
||||
toStep.integrationConfig &&
|
||||
(toStep.integrationConfig as FlowProcedureConfig).type === "procedure"
|
||||
) {
|
||||
const procConfig = toStep.integrationConfig as FlowProcedureConfig;
|
||||
// 레코드 데이터 조회 (파라미터 매핑용)
|
||||
let recordData: Record<string, any> = {};
|
||||
try {
|
||||
const recordTable = FlowConditionParser.sanitizeTableName(
|
||||
sourceTable || flowDefinition.tableName
|
||||
);
|
||||
const recordResult = await client.query(
|
||||
`SELECT * FROM ${recordTable} WHERE id = $1 LIMIT 1`,
|
||||
[dataId]
|
||||
);
|
||||
if (recordResult.rows && recordResult.rows.length > 0) {
|
||||
recordData = recordResult.rows[0];
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn("프로시저 파라미터용 레코드 조회 실패:", err.message);
|
||||
}
|
||||
|
||||
console.log(`프로시저 호출 시작: ${procConfig.procedureName}`, {
|
||||
flowId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
dataId,
|
||||
dbSource: procConfig.dbSource,
|
||||
});
|
||||
|
||||
const procResult = await this.flowProcedureService.executeProcedure(
|
||||
procConfig,
|
||||
recordData,
|
||||
procConfig.dbSource === "internal" ? client : undefined
|
||||
);
|
||||
|
||||
console.log(`프로시저 호출 완료: ${procConfig.procedureName}`, {
|
||||
success: procResult.success,
|
||||
});
|
||||
|
||||
// 프로시저 실행 로그 기록
|
||||
await this.logIntegration(
|
||||
flowId,
|
||||
toStep.id,
|
||||
dataId,
|
||||
"procedure",
|
||||
procConfig.connectionId,
|
||||
procConfig,
|
||||
procResult.result,
|
||||
"success",
|
||||
undefined,
|
||||
0,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 이동 방식에 따라 처리
|
||||
switch (toStep.moveType || "status") {
|
||||
case "status":
|
||||
@@ -236,18 +299,19 @@ export class FlowDataMoveService {
|
||||
);
|
||||
}
|
||||
|
||||
const statusColumn = toStep.statusColumn;
|
||||
const tableName = fromStep.tableName;
|
||||
const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn);
|
||||
const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName);
|
||||
|
||||
// 추가 필드 업데이트 준비
|
||||
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
|
||||
const values: any[] = [dataId, toStep.statusValue];
|
||||
let paramIndex = 3;
|
||||
|
||||
// 추가 데이터가 있으면 함께 업데이트
|
||||
// 추가 데이터가 있으면 함께 업데이트 (키 검증 포함)
|
||||
if (additionalData) {
|
||||
for (const [key, value] of Object.entries(additionalData)) {
|
||||
updates.push(`${key} = $${paramIndex}`);
|
||||
const safeKey = FlowConditionParser.sanitizeColumnName(key);
|
||||
updates.push(`${safeKey} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
@@ -276,33 +340,38 @@ export class FlowDataMoveService {
|
||||
dataId: any,
|
||||
additionalData?: Record<string, any>
|
||||
): Promise<any> {
|
||||
const sourceTable = fromStep.tableName;
|
||||
const targetTable = toStep.targetTable || toStep.tableName;
|
||||
const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName);
|
||||
const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName);
|
||||
const fieldMappings = toStep.fieldMappings || {};
|
||||
|
||||
// 1. 소스 데이터 조회
|
||||
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
|
||||
const sourceResult = await client.query(selectQuery, [dataId]);
|
||||
|
||||
if (sourceResult.length === 0) {
|
||||
if (sourceResult.rows.length === 0) {
|
||||
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
|
||||
}
|
||||
|
||||
const sourceData = sourceResult[0];
|
||||
const sourceData = sourceResult.rows[0];
|
||||
|
||||
// 2. 필드 매핑 적용
|
||||
const mappedData: Record<string, any> = {};
|
||||
|
||||
// 매핑 정의가 있으면 적용
|
||||
// 매핑 정의가 있으면 적용 (컬럼명 검증)
|
||||
for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
|
||||
FlowConditionParser.sanitizeColumnName(sourceField);
|
||||
FlowConditionParser.sanitizeColumnName(targetField as string);
|
||||
if (sourceData[sourceField] !== undefined) {
|
||||
mappedData[targetField as string] = sourceData[sourceField];
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 데이터 병합
|
||||
// 추가 데이터 병합 (키 검증)
|
||||
if (additionalData) {
|
||||
Object.assign(mappedData, additionalData);
|
||||
for (const [key, value] of Object.entries(additionalData)) {
|
||||
const safeKey = FlowConditionParser.sanitizeColumnName(key);
|
||||
mappedData[safeKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 타겟 테이블에 데이터 삽입
|
||||
@@ -321,7 +390,7 @@ export class FlowDataMoveService {
|
||||
`;
|
||||
|
||||
const insertResult = await client.query(insertQuery, values);
|
||||
return insertResult[0].id;
|
||||
return insertResult.rows[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,12 +418,12 @@ export class FlowDataMoveService {
|
||||
]);
|
||||
|
||||
const stepDataMap: Record<string, string> =
|
||||
mappingResult.length > 0 ? mappingResult[0].step_data_map : {};
|
||||
mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {};
|
||||
|
||||
// 새 단계 데이터 추가
|
||||
stepDataMap[String(currentStepId)] = String(targetDataId);
|
||||
|
||||
if (mappingResult.length > 0) {
|
||||
if (mappingResult.rows.length > 0) {
|
||||
// 기존 매핑 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE flow_data_mapping
|
||||
@@ -366,7 +435,7 @@ export class FlowDataMoveService {
|
||||
await client.query(updateQuery, [
|
||||
currentStepId,
|
||||
JSON.stringify(stepDataMap),
|
||||
mappingResult[0].id,
|
||||
mappingResult.rows[0].id,
|
||||
]);
|
||||
} else {
|
||||
// 새 매핑 생성
|
||||
@@ -596,18 +665,19 @@ export class FlowDataMoveService {
|
||||
}
|
||||
break;
|
||||
|
||||
case "procedure":
|
||||
// 프로시저는 데이터 이동 전에 이미 실행됨 (step 1.5)
|
||||
break;
|
||||
|
||||
case "rest_api":
|
||||
// REST API 연동 (추후 구현)
|
||||
console.warn("REST API 연동은 아직 구현되지 않았습니다");
|
||||
break;
|
||||
|
||||
case "webhook":
|
||||
// Webhook 연동 (추후 구현)
|
||||
console.warn("Webhook 연동은 아직 구현되지 않았습니다");
|
||||
break;
|
||||
|
||||
case "hybrid":
|
||||
// 복합 연동 (추후 구현)
|
||||
console.warn("복합 연동은 아직 구현되지 않았습니다");
|
||||
break;
|
||||
|
||||
@@ -709,6 +779,40 @@ export class FlowDataMoveService {
|
||||
let sourceTable = fromStep.tableName;
|
||||
let targetTable = toStep.tableName || fromStep.tableName;
|
||||
|
||||
// 1.5. 프로시저 호출 (외부 DB 경로 - 스텝 이동 전)
|
||||
if (
|
||||
toStep.integrationType === "procedure" &&
|
||||
toStep.integrationConfig &&
|
||||
(toStep.integrationConfig as FlowProcedureConfig).type === "procedure"
|
||||
) {
|
||||
const procConfig = toStep.integrationConfig as FlowProcedureConfig;
|
||||
let recordData: Record<string, any> = {};
|
||||
try {
|
||||
const recordTable = FlowConditionParser.sanitizeTableName(
|
||||
sourceTable || ""
|
||||
);
|
||||
if (recordTable) {
|
||||
const placeholder = getPlaceholder(dbType, 1);
|
||||
const recordResult = await externalClient.query(
|
||||
`SELECT * FROM ${recordTable} WHERE id = ${placeholder}`,
|
||||
[dataId]
|
||||
);
|
||||
const rows = recordResult.rows || recordResult;
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
recordData = rows[0];
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn("프로시저 파라미터용 레코드 조회 실패 (외부):", err.message);
|
||||
}
|
||||
|
||||
await this.flowProcedureService.executeProcedure(
|
||||
procConfig,
|
||||
recordData,
|
||||
procConfig.dbSource === "external" ? undefined : undefined
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 이동 방식에 따라 처리
|
||||
switch (toStep.moveType || "status") {
|
||||
case "status":
|
||||
|
||||
@@ -19,7 +19,8 @@ export class FlowDefinitionService {
|
||||
userId: string,
|
||||
userCompanyCode?: string
|
||||
): Promise<FlowDefinition> {
|
||||
const companyCode = request.companyCode || userCompanyCode || "*";
|
||||
// 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용
|
||||
const companyCode = userCompanyCode || "*";
|
||||
|
||||
console.log("🔥 flowDefinitionService.create called with:", {
|
||||
name: request.name,
|
||||
@@ -118,10 +119,21 @@ export class FlowDefinitionService {
|
||||
|
||||
/**
|
||||
* 플로우 정의 단일 조회
|
||||
* companyCode가 전달되면 해당 회사 소유 플로우만 반환
|
||||
*/
|
||||
async findById(id: number): Promise<FlowDefinition | null> {
|
||||
const query = "SELECT * FROM flow_definition WHERE id = $1";
|
||||
const result = await db.query(query, [id]);
|
||||
async findById(id: number, companyCode?: string): Promise<FlowDefinition | null> {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
query = "SELECT * FROM flow_definition WHERE id = $1 AND company_code = $2";
|
||||
params = [id, companyCode];
|
||||
} else {
|
||||
query = "SELECT * FROM flow_definition WHERE id = $1";
|
||||
params = [id];
|
||||
}
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
@@ -132,10 +144,12 @@ export class FlowDefinitionService {
|
||||
|
||||
/**
|
||||
* 플로우 정의 수정
|
||||
* companyCode가 전달되면 해당 회사 소유 플로우만 수정 가능
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
request: UpdateFlowDefinitionRequest
|
||||
request: UpdateFlowDefinitionRequest,
|
||||
companyCode?: string
|
||||
): Promise<FlowDefinition | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -160,18 +174,27 @@ export class FlowDefinitionService {
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findById(id);
|
||||
return this.findById(id, companyCode);
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
|
||||
let whereClause = `WHERE id = $${paramIndex}`;
|
||||
params.push(id);
|
||||
paramIndex++;
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE flow_definition
|
||||
SET ${fields.join(", ")}
|
||||
WHERE id = $${paramIndex}
|
||||
${whereClause}
|
||||
RETURNING *
|
||||
`;
|
||||
params.push(id);
|
||||
|
||||
const result = await db.query(query, params);
|
||||
|
||||
@@ -184,10 +207,21 @@ export class FlowDefinitionService {
|
||||
|
||||
/**
|
||||
* 플로우 정의 삭제
|
||||
* companyCode가 전달되면 해당 회사 소유 플로우만 삭제 가능
|
||||
*/
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
|
||||
const result = await db.query(query, [id]);
|
||||
async delete(id: number, companyCode?: string): Promise<boolean> {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
query = "DELETE FROM flow_definition WHERE id = $1 AND company_code = $2 RETURNING id";
|
||||
params = [id, companyCode];
|
||||
} else {
|
||||
query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
|
||||
params = [id];
|
||||
}
|
||||
|
||||
const result = await db.query(query, params);
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService";
|
||||
import { FlowConditionParser } from "./flowConditionParser";
|
||||
import { executeExternalQuery } from "./externalDbHelper";
|
||||
import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
|
||||
import { FlowConditionParser } from "./flowConditionParser";
|
||||
|
||||
export class FlowExecutionService {
|
||||
private flowDefinitionService: FlowDefinitionService;
|
||||
@@ -42,7 +43,7 @@ export class FlowExecutionService {
|
||||
}
|
||||
|
||||
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
|
||||
|
||||
// 4. 조건 JSON을 SQL WHERE절로 변환
|
||||
const { where, params } = FlowConditionParser.toSqlWhere(
|
||||
@@ -96,7 +97,7 @@ export class FlowExecutionService {
|
||||
}
|
||||
|
||||
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
|
||||
|
||||
// 4. 조건 JSON을 SQL WHERE절로 변환
|
||||
const { where, params } = FlowConditionParser.toSqlWhere(
|
||||
@@ -267,11 +268,12 @@ export class FlowExecutionService {
|
||||
throw new Error(`Flow step not found: ${stepId}`);
|
||||
}
|
||||
|
||||
// 3. 테이블명 결정
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
if (!tableName) {
|
||||
// 3. 테이블명 결정 (SQL 인젝션 방지)
|
||||
const rawTableName = step.tableName || flowDef.tableName;
|
||||
if (!rawTableName) {
|
||||
throw new Error("Table name not found");
|
||||
}
|
||||
const tableName = FlowConditionParser.sanitizeTableName(rawTableName);
|
||||
|
||||
// 4. Primary Key 컬럼 결정 (기본값: id)
|
||||
const primaryKeyColumn = flowDef.primaryKey || "id";
|
||||
@@ -280,8 +282,10 @@ export class FlowExecutionService {
|
||||
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
||||
);
|
||||
|
||||
// 5. SET 절 생성
|
||||
const updateColumns = Object.keys(updateData);
|
||||
// 5. SET 절 생성 (컬럼명 SQL 인젝션 방지)
|
||||
const updateColumns = Object.keys(updateData).map((col) =>
|
||||
FlowConditionParser.sanitizeColumnName(col)
|
||||
);
|
||||
if (updateColumns.length === 0) {
|
||||
throw new Error("No columns to update");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 플로우 프로시저 호출 서비스
|
||||
* 내부/외부 DB의 프로시저/함수 목록 조회, 파라미터 조회, 실행을 담당
|
||||
*/
|
||||
|
||||
import db from "../database/db";
|
||||
import {
|
||||
getExternalPool,
|
||||
executeExternalQuery,
|
||||
} from "./externalDbHelper";
|
||||
import { getPlaceholder } from "./dbQueryBuilder";
|
||||
import {
|
||||
FlowProcedureConfig,
|
||||
FlowProcedureParam,
|
||||
ProcedureListItem,
|
||||
ProcedureParameterInfo,
|
||||
} from "../types/flow";
|
||||
|
||||
export class FlowProcedureService {
|
||||
/**
|
||||
* 프로시저/함수 목록 조회
|
||||
* information_schema.routines에서 사용 가능한 프로시저/함수를 가져온다
|
||||
*/
|
||||
async listProcedures(
|
||||
dbSource: "internal" | "external",
|
||||
connectionId?: number,
|
||||
schema?: string
|
||||
): Promise<ProcedureListItem[]> {
|
||||
if (dbSource === "external" && connectionId) {
|
||||
return this.listExternalProcedures(connectionId, schema);
|
||||
}
|
||||
return this.listInternalProcedures(schema);
|
||||
}
|
||||
|
||||
private async listInternalProcedures(
|
||||
schema?: string
|
||||
): Promise<ProcedureListItem[]> {
|
||||
const targetSchema = schema || "public";
|
||||
// 트리거 함수(data_type='trigger')는 직접 호출 대상이 아니므로 제외
|
||||
const query = `
|
||||
SELECT
|
||||
routine_name AS name,
|
||||
routine_schema AS schema,
|
||||
routine_type AS type,
|
||||
data_type AS return_type
|
||||
FROM information_schema.routines
|
||||
WHERE routine_schema = $1
|
||||
AND routine_type IN ('PROCEDURE', 'FUNCTION')
|
||||
AND data_type != 'trigger'
|
||||
ORDER BY routine_type, routine_name
|
||||
`;
|
||||
const rows = await db.query(query, [targetSchema]);
|
||||
return rows.map((r: any) => ({
|
||||
name: r.name,
|
||||
schema: r.schema,
|
||||
type: r.type as "PROCEDURE" | "FUNCTION",
|
||||
returnType: r.return_type || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private async listExternalProcedures(
|
||||
connectionId: number,
|
||||
schema?: string
|
||||
): Promise<ProcedureListItem[]> {
|
||||
const poolInfo = await getExternalPool(connectionId);
|
||||
const dbType = poolInfo.dbType.toLowerCase();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
switch (dbType) {
|
||||
case "postgresql": {
|
||||
const targetSchema = schema || "public";
|
||||
query = `
|
||||
SELECT
|
||||
routine_name AS name,
|
||||
routine_schema AS schema,
|
||||
routine_type AS type,
|
||||
data_type AS return_type
|
||||
FROM information_schema.routines
|
||||
WHERE routine_schema = $1
|
||||
AND routine_type IN ('PROCEDURE', 'FUNCTION')
|
||||
AND data_type != 'trigger'
|
||||
ORDER BY routine_type, routine_name
|
||||
`;
|
||||
params = [targetSchema];
|
||||
break;
|
||||
}
|
||||
case "mysql":
|
||||
case "mariadb": {
|
||||
query = `
|
||||
SELECT
|
||||
ROUTINE_NAME AS name,
|
||||
ROUTINE_SCHEMA AS \`schema\`,
|
||||
ROUTINE_TYPE AS type,
|
||||
DATA_TYPE AS return_type
|
||||
FROM information_schema.ROUTINES
|
||||
WHERE ROUTINE_SCHEMA = DATABASE()
|
||||
AND ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION')
|
||||
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
|
||||
`;
|
||||
params = [];
|
||||
break;
|
||||
}
|
||||
case "mssql": {
|
||||
query = `
|
||||
SELECT
|
||||
ROUTINE_NAME AS name,
|
||||
ROUTINE_SCHEMA AS [schema],
|
||||
ROUTINE_TYPE AS type,
|
||||
DATA_TYPE AS return_type
|
||||
FROM INFORMATION_SCHEMA.ROUTINES
|
||||
WHERE ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION')
|
||||
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
|
||||
`;
|
||||
params = [];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`프로시저 목록 조회 미지원 DB: ${dbType}`);
|
||||
}
|
||||
|
||||
const result = await executeExternalQuery(connectionId, query, params);
|
||||
return (result.rows || []).map((r: any) => ({
|
||||
name: r.name || r.NAME,
|
||||
schema: r.schema || r.SCHEMA || "",
|
||||
type: (r.type || r.TYPE || "FUNCTION").toUpperCase() as "PROCEDURE" | "FUNCTION",
|
||||
returnType: r.return_type || r.RETURN_TYPE || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로시저/함수 파라미터 정보 조회
|
||||
*/
|
||||
async getProcedureParameters(
|
||||
procedureName: string,
|
||||
dbSource: "internal" | "external",
|
||||
connectionId?: number,
|
||||
schema?: string
|
||||
): Promise<ProcedureParameterInfo[]> {
|
||||
if (dbSource === "external" && connectionId) {
|
||||
return this.getExternalProcedureParameters(
|
||||
connectionId,
|
||||
procedureName,
|
||||
schema
|
||||
);
|
||||
}
|
||||
return this.getInternalProcedureParameters(procedureName, schema);
|
||||
}
|
||||
|
||||
private async getInternalProcedureParameters(
|
||||
procedureName: string,
|
||||
schema?: string
|
||||
): Promise<ProcedureParameterInfo[]> {
|
||||
const targetSchema = schema || "public";
|
||||
// PostgreSQL의 specific_name은 routine_name + OID 형태이므로 서브쿼리로 매칭
|
||||
const query = `
|
||||
SELECT
|
||||
p.parameter_name AS name,
|
||||
p.ordinal_position AS position,
|
||||
p.data_type,
|
||||
p.parameter_mode AS mode,
|
||||
p.parameter_default AS default_value
|
||||
FROM information_schema.parameters p
|
||||
WHERE p.specific_schema = $1
|
||||
AND p.specific_name IN (
|
||||
SELECT r.specific_name FROM information_schema.routines r
|
||||
WHERE r.routine_schema = $1 AND r.routine_name = $2
|
||||
LIMIT 1
|
||||
)
|
||||
AND p.parameter_name IS NOT NULL
|
||||
ORDER BY p.ordinal_position
|
||||
`;
|
||||
const rows = await db.query(query, [targetSchema, procedureName]);
|
||||
return rows.map((r: any) => ({
|
||||
name: r.name,
|
||||
position: parseInt(r.position, 10),
|
||||
dataType: r.data_type,
|
||||
mode: this.normalizeParamMode(r.mode),
|
||||
defaultValue: r.default_value || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getExternalProcedureParameters(
|
||||
connectionId: number,
|
||||
procedureName: string,
|
||||
schema?: string
|
||||
): Promise<ProcedureParameterInfo[]> {
|
||||
const poolInfo = await getExternalPool(connectionId);
|
||||
const dbType = poolInfo.dbType.toLowerCase();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
switch (dbType) {
|
||||
case "postgresql": {
|
||||
const targetSchema = schema || "public";
|
||||
query = `
|
||||
SELECT
|
||||
p.parameter_name AS name,
|
||||
p.ordinal_position AS position,
|
||||
p.data_type,
|
||||
p.parameter_mode AS mode,
|
||||
p.parameter_default AS default_value
|
||||
FROM information_schema.parameters p
|
||||
WHERE p.specific_schema = $1
|
||||
AND p.specific_name IN (
|
||||
SELECT r.specific_name FROM information_schema.routines r
|
||||
WHERE r.routine_schema = $1 AND r.routine_name = $2
|
||||
LIMIT 1
|
||||
)
|
||||
AND p.parameter_name IS NOT NULL
|
||||
ORDER BY p.ordinal_position
|
||||
`;
|
||||
params = [targetSchema, procedureName];
|
||||
break;
|
||||
}
|
||||
case "mysql":
|
||||
case "mariadb": {
|
||||
query = `
|
||||
SELECT
|
||||
PARAMETER_NAME AS name,
|
||||
ORDINAL_POSITION AS position,
|
||||
DATA_TYPE AS data_type,
|
||||
PARAMETER_MODE AS mode,
|
||||
'' AS default_value
|
||||
FROM information_schema.PARAMETERS
|
||||
WHERE SPECIFIC_SCHEMA = DATABASE()
|
||||
AND SPECIFIC_NAME = ?
|
||||
AND PARAMETER_NAME IS NOT NULL
|
||||
ORDER BY ORDINAL_POSITION
|
||||
`;
|
||||
params = [procedureName];
|
||||
break;
|
||||
}
|
||||
case "mssql": {
|
||||
query = `
|
||||
SELECT
|
||||
PARAMETER_NAME AS name,
|
||||
ORDINAL_POSITION AS position,
|
||||
DATA_TYPE AS data_type,
|
||||
PARAMETER_MODE AS mode,
|
||||
'' AS default_value
|
||||
FROM INFORMATION_SCHEMA.PARAMETERS
|
||||
WHERE SPECIFIC_NAME = @p1
|
||||
AND PARAMETER_NAME IS NOT NULL
|
||||
ORDER BY ORDINAL_POSITION
|
||||
`;
|
||||
params = [procedureName];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`파라미터 조회 미지원 DB: ${dbType}`);
|
||||
}
|
||||
|
||||
const result = await executeExternalQuery(connectionId, query, params);
|
||||
return (result.rows || []).map((r: any) => ({
|
||||
name: (r.name || r.NAME || "").replace(/^@/, ""),
|
||||
position: parseInt(r.position || r.POSITION || "0", 10),
|
||||
dataType: r.data_type || r.DATA_TYPE || "unknown",
|
||||
mode: this.normalizeParamMode(r.mode || r.MODE),
|
||||
defaultValue: r.default_value || r.DEFAULT_VALUE || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로시저/함수 실행
|
||||
* 내부 DB는 기존 트랜잭션 client를 사용, 외부 DB는 별도 연결
|
||||
*/
|
||||
async executeProcedure(
|
||||
config: FlowProcedureConfig,
|
||||
recordData: Record<string, any>,
|
||||
client?: any
|
||||
): Promise<{ success: boolean; result?: any; error?: string }> {
|
||||
const paramValues = this.resolveParameters(config.parameters, recordData);
|
||||
|
||||
if (config.dbSource === "internal") {
|
||||
return this.executeInternalProcedure(config, paramValues, client);
|
||||
}
|
||||
|
||||
if (!config.connectionId) {
|
||||
throw new Error("외부 DB 프로시저 호출에 connectionId가 필요합니다");
|
||||
}
|
||||
return this.executeExternalProcedure(config, paramValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 DB 프로시저 실행 (트랜잭션 client 공유)
|
||||
*/
|
||||
private async executeInternalProcedure(
|
||||
config: FlowProcedureConfig,
|
||||
paramValues: any[],
|
||||
client?: any
|
||||
): Promise<{ success: boolean; result?: any; error?: string }> {
|
||||
const schema = config.procedureSchema || "public";
|
||||
const safeName = this.sanitizeName(config.procedureName);
|
||||
const safeSchema = this.sanitizeName(schema);
|
||||
const qualifiedName = `${safeSchema}.${safeName}`;
|
||||
|
||||
const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
let sql: string;
|
||||
if (config.callType === "function") {
|
||||
// SELECT * FROM fn()을 사용하여 OUT 파라미터를 개별 컬럼으로 반환
|
||||
sql = `SELECT * FROM ${qualifiedName}(${placeholders})`;
|
||||
} else {
|
||||
sql = `CALL ${qualifiedName}(${placeholders})`;
|
||||
}
|
||||
|
||||
try {
|
||||
const executor = client || db;
|
||||
const result = client
|
||||
? await client.query(sql, paramValues)
|
||||
: await db.query(sql, paramValues);
|
||||
|
||||
const rows = client ? result.rows : result;
|
||||
return { success: true, result: rows };
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`프로시저 실행 실패 [${qualifiedName}]: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 프로시저 실행
|
||||
*/
|
||||
private async executeExternalProcedure(
|
||||
config: FlowProcedureConfig,
|
||||
paramValues: any[]
|
||||
): Promise<{ success: boolean; result?: any; error?: string }> {
|
||||
const connectionId = config.connectionId!;
|
||||
const poolInfo = await getExternalPool(connectionId);
|
||||
const dbType = poolInfo.dbType.toLowerCase();
|
||||
const safeName = this.sanitizeName(config.procedureName);
|
||||
const safeSchema = config.procedureSchema
|
||||
? this.sanitizeName(config.procedureSchema)
|
||||
: null;
|
||||
|
||||
let sql: string;
|
||||
|
||||
switch (dbType) {
|
||||
case "postgresql": {
|
||||
const qualifiedName = safeSchema
|
||||
? `${safeSchema}.${safeName}`
|
||||
: safeName;
|
||||
const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", ");
|
||||
sql =
|
||||
config.callType === "function"
|
||||
? `SELECT * FROM ${qualifiedName}(${placeholders})`
|
||||
: `CALL ${qualifiedName}(${placeholders})`;
|
||||
break;
|
||||
}
|
||||
case "mysql":
|
||||
case "mariadb": {
|
||||
const placeholders = paramValues.map(() => "?").join(", ");
|
||||
sql = `CALL ${safeName}(${placeholders})`;
|
||||
break;
|
||||
}
|
||||
case "mssql": {
|
||||
const paramList = paramValues
|
||||
.map((_, i) => `@p${i + 1}`)
|
||||
.join(", ");
|
||||
sql = `EXEC ${safeName} ${paramList}`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`프로시저 실행 미지원 DB: ${dbType}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeExternalQuery(connectionId, sql, paramValues);
|
||||
return { success: true, result: result.rows };
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`외부 프로시저 실행 실패 [${safeName}]: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 파라미터 매핑에서 실제 값을 추출
|
||||
*/
|
||||
private resolveParameters(
|
||||
params: FlowProcedureParam[],
|
||||
recordData: Record<string, any>
|
||||
): any[] {
|
||||
const inParams = params.filter((p) => p.mode === "IN" || p.mode === "INOUT");
|
||||
return inParams.map((param) => {
|
||||
switch (param.source) {
|
||||
case "record_field":
|
||||
if (!param.field) {
|
||||
throw new Error(`파라미터 ${param.name}: 레코드 필드가 지정되지 않았습니다`);
|
||||
}
|
||||
return recordData[param.field] ?? null;
|
||||
|
||||
case "static":
|
||||
return param.value ?? null;
|
||||
|
||||
case "step_variable":
|
||||
return recordData[param.field || param.name] ?? param.value ?? null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름(스키마/프로시저) SQL Injection 방지용 검증
|
||||
*/
|
||||
private sanitizeName(name: string): string {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
||||
throw new Error(`유효하지 않은 이름: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파라미터 모드 정규화
|
||||
*/
|
||||
private normalizeParamMode(mode: string | null): "IN" | "OUT" | "INOUT" {
|
||||
if (!mode) return "IN";
|
||||
const upper = mode.toUpperCase();
|
||||
if (upper === "OUT") return "OUT";
|
||||
if (upper === "INOUT") return "INOUT";
|
||||
return "IN";
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ interface Menu {
|
||||
lang_key_desc: string | null;
|
||||
screen_code: string | null;
|
||||
menu_code: string | null;
|
||||
menu_icon: string | null;
|
||||
screen_group_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,7 +373,8 @@ export class MenuCopyService {
|
||||
private async collectScreens(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
client: PoolClient,
|
||||
menus?: Menu[]
|
||||
): Promise<Set<number>> {
|
||||
logger.info(
|
||||
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
|
||||
@@ -392,9 +395,25 @@ export class MenuCopyService {
|
||||
screenIds.add(assignment.screen_id);
|
||||
}
|
||||
|
||||
logger.info(`📌 직접 할당 화면: ${screenIds.size}개`);
|
||||
// 1.5) menu_url에서 참조되는 화면 수집 (/screens/{screenId} 패턴)
|
||||
if (menus) {
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
for (const menu of menus) {
|
||||
if (menu.menu_url) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (match) {
|
||||
const urlScreenId = parseInt(match[1], 10);
|
||||
if (!isNaN(urlScreenId) && urlScreenId > 0) {
|
||||
screenIds.add(urlScreenId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 화면 내부에서 참조되는 화면 (재귀)
|
||||
logger.info(`📌 직접 할당 + menu_url 화면: ${screenIds.size}개`);
|
||||
|
||||
// 2) 화면 내부에서 참조되는 화면 (재귀) - V1 + V2 레이아웃 모두 탐색
|
||||
const queue = Array.from(screenIds);
|
||||
|
||||
while (queue.length > 0) {
|
||||
@@ -403,17 +422,29 @@ export class MenuCopyService {
|
||||
if (visited.has(screenId)) continue;
|
||||
visited.add(screenId);
|
||||
|
||||
// 화면 레이아웃 조회
|
||||
const referencedScreens: number[] = [];
|
||||
|
||||
// V1 레이아웃에서 참조 화면 추출
|
||||
const layoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
// 참조 화면 추출
|
||||
const referencedScreens = this.extractReferencedScreens(
|
||||
layoutsResult.rows
|
||||
referencedScreens.push(
|
||||
...this.extractReferencedScreens(layoutsResult.rows)
|
||||
);
|
||||
|
||||
// V2 레이아웃에서 참조 화면 추출
|
||||
const layoutsV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, sourceCompanyCode]
|
||||
);
|
||||
for (const row of layoutsV2Result.rows) {
|
||||
if (row.layout_data) {
|
||||
this.extractScreenIdsFromObject(row.layout_data, referencedScreens);
|
||||
}
|
||||
}
|
||||
|
||||
if (referencedScreens.length > 0) {
|
||||
logger.info(
|
||||
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
|
||||
@@ -895,6 +926,8 @@ export class MenuCopyService {
|
||||
screenNameConfig?: {
|
||||
removeText?: string;
|
||||
addPrefix?: string;
|
||||
replaceFrom?: string;
|
||||
replaceTo?: string;
|
||||
},
|
||||
additionalCopyOptions?: AdditionalCopyOptions
|
||||
): Promise<MenuCopyResult> {
|
||||
@@ -937,7 +970,8 @@ export class MenuCopyService {
|
||||
const screenIds = await this.collectScreens(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
client,
|
||||
menus
|
||||
);
|
||||
|
||||
const flowIds = await this.collectFlows(screenIds, client);
|
||||
@@ -1093,6 +1127,16 @@ export class MenuCopyService {
|
||||
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
||||
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
||||
|
||||
// === 6.7단계: screen_group_screens 복제 ===
|
||||
logger.info("\n🏷️ [6.7단계] screen_group_screens 복제");
|
||||
await this.copyScreenGroupScreens(
|
||||
screenIds,
|
||||
screenIdMap,
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
// === 7단계: 테이블 타입 설정 복사 ===
|
||||
if (additionalCopyOptions?.copyTableTypeColumns) {
|
||||
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
||||
@@ -1417,6 +1461,8 @@ export class MenuCopyService {
|
||||
screenNameConfig?: {
|
||||
removeText?: string;
|
||||
addPrefix?: string;
|
||||
replaceFrom?: string;
|
||||
replaceTo?: string;
|
||||
},
|
||||
numberingRuleIdMap?: Map<string, string>,
|
||||
menuIdMap?: Map<number, number>
|
||||
@@ -1516,6 +1562,13 @@ export class MenuCopyService {
|
||||
// 3) 화면명 변환 적용
|
||||
let transformedScreenName = screenDef.screen_name;
|
||||
if (screenNameConfig) {
|
||||
if (screenNameConfig.replaceFrom?.trim()) {
|
||||
transformedScreenName = transformedScreenName.replace(
|
||||
new RegExp(screenNameConfig.replaceFrom.trim(), "g"),
|
||||
screenNameConfig.replaceTo?.trim() || ""
|
||||
);
|
||||
transformedScreenName = transformedScreenName.trim();
|
||||
}
|
||||
if (screenNameConfig.removeText?.trim()) {
|
||||
transformedScreenName = transformedScreenName.replace(
|
||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||
@@ -1533,20 +1586,21 @@ export class MenuCopyService {
|
||||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||
const existingScreenId = existingCopy.screen_id;
|
||||
|
||||
// 원본 V2 레이아웃 조회
|
||||
// 원본 V2 레이아웃 조회 (모든 레이어)
|
||||
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
// 대상 V2 레이아웃 조회
|
||||
// 대상 V2 레이아웃 조회 (모든 레이어)
|
||||
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`,
|
||||
[existingScreenId]
|
||||
);
|
||||
|
||||
// 변경 여부 확인 (V2 레이아웃 비교)
|
||||
const hasChanges = this.hasLayoutChangesV2(
|
||||
// 변경 여부 확인: 레이어 수가 다르면 무조건 변경됨
|
||||
const layerCountDiffers = sourceLayoutV2Result.rows.length !== targetLayoutV2Result.rows.length;
|
||||
const hasChanges = layerCountDiffers || this.hasLayoutChangesV2(
|
||||
sourceLayoutV2Result.rows[0]?.layout_data,
|
||||
targetLayoutV2Result.rows[0]?.layout_data
|
||||
);
|
||||
@@ -1650,7 +1704,7 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
|
||||
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
|
||||
// === 2단계: screen_conditional_zones + screen_layouts_v2 처리 (멀티 레이어 지원) ===
|
||||
logger.info(
|
||||
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
);
|
||||
@@ -1662,23 +1716,90 @@ export class MenuCopyService {
|
||||
isUpdate,
|
||||
} of screenDefsToProcess) {
|
||||
try {
|
||||
// 원본 V2 레이아웃 조회
|
||||
const layoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
const sourceCompanyCode = screenDef.company_code;
|
||||
|
||||
// 원본 V2 레이아웃 전체 조회 (모든 레이어)
|
||||
const layoutV2Result = await client.query<{
|
||||
layout_data: any;
|
||||
layer_id: number;
|
||||
layer_name: string;
|
||||
condition_config: any;
|
||||
}>(
|
||||
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[originalScreenId, sourceCompanyCode]
|
||||
);
|
||||
|
||||
if (layoutV2Result.rows.length === 0) {
|
||||
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
let compIdx = 0;
|
||||
for (const layer of layoutV2Result.rows) {
|
||||
const components = layer.layout_data?.components || [];
|
||||
for (const comp of components) {
|
||||
if (!componentIdMap.has(comp.id)) {
|
||||
const newId = `comp_${timestamp}_${compIdx++}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(comp.id, newId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// screen_conditional_zones 복제 + zoneIdMap 생성
|
||||
const zoneIdMap = new Map<number, number>();
|
||||
const zonesResult = await client.query(
|
||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
const layoutData = layoutV2Result.rows[0]?.layout_data;
|
||||
const components = layoutData?.components || [];
|
||||
if (isUpdate) {
|
||||
await client.query(
|
||||
`DELETE FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2`,
|
||||
[targetScreenId, targetCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutData && components.length > 0) {
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
components.forEach((comp: any, idx: number) => {
|
||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(comp.id, newComponentId);
|
||||
});
|
||||
for (const zone of zonesResult.rows) {
|
||||
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
|
||||
const newZone = await client.query<{ zone_id: number }>(
|
||||
`INSERT INTO screen_conditional_zones
|
||||
(screen_id, company_code, zone_name, x, y, width, height,
|
||||
trigger_component_id, trigger_operator)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING zone_id`,
|
||||
[targetScreenId, targetCompanyCode, zone.zone_name,
|
||||
zone.x, zone.y, zone.width, zone.height,
|
||||
newTriggerCompId, zone.trigger_operator]
|
||||
);
|
||||
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
|
||||
}
|
||||
|
||||
if (zonesResult.rows.length > 0) {
|
||||
logger.info(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개 (zoneIdMap: ${zoneIdMap.size}개)`);
|
||||
}
|
||||
|
||||
// 업데이트인 경우 기존 레이아웃 삭제 (레이어 수 변경 대응)
|
||||
if (isUpdate) {
|
||||
await client.query(
|
||||
`DELETE FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`,
|
||||
[targetScreenId, targetCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
// 각 레이어별 처리
|
||||
let totalComponents = 0;
|
||||
for (const layer of layoutV2Result.rows) {
|
||||
const layoutData = layer.layout_data;
|
||||
const components = layoutData?.components || [];
|
||||
|
||||
if (!layoutData || components.length === 0) continue;
|
||||
totalComponents += components.length;
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
|
||||
@@ -1690,20 +1811,34 @@ export class MenuCopyService {
|
||||
menuIdMap
|
||||
);
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)]
|
||||
);
|
||||
// condition_config의 zone_id 재매핑
|
||||
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
|
||||
if (updatedConditionConfig?.zone_id) {
|
||||
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
|
||||
if (newZoneId) {
|
||||
updatedConditionConfig.zone_id = newZoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
|
||||
} else {
|
||||
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
||||
// V2 레이아웃 저장 (레이어별 INSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
|
||||
[
|
||||
targetScreenId,
|
||||
targetCompanyCode,
|
||||
layer.layer_id,
|
||||
layer.layer_name,
|
||||
JSON.stringify(updatedLayoutData),
|
||||
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
@@ -1983,6 +2118,26 @@ export class MenuCopyService {
|
||||
|
||||
logger.info(`📂 메뉴 복사 중: ${menus.length}개`);
|
||||
|
||||
// screen_group_id 재매핑 맵 생성 (source company → target company)
|
||||
const screenGroupIdMap = new Map<number, number>();
|
||||
const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[];
|
||||
if (sourceGroupIds.length > 0) {
|
||||
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||
[sourceGroupIds]
|
||||
);
|
||||
for (const sg of sourceGroups.rows) {
|
||||
const targetGroup = await client.query<{ id: number }>(
|
||||
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||
[sg.group_name, targetCompanyCode]
|
||||
);
|
||||
if (targetGroup.rows.length > 0) {
|
||||
screenGroupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||
}
|
||||
}
|
||||
logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}개`);
|
||||
}
|
||||
|
||||
// 위상 정렬 (부모 먼저 삽입)
|
||||
const sortedMenus = this.topologicalSortMenus(menus);
|
||||
|
||||
@@ -2106,26 +2261,28 @@ export class MenuCopyService {
|
||||
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_url, menu_desc, writer, status, system_name,
|
||||
company_code, lang_key, lang_key_desc, screen_code, menu_code,
|
||||
source_menu_objid
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
source_menu_objid, menu_icon, screen_group_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
||||
[
|
||||
newObjId,
|
||||
menu.menu_type,
|
||||
newParentObjId, // 재매핑
|
||||
newParentObjId,
|
||||
menu.menu_name_kor,
|
||||
menu.menu_name_eng,
|
||||
menu.seq,
|
||||
menu.menu_url,
|
||||
menu.menu_desc,
|
||||
userId,
|
||||
'active', // 복제된 메뉴는 항상 활성화 상태
|
||||
menu.status || 'active',
|
||||
menu.system_name,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
targetCompanyCode,
|
||||
menu.lang_key,
|
||||
menu.lang_key_desc,
|
||||
menu.screen_code, // 그대로 유지
|
||||
menu.screen_code,
|
||||
menu.menu_code,
|
||||
sourceMenuObjid, // 원본 메뉴 ID (최상위만)
|
||||
sourceMenuObjid,
|
||||
menu.menu_icon,
|
||||
menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2246,8 +2403,9 @@ export class MenuCopyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 URL 업데이트 (화면 ID 재매핑)
|
||||
* 메뉴 URL + screen_code 업데이트 (화면 ID 재매핑)
|
||||
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
||||
* menu_info.screen_code도 복제된 screen_definitions.screen_code로 교체
|
||||
*/
|
||||
private async updateMenuUrls(
|
||||
menuIdMap: Map<number, number>,
|
||||
@@ -2255,56 +2413,197 @@ export class MenuCopyService {
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
||||
logger.info("📭 메뉴 URL 업데이트 대상 없음");
|
||||
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMenuObjids = Array.from(menuIdMap.values());
|
||||
|
||||
// 복제된 메뉴 중 menu_url이 있는 것 조회
|
||||
const menusWithUrl = await client.query<{
|
||||
// 복제된 메뉴 조회
|
||||
const menusToUpdate = await client.query<{
|
||||
objid: number;
|
||||
menu_url: string;
|
||||
menu_url: string | null;
|
||||
screen_code: string | null;
|
||||
}>(
|
||||
`SELECT objid, menu_url FROM menu_info
|
||||
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
|
||||
`SELECT objid, menu_url, screen_code FROM menu_info
|
||||
WHERE objid = ANY($1)`,
|
||||
[newMenuObjids]
|
||||
);
|
||||
|
||||
if (menusWithUrl.rows.length === 0) {
|
||||
logger.info("📭 menu_url 업데이트 대상 없음");
|
||||
if (menusToUpdate.rows.length === 0) {
|
||||
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
|
||||
for (const menu of menusWithUrl.rows) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (!match) continue;
|
||||
|
||||
const originalScreenId = parseInt(match[1], 10);
|
||||
const newScreenId = screenIdMap.get(originalScreenId);
|
||||
|
||||
if (newScreenId && newScreenId !== originalScreenId) {
|
||||
const newMenuUrl = menu.menu_url.replace(
|
||||
`/screens/${originalScreenId}`,
|
||||
`/screens/${newScreenId}`
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
|
||||
[newMenuUrl, menu.objid]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
updatedCount++;
|
||||
// screenIdMap의 역방향: 원본 screen_id → 새 screen_id의 screen_code 조회
|
||||
const newScreenIds = Array.from(screenIdMap.values());
|
||||
const screenCodeMap = new Map<string, string>();
|
||||
if (newScreenIds.length > 0) {
|
||||
const screenCodesResult = await client.query<{
|
||||
screen_id: number;
|
||||
screen_code: string;
|
||||
source_screen_id: number;
|
||||
}>(
|
||||
`SELECT sd_new.screen_id, sd_new.screen_code, sd_new.source_screen_id
|
||||
FROM screen_definitions sd_new
|
||||
WHERE sd_new.screen_id = ANY($1) AND sd_new.screen_code IS NOT NULL`,
|
||||
[newScreenIds]
|
||||
);
|
||||
for (const row of screenCodesResult.rows) {
|
||||
if (row.source_screen_id) {
|
||||
// 원본의 screen_code 조회
|
||||
const origResult = await client.query<{ screen_code: string }>(
|
||||
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
|
||||
[row.source_screen_id]
|
||||
);
|
||||
if (origResult.rows[0]?.screen_code) {
|
||||
screenCodeMap.set(origResult.rows[0].screen_code, row.screen_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`);
|
||||
let updatedUrlCount = 0;
|
||||
let updatedCodeCount = 0;
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
|
||||
for (const menu of menusToUpdate.rows) {
|
||||
let newMenuUrl = menu.menu_url;
|
||||
let newScreenCode = menu.screen_code;
|
||||
let changed = false;
|
||||
|
||||
// menu_url 재매핑
|
||||
if (menu.menu_url) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (match) {
|
||||
const originalScreenId = parseInt(match[1], 10);
|
||||
const newScreenId = screenIdMap.get(originalScreenId);
|
||||
if (newScreenId && newScreenId !== originalScreenId) {
|
||||
newMenuUrl = menu.menu_url.replace(
|
||||
`/screens/${originalScreenId}`,
|
||||
`/screens/${newScreenId}`
|
||||
);
|
||||
changed = true;
|
||||
updatedUrlCount++;
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// /screen/{screen_code} 형식도 처리
|
||||
const screenCodeUrlMatch = menu.menu_url.match(/\/screen\/(.+)/);
|
||||
if (screenCodeUrlMatch && !menu.menu_url.match(/\/screens\//)) {
|
||||
const origCode = screenCodeUrlMatch[1];
|
||||
const newCode = screenCodeMap.get(origCode);
|
||||
if (newCode && newCode !== origCode) {
|
||||
newMenuUrl = `/screen/${newCode}`;
|
||||
changed = true;
|
||||
updatedUrlCount++;
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL(코드) 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// screen_code 재매핑
|
||||
if (menu.screen_code) {
|
||||
const mappedCode = screenCodeMap.get(menu.screen_code);
|
||||
if (mappedCode && mappedCode !== menu.screen_code) {
|
||||
newScreenCode = mappedCode;
|
||||
changed = true;
|
||||
updatedCodeCount++;
|
||||
logger.info(
|
||||
` 🏷️ screen_code 업데이트: ${menu.screen_code} → ${newScreenCode}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await client.query(
|
||||
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
|
||||
[newMenuUrl, newScreenCode, menu.objid]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
* screen_group_screens 복제 (화면-스크린그룹 매핑)
|
||||
*/
|
||||
private async copyScreenGroupScreens(
|
||||
screenIds: Set<number>,
|
||||
screenIdMap: Map<number, number>,
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
if (screenIds.size === 0 || screenIdMap.size === 0) {
|
||||
logger.info("📭 screen_group_screens 복제 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리)
|
||||
await client.query(
|
||||
`DELETE FROM screen_group_screens WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
|
||||
// 소스 회사의 screen_group_screens 조회
|
||||
const sourceScreenIds = Array.from(screenIds);
|
||||
const sourceResult = await client.query<{
|
||||
group_id: number;
|
||||
screen_id: number;
|
||||
screen_role: string;
|
||||
display_order: number;
|
||||
is_default: string;
|
||||
}>(
|
||||
`SELECT group_id, screen_id, screen_role, display_order, is_default
|
||||
FROM screen_group_screens
|
||||
WHERE company_code = $1 AND screen_id = ANY($2)`,
|
||||
[sourceCompanyCode, sourceScreenIds]
|
||||
);
|
||||
|
||||
if (sourceResult.rows.length === 0) {
|
||||
logger.info("📭 소스에 screen_group_screens 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// screen_group ID 매핑 (source group_name → target group_id)
|
||||
const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))];
|
||||
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||
[sourceGroupIds]
|
||||
);
|
||||
const groupIdMap = new Map<number, number>();
|
||||
for (const sg of sourceGroups.rows) {
|
||||
const targetGroup = await client.query<{ id: number }>(
|
||||
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||
[sg.group_name, targetCompanyCode]
|
||||
);
|
||||
if (targetGroup.rows.length > 0) {
|
||||
groupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
let insertedCount = 0;
|
||||
for (const row of sourceResult.rows) {
|
||||
const newGroupId = groupIdMap.get(row.group_id);
|
||||
const newScreenId = screenIdMap.get(row.screen_id);
|
||||
if (!newGroupId || !newScreenId) continue;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'system')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode]
|
||||
);
|
||||
insertedCount++;
|
||||
}
|
||||
|
||||
logger.info(`✅ screen_group_screens 복제: ${insertedCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -124,7 +124,10 @@ export async function syncScreenGroupsToMenu(
|
||||
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
||||
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
||||
|
||||
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
||||
// 3. objid 충돌 방지: 순차 카운터 사용
|
||||
let nextObjid = Date.now();
|
||||
|
||||
// 4. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
||||
// 없으면 생성
|
||||
let userMenuRootObjid: number | null = null;
|
||||
const rootMenuQuery = `
|
||||
@@ -138,19 +141,18 @@ export async function syncScreenGroupsToMenu(
|
||||
if (rootMenuResult.rows.length > 0) {
|
||||
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
||||
} else {
|
||||
// 루트 메뉴가 없으면 생성
|
||||
const newObjid = Date.now();
|
||||
const rootObjid = nextObjid++;
|
||||
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]);
|
||||
const createRootResult = await client.query(createRootQuery, [rootObjid, companyCode, userId]);
|
||||
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
||||
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
||||
}
|
||||
|
||||
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
||||
// 5. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
||||
const groupToMenuMap: Map<number, number> = new Map();
|
||||
|
||||
// screen_groups의 부모 이름 조회를 위한 매핑
|
||||
@@ -280,7 +282,7 @@ export async function syncScreenGroupsToMenu(
|
||||
|
||||
} else {
|
||||
// 새 메뉴 생성
|
||||
const newObjid = Date.now() + groupId; // 고유 ID 보장
|
||||
const newObjid = nextObjid++;
|
||||
|
||||
// 부모 메뉴 objid 결정
|
||||
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
||||
@@ -334,8 +336,8 @@ export async function syncScreenGroupsToMenu(
|
||||
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,
|
||||
menu_url, screen_code
|
||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11)
|
||||
menu_url, screen_code, menu_icon
|
||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11, $12)
|
||||
RETURNING objid
|
||||
`;
|
||||
await client.query(insertMenuQuery, [
|
||||
@@ -350,6 +352,7 @@ export async function syncScreenGroupsToMenu(
|
||||
group.description || null,
|
||||
menuUrl,
|
||||
screenCode,
|
||||
group.icon || null,
|
||||
]);
|
||||
|
||||
// screen_groups에 menu_objid 업데이트
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import axios from "axios";
|
||||
import { FlowProcedureService } from "./flowProcedureService";
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
@@ -36,6 +37,7 @@ export type NodeType =
|
||||
| "emailAction" // 이메일 발송 액션
|
||||
| "scriptAction" // 스크립트 실행 액션
|
||||
| "httpRequestAction" // HTTP 요청 액션
|
||||
| "procedureCallAction" // 프로시저/함수 호출 액션
|
||||
| "comment"
|
||||
| "log";
|
||||
|
||||
@@ -663,6 +665,9 @@ export class NodeFlowExecutionService {
|
||||
case "httpRequestAction":
|
||||
return this.executeHttpRequestAction(node, inputData, context);
|
||||
|
||||
case "procedureCallAction":
|
||||
return this.executeProcedureCallAction(node, inputData, context, client);
|
||||
|
||||
case "comment":
|
||||
case "log":
|
||||
// 로그/코멘트는 실행 없이 통과
|
||||
@@ -4856,4 +4861,105 @@ export class NodeFlowExecutionService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로시저/함수 호출 액션 노드 실행
|
||||
*/
|
||||
private static async executeProcedureCallAction(
|
||||
node: FlowNode,
|
||||
inputData: any,
|
||||
context: ExecutionContext,
|
||||
client?: any
|
||||
): Promise<any> {
|
||||
const {
|
||||
dbSource = "internal",
|
||||
connectionId,
|
||||
procedureName,
|
||||
procedureSchema = "public",
|
||||
callType = "function",
|
||||
parameters = [],
|
||||
} = node.data;
|
||||
|
||||
logger.info(
|
||||
`🔧 프로시저 호출 노드 실행: ${node.data.displayName || node.id}`
|
||||
);
|
||||
logger.info(
|
||||
` 프로시저: ${procedureSchema}.${procedureName} (${callType}), DB: ${dbSource}`
|
||||
);
|
||||
|
||||
if (!procedureName) {
|
||||
throw new Error("프로시저/함수가 선택되지 않았습니다.");
|
||||
}
|
||||
|
||||
const dataArray = Array.isArray(inputData)
|
||||
? inputData
|
||||
: inputData
|
||||
? [inputData]
|
||||
: [{}];
|
||||
|
||||
const procedureService = new FlowProcedureService();
|
||||
const results: any[] = [];
|
||||
|
||||
const config = {
|
||||
type: "procedure" as const,
|
||||
dbSource: dbSource as "internal" | "external",
|
||||
connectionId,
|
||||
procedureName,
|
||||
procedureSchema,
|
||||
callType: callType as "procedure" | "function",
|
||||
parameters: parameters.map((p: any) => ({
|
||||
name: p.name,
|
||||
dataType: p.dataType,
|
||||
mode: p.mode || "IN",
|
||||
source: p.source || "static",
|
||||
field: p.field,
|
||||
value: p.value,
|
||||
})),
|
||||
};
|
||||
|
||||
for (const record of dataArray) {
|
||||
try {
|
||||
logger.info(` 입력 레코드 키: ${Object.keys(record).join(", ")}`);
|
||||
|
||||
const execResult = await procedureService.executeProcedure(
|
||||
config,
|
||||
record,
|
||||
dbSource === "internal" ? client : undefined
|
||||
);
|
||||
|
||||
logger.info(` ✅ 프로시저 실행 성공: ${procedureName}`);
|
||||
|
||||
// 프로시저 반환값을 레코드에 평탄화하여 다음 노드에서 필드로 참조 가능하게 함
|
||||
let flatResult: Record<string, any> = {};
|
||||
if (Array.isArray(execResult.result) && execResult.result.length > 0) {
|
||||
const row = execResult.result[0];
|
||||
for (const [key, val] of Object.entries(row)) {
|
||||
// 함수명과 동일한 키(SELECT fn() 결과)는 _procedureReturn으로 매핑
|
||||
if (key === procedureName) {
|
||||
flatResult["_procedureReturn"] = val;
|
||||
} else {
|
||||
flatResult[key] = val;
|
||||
}
|
||||
}
|
||||
logger.info(` 반환 필드: ${Object.keys(flatResult).join(", ")}`);
|
||||
}
|
||||
|
||||
results.push({
|
||||
...record,
|
||||
...flatResult,
|
||||
_procedureResult: execResult.result,
|
||||
_procedureSuccess: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(` ❌ 프로시저 실행 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔧 프로시저 호출 완료: ${results.length}건 처리`
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,155 @@ interface NumberingRuleConfig {
|
||||
}
|
||||
|
||||
class NumberingRuleService {
|
||||
/**
|
||||
* 순번(sequence) 파트를 제외한 나머지 파트 값들을 조합해 prefix_key 생성
|
||||
* 이 키가 같으면 같은 순번 계열, 다르면 001부터 재시작
|
||||
*/
|
||||
private async buildPrefixKey(
|
||||
rule: NumberingRuleConfig,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const prefixParts: string[] = [];
|
||||
|
||||
for (const part of sortedParts) {
|
||||
if (part.partType === "sequence") continue;
|
||||
|
||||
if (part.generationMethod === "manual") {
|
||||
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
|
||||
continue;
|
||||
}
|
||||
|
||||
const autoConfig = (part as any).autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "date": {
|
||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
||||
const columnValue = formData[autoConfig.sourceColumnName];
|
||||
if (columnValue) {
|
||||
const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue);
|
||||
if (!isNaN(dateValue.getTime())) {
|
||||
prefixParts.push(this.formatDate(dateValue, dateFormat));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
prefixParts.push(this.formatDate(new Date(), dateFormat));
|
||||
break;
|
||||
}
|
||||
|
||||
case "text": {
|
||||
prefixParts.push(autoConfig.textValue || "TEXT");
|
||||
break;
|
||||
}
|
||||
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
prefixParts.push(String(value).padStart(length, "0"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "category": {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
prefixParts.push("");
|
||||
break;
|
||||
}
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
if (!selectedValue) {
|
||||
prefixParts.push("");
|
||||
break;
|
||||
}
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [catTableName, catColumnName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[catTableName, catColumnName, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
const resolvedId = cvResult.rows[0].value_id;
|
||||
const resolvedLabel = cvResult.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
prefixParts.push(mapping?.format || selectedValueStr);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return prefixParts.join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* prefix_key 기반으로 현재 순번 조회 (새 테이블 사용)
|
||||
*/
|
||||
private async getSequenceForPrefix(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
prefixKey: string
|
||||
): Promise<number> {
|
||||
const result = await client.query(
|
||||
`SELECT current_sequence FROM numbering_rule_sequences
|
||||
WHERE rule_id = $1 AND company_code = $2 AND prefix_key = $3`,
|
||||
[ruleId, companyCode, prefixKey]
|
||||
);
|
||||
return result.rows.length > 0 ? result.rows[0].current_sequence : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* prefix_key 기반으로 순번 증가 (UPSERT)
|
||||
*/
|
||||
private async incrementSequenceForPrefix(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
prefixKey: string
|
||||
): Promise<number> {
|
||||
const result = await client.query(
|
||||
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
|
||||
VALUES ($1, $2, $3, 1, NOW())
|
||||
ON CONFLICT (rule_id, company_code, prefix_key)
|
||||
DO UPDATE SET current_sequence = numbering_rule_sequences.current_sequence + 1,
|
||||
last_allocated_at = NOW()
|
||||
RETURNING current_sequence`,
|
||||
[ruleId, companyCode, prefixKey]
|
||||
);
|
||||
return result.rows[0].current_sequence;
|
||||
}
|
||||
/**
|
||||
* 규칙 목록 조회 (전체)
|
||||
*/
|
||||
@@ -928,12 +1077,19 @@ class NumberingRuleService {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
// prefix_key 기반 순번 조회
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
const pool = getPool();
|
||||
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
logger.info("미리보기: prefix_key 기반 순번 조회", {
|
||||
ruleId, prefixKey, currentSeq,
|
||||
});
|
||||
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
|
||||
// placeholder 텍스트는 프론트엔드에서 별도로 표시
|
||||
return "____";
|
||||
}
|
||||
|
||||
@@ -941,9 +1097,8 @@ class NumberingRuleService {
|
||||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
||||
const nextSequence = currentSeq + 1;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
@@ -1129,6 +1284,27 @@ class NumberingRuleService {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
|
||||
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
|
||||
let allocatedSequence = 0;
|
||||
if (hasSequence) {
|
||||
allocatedSequence = await this.incrementSequenceForPrefix(
|
||||
client, ruleId, companyCode, prefixKey
|
||||
);
|
||||
// 호환성을 위해 기존 current_sequence도 업데이트
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("allocateCode: prefix_key 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence,
|
||||
});
|
||||
|
||||
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
|
||||
const manualParts = rule.parts.filter(
|
||||
(p: any) => p.generationMethod === "manual"
|
||||
@@ -1136,8 +1312,6 @@ class NumberingRuleService {
|
||||
let extractedManualValues: string[] = [];
|
||||
|
||||
if (manualParts.length > 0 && userInputCode) {
|
||||
// 프리뷰 코드를 생성해서 ____ 위치 파악
|
||||
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
|
||||
const previewParts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map(async (part: any) => {
|
||||
@@ -1148,19 +1322,18 @@ class NumberingRuleService {
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return "X".repeat(length); // 순번 자리 표시
|
||||
return "X".repeat(length);
|
||||
}
|
||||
case "text":
|
||||
return autoConfig.textValue || "";
|
||||
case "date":
|
||||
return "DATEPART"; // 날짜 자리 표시
|
||||
return "DATEPART";
|
||||
case "category": {
|
||||
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
|
||||
const catKey2 = autoConfig.categoryKey;
|
||||
const catMappings2 = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!catKey2 || !formData) {
|
||||
return "CATEGORY"; // 폴백
|
||||
return "CATEGORY";
|
||||
}
|
||||
|
||||
const colName2 = catKey2.includes(".")
|
||||
@@ -1169,7 +1342,7 @@ class NumberingRuleService {
|
||||
const selVal2 = formData[colName2];
|
||||
|
||||
if (!selVal2) {
|
||||
return "CATEGORY"; // 폴백
|
||||
return "CATEGORY";
|
||||
}
|
||||
|
||||
const selValStr2 = String(selVal2);
|
||||
@@ -1180,7 +1353,6 @@ class NumberingRuleService {
|
||||
return false;
|
||||
});
|
||||
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!catMapping2) {
|
||||
try {
|
||||
const pool2 = getPool();
|
||||
@@ -1211,8 +1383,6 @@ class NumberingRuleService {
|
||||
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||
|
||||
// 사용자 입력 코드에서 수동 입력 부분 추출
|
||||
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
|
||||
const templateParts = previewTemplate.split("____");
|
||||
if (templateParts.length > 1) {
|
||||
let remainingCode = userInputCode;
|
||||
@@ -1220,14 +1390,11 @@ class NumberingRuleService {
|
||||
const prefix = templateParts[i];
|
||||
const suffix = templateParts[i + 1];
|
||||
|
||||
// prefix 이후 부분 추출
|
||||
if (prefix && remainingCode.startsWith(prefix)) {
|
||||
remainingCode = remainingCode.slice(prefix.length);
|
||||
}
|
||||
|
||||
// suffix 이전까지가 수동 입력 값
|
||||
if (suffix) {
|
||||
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
|
||||
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
|
||||
const manualEndIndex = suffixStart
|
||||
? remainingCode.indexOf(suffixStart)
|
||||
@@ -1254,7 +1421,6 @@ class NumberingRuleService {
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
|
||||
const manualValue =
|
||||
extractedManualValues[manualPartIndex] ||
|
||||
part.manualConfig?.value ||
|
||||
@@ -1267,24 +1433,19 @@ class NumberingRuleService {
|
||||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (자동 증가 숫자 - 다음 번호 사용)
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
return String(allocatedSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// 숫자 (고정 자릿수)
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "date": {
|
||||
// 날짜 (다양한 날짜 형식)
|
||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||
|
||||
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
|
||||
if (
|
||||
autoConfig.useColumnValue &&
|
||||
autoConfig.sourceColumnName &&
|
||||
@@ -1292,80 +1453,42 @@ class NumberingRuleService {
|
||||
) {
|
||||
const columnValue = formData[autoConfig.sourceColumnName];
|
||||
if (columnValue) {
|
||||
// 날짜 문자열 또는 Date 객체를 Date로 변환
|
||||
const dateValue =
|
||||
columnValue instanceof Date
|
||||
? columnValue
|
||||
: new Date(columnValue);
|
||||
|
||||
if (!isNaN(dateValue.getTime())) {
|
||||
logger.info("컬럼 기준 날짜 생성", {
|
||||
sourceColumn: autoConfig.sourceColumnName,
|
||||
columnValue,
|
||||
parsedDate: dateValue.toISOString(),
|
||||
});
|
||||
return this.formatDate(dateValue, dateFormat);
|
||||
} else {
|
||||
logger.warn("날짜 변환 실패, 현재 날짜 사용", {
|
||||
sourceColumn: autoConfig.sourceColumnName,
|
||||
columnValue,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn("소스 컬럼 값이 없음, 현재 날짜 사용", {
|
||||
sourceColumn: autoConfig.sourceColumnName,
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 기본: 현재 날짜 사용
|
||||
return this.formatDate(new Date(), dateFormat);
|
||||
}
|
||||
|
||||
case "text": {
|
||||
// 텍스트 (고정 문자열)
|
||||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
case "category": {
|
||||
// 카테고리 기반 코드 생성 (allocateCode용)
|
||||
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", {
|
||||
categoryKey,
|
||||
hasFormData: !!formData,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
|
||||
// 폼 데이터에서 해당 컬럼의 값 가져오기
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
logger.info("allocateCode: 카테고리 파트 처리", {
|
||||
categoryKey,
|
||||
columnName,
|
||||
selectedValue,
|
||||
formDataKeys: Object.keys(formData),
|
||||
mappingsCount: categoryMappings.length,
|
||||
});
|
||||
|
||||
if (!selectedValue) {
|
||||
logger.warn("allocateCode: 카테고리 값이 선택되지 않음", {
|
||||
columnName,
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
@@ -1374,7 +1497,6 @@ class NumberingRuleService {
|
||||
return false;
|
||||
});
|
||||
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!allocMapping) {
|
||||
try {
|
||||
const pool3 = getPool();
|
||||
@@ -1391,37 +1513,18 @@ class NumberingRuleService {
|
||||
if (m.categoryValueLabel === rlabel3) return true;
|
||||
return false;
|
||||
});
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 역변환 성공", {
|
||||
valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
format: allocMapping.format,
|
||||
categoryValueLabel: allocMapping.categoryValueLabel,
|
||||
});
|
||||
return allocMapping.format || "";
|
||||
}
|
||||
|
||||
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
|
||||
selectedValue,
|
||||
availableMappings: categoryMappings.map((m: any) => ({
|
||||
id: m.categoryValueId,
|
||||
code: m.categoryValueCode,
|
||||
label: m.categoryValueLabel,
|
||||
})),
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
}));
|
||||
@@ -1429,17 +1532,6 @@ class NumberingRuleService {
|
||||
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
|
||||
|
||||
// 순번이 있는 경우에만 증가
|
||||
const hasSequence = rule.parts.some(
|
||||
(p: any) => p.partType === "sequence"
|
||||
);
|
||||
if (hasSequence) {
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode });
|
||||
return allocatedCode;
|
||||
@@ -1492,11 +1584,17 @@ class NumberingRuleService {
|
||||
|
||||
async resetSequence(ruleId: string, companyCode: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
// 새 테이블의 모든 prefix 순번 초기화
|
||||
await pool.query(
|
||||
"UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2",
|
||||
"DELETE FROM numbering_rule_sequences WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
|
||||
// 기존 테이블도 초기화 (호환성)
|
||||
await pool.query(
|
||||
"UPDATE numbering_rules SET current_sequence = 0, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
logger.info("시퀀스 초기화 완료 (prefix별 순번 포함)", { ruleId, companyCode });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -410,6 +410,38 @@ export class ScreenManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 메인 테이블명만 업데이트
|
||||
*/
|
||||
async updateScreenTableName(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
userCompanyCode: string,
|
||||
): Promise<void> {
|
||||
const existingResult = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (existingResult.length === 0) {
|
||||
throw new Error("화면을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
if (
|
||||
userCompanyCode !== "*" &&
|
||||
existingResult[0].company_code !== userCompanyCode
|
||||
) {
|
||||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
|
||||
[tableName, screenId],
|
||||
);
|
||||
|
||||
console.log(`화면 테이블명 업데이트 완료: screenId=${screenId}, tableName=${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
|
||||
*/
|
||||
@@ -3450,8 +3482,74 @@ export class ScreenManagementService {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
||||
`✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
||||
);
|
||||
|
||||
// V2 레이아웃(screen_layouts_v2)도 동일하게 처리
|
||||
const v2LayoutsResult = await client.query(
|
||||
`SELECT screen_id, layer_id, company_code, layout_data
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id IN (${placeholders})
|
||||
AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`,
|
||||
targetScreenIds,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`,
|
||||
);
|
||||
|
||||
let v2Updated = 0;
|
||||
for (const v2Layout of v2LayoutsResult.rows) {
|
||||
let layoutData = v2Layout.layout_data;
|
||||
if (!layoutData) continue;
|
||||
|
||||
let v2HasChanges = false;
|
||||
|
||||
const updateV2References = (obj: any): void => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) updateV2References(item);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (
|
||||
(key === "screenId" || key === "targetScreenId" || key === "modalScreenId" ||
|
||||
key === "leftScreenId" || key === "rightScreenId" ||
|
||||
key === "addModalScreenId" || key === "editModalScreenId")
|
||||
) {
|
||||
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||
if (!isNaN(numVal) && numVal > 0) {
|
||||
const newId = screenMap.get(numVal);
|
||||
if (newId) {
|
||||
obj[key] = typeof value === "number" ? newId : String(newId);
|
||||
v2HasChanges = true;
|
||||
console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
updateV2References(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateV2References(layoutData);
|
||||
|
||||
if (v2HasChanges) {
|
||||
await client.query(
|
||||
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||
);
|
||||
v2Updated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`,
|
||||
);
|
||||
result.updated += v2Updated;
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -4178,39 +4276,65 @@ export class ScreenManagementService {
|
||||
|
||||
const newScreen = newScreenResult.rows[0];
|
||||
|
||||
// 4. 원본 화면의 V2 레이아웃 조회
|
||||
let sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
// 4. 원본 화면의 V2 레이아웃 전체 조회 (모든 레이어)
|
||||
let sourceLayoutV2Result = await client.query<{
|
||||
layout_data: any;
|
||||
layer_id: number;
|
||||
layer_name: string;
|
||||
condition_config: any;
|
||||
}>(
|
||||
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[sourceScreenId, sourceScreen.company_code],
|
||||
);
|
||||
|
||||
// 없으면 공통(*) 레이아웃 조회
|
||||
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data;
|
||||
if (!layoutData && sourceScreen.company_code !== "*") {
|
||||
const fallbackResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
if (sourceLayoutV2Result.rows.length === 0 && sourceScreen.company_code !== "*") {
|
||||
sourceLayoutV2Result = await client.query<{
|
||||
layout_data: any;
|
||||
layer_id: number;
|
||||
layer_name: string;
|
||||
condition_config: any;
|
||||
}>(
|
||||
`SELECT layout_data, layer_id, layer_name, condition_config
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id`,
|
||||
[sourceScreenId],
|
||||
);
|
||||
layoutData = fallbackResult.rows[0]?.layout_data;
|
||||
}
|
||||
|
||||
const components = layoutData?.components || [];
|
||||
// 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const layer of sourceLayoutV2Result.rows) {
|
||||
const components = layer.layout_data?.components || [];
|
||||
for (const comp of components) {
|
||||
if (!componentIdMap.has(comp.id)) {
|
||||
componentIdMap.set(comp.id, generateId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasComponents = componentIdMap.size > 0;
|
||||
// 첫 번째 레이어의 layoutData (flowId/ruleId 수집용 - 모든 레이어에서 수집)
|
||||
const allLayoutDatas = sourceLayoutV2Result.rows.map((r: any) => r.layout_data).filter(Boolean);
|
||||
|
||||
// 5. 노드 플로우 복사 (회사가 다른 경우)
|
||||
let flowIdMap = new Map<number, number>();
|
||||
if (
|
||||
components.length > 0 &&
|
||||
hasComponents &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// V2 레이아웃에서 flowId 수집
|
||||
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
|
||||
const flowIds = new Set<number>();
|
||||
for (const ld of allLayoutDatas) {
|
||||
const ids = this.collectFlowIdsFromLayoutData(ld);
|
||||
ids.forEach((id: number) => flowIds.add(id));
|
||||
}
|
||||
|
||||
if (flowIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`);
|
||||
|
||||
// 노드 플로우 복사 및 매핑 생성
|
||||
flowIdMap = await this.copyNodeFlowsForScreen(
|
||||
flowIds,
|
||||
sourceScreen.company_code,
|
||||
@@ -4223,16 +4347,17 @@ export class ScreenManagementService {
|
||||
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
|
||||
let ruleIdMap = new Map<string, string>();
|
||||
if (
|
||||
components.length > 0 &&
|
||||
hasComponents &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// V2 레이아웃에서 채번 규칙 ID 수집
|
||||
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
|
||||
const ruleIds = new Set<string>();
|
||||
for (const ld of allLayoutDatas) {
|
||||
const ids = this.collectNumberingRuleIdsFromLayoutData(ld);
|
||||
ids.forEach((id: string) => ruleIds.add(id));
|
||||
}
|
||||
|
||||
if (ruleIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`);
|
||||
|
||||
// 채번 규칙 복사 및 매핑 생성
|
||||
ruleIdMap = await this.copyNumberingRulesForScreen(
|
||||
ruleIds,
|
||||
sourceScreen.company_code,
|
||||
@@ -4242,39 +4367,89 @@ export class ScreenManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. V2 레이아웃이 있다면 복사
|
||||
if (layoutData && components.length > 0) {
|
||||
// 5.2. screen_conditional_zones 복제 + zoneIdMap 생성
|
||||
const zoneIdMap = new Map<number, number>();
|
||||
if (hasComponents) {
|
||||
try {
|
||||
// componentId 매핑 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const comp of components) {
|
||||
componentIdMap.set(comp.id, generateId());
|
||||
const zonesResult = await client.query(
|
||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
|
||||
[sourceScreenId]
|
||||
);
|
||||
|
||||
for (const zone of zonesResult.rows) {
|
||||
const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id;
|
||||
const newZone = await client.query<{ zone_id: number }>(
|
||||
`INSERT INTO screen_conditional_zones
|
||||
(screen_id, company_code, zone_name, x, y, width, height,
|
||||
trigger_component_id, trigger_operator)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING zone_id`,
|
||||
[newScreen.screen_id, targetCompanyCode, zone.zone_name,
|
||||
zone.x, zone.y, zone.width, zone.height,
|
||||
newTriggerCompId, zone.trigger_operator]
|
||||
);
|
||||
zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id);
|
||||
}
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||
layoutData,
|
||||
{
|
||||
componentIdMap,
|
||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
|
||||
},
|
||||
);
|
||||
if (zonesResult.rows.length > 0) {
|
||||
console.log(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("조건부 영역 복사 중 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
// 6. V2 레이아웃 복사 (모든 레이어 순회)
|
||||
if (sourceLayoutV2Result.rows.length > 0 && hasComponents) {
|
||||
try {
|
||||
let totalComponents = 0;
|
||||
|
||||
|
||||
for (const layer of sourceLayoutV2Result.rows) {
|
||||
const layoutData = layer.layout_data;
|
||||
const components = layoutData?.components || [];
|
||||
|
||||
if (!layoutData || components.length === 0) continue;
|
||||
totalComponents += components.length;
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||
layoutData,
|
||||
{
|
||||
componentIdMap,
|
||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// condition_config의 zone_id 재매핑
|
||||
let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null;
|
||||
if (updatedConditionConfig?.zone_id) {
|
||||
const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id);
|
||||
if (newZoneId) {
|
||||
updatedConditionConfig.zone_id = newZoneId;
|
||||
}
|
||||
}
|
||||
|
||||
// V2 레이아웃 저장 (레이어별 INSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
|
||||
[
|
||||
newScreen.screen_id,
|
||||
targetCompanyCode,
|
||||
layer.layer_id,
|
||||
layer.layer_name,
|
||||
JSON.stringify(updatedLayoutData),
|
||||
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` ↳ V2 레이아웃 복사: ${sourceLayoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
|
||||
} catch (error) {
|
||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4501,9 +4676,60 @@ export class ScreenManagementService {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`,
|
||||
`✅ V1: ${updateCount}개 레이아웃 업데이트 완료`,
|
||||
);
|
||||
return updateCount;
|
||||
|
||||
// V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑
|
||||
const v2Layouts = await query<any>(
|
||||
`SELECT screen_id, layer_id, company_code, layout_data
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
AND layout_data IS NOT NULL`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
let v2UpdateCount = 0;
|
||||
for (const v2Layout of v2Layouts) {
|
||||
const layoutData = v2Layout.layout_data;
|
||||
if (!layoutData?.components) continue;
|
||||
|
||||
let v2Changed = false;
|
||||
const updateV2Refs = (obj: any): void => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; }
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (
|
||||
(key === "targetScreenId" || key === "screenId" || key === "modalScreenId" ||
|
||||
key === "leftScreenId" || key === "rightScreenId" ||
|
||||
key === "addModalScreenId" || key === "editModalScreenId")
|
||||
) {
|
||||
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||
if (!isNaN(numVal) && screenIdMapping.has(numVal)) {
|
||||
obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString();
|
||||
v2Changed = true;
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && value !== null) updateV2Refs(value);
|
||||
}
|
||||
};
|
||||
updateV2Refs(layoutData);
|
||||
|
||||
if (v2Changed) {
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||
);
|
||||
v2UpdateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const total = updateCount + v2UpdateCount;
|
||||
console.log(
|
||||
`✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`,
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -513,6 +513,15 @@ export class TableManagementService {
|
||||
detailSettingsStr = JSON.stringify(settings.detailSettings);
|
||||
}
|
||||
|
||||
// 입력타입에 해당하지 않는 설정값은 NULL로 강제 초기화
|
||||
const inputType = settings.inputType;
|
||||
const referenceTable = inputType === "entity" ? (settings.referenceTable || null) : null;
|
||||
const referenceColumn = inputType === "entity" ? (settings.referenceColumn || null) : null;
|
||||
const displayColumn = inputType === "entity" ? (settings.displayColumn || null) : null;
|
||||
const codeCategory = inputType === "code" ? (settings.codeCategory || null) : null;
|
||||
const codeValue = inputType === "code" ? (settings.codeValue || null) : null;
|
||||
const categoryRef = inputType === "category" ? (settings.categoryRef || null) : null;
|
||||
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
@@ -525,11 +534,11 @@ export class TableManagementService {
|
||||
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
||||
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type),
|
||||
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
|
||||
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category),
|
||||
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value),
|
||||
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table),
|
||||
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column),
|
||||
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
||||
code_category = EXCLUDED.code_category,
|
||||
code_value = EXCLUDED.code_value,
|
||||
reference_table = EXCLUDED.reference_table,
|
||||
reference_column = EXCLUDED.reference_column,
|
||||
display_column = EXCLUDED.display_column,
|
||||
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
||||
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
||||
category_ref = EXCLUDED.category_ref,
|
||||
@@ -538,17 +547,17 @@ export class TableManagementService {
|
||||
tableName,
|
||||
columnName,
|
||||
settings.columnLabel,
|
||||
settings.inputType,
|
||||
inputType,
|
||||
detailSettingsStr,
|
||||
settings.codeCategory,
|
||||
settings.codeValue,
|
||||
settings.referenceTable,
|
||||
settings.referenceColumn,
|
||||
settings.displayColumn,
|
||||
codeCategory,
|
||||
codeValue,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
settings.displayOrder || 0,
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
companyCode,
|
||||
settings.categoryRef || null,
|
||||
categoryRef,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -849,16 +858,26 @@ export class TableManagementService {
|
||||
...detailSettings,
|
||||
};
|
||||
|
||||
// table_type_columns 테이블에서 업데이트 (company_code 추가)
|
||||
// 입력타입 변경 시 이전 타입의 설정값 초기화
|
||||
const clearEntity = finalInputType !== "entity";
|
||||
const clearCode = finalInputType !== "code";
|
||||
const clearCategory = finalInputType !== "category";
|
||||
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (
|
||||
table_name, column_name, input_type, detail_settings,
|
||||
table_name, column_name, input_type, detail_settings,
|
||||
is_nullable, display_order, company_code, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
reference_table = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_table END,
|
||||
reference_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_column END,
|
||||
display_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.display_column END,
|
||||
code_category = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_category END,
|
||||
code_value = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_value END,
|
||||
category_ref = CASE WHEN $8 THEN NULL ELSE table_type_columns.category_ref END,
|
||||
updated_date = now()`,
|
||||
[
|
||||
tableName,
|
||||
@@ -866,6 +885,9 @@ export class TableManagementService {
|
||||
finalInputType,
|
||||
JSON.stringify(finalDetailSettings),
|
||||
companyCode,
|
||||
clearEntity,
|
||||
clearCode,
|
||||
clearCategory,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ export interface FlowStepDataList {
|
||||
// 데이터 이동 요청
|
||||
export interface MoveDataRequest {
|
||||
flowId: number;
|
||||
fromStepId: number;
|
||||
recordId: string;
|
||||
toStepId: number;
|
||||
note?: string;
|
||||
@@ -277,6 +278,7 @@ export interface SqlWhereResult {
|
||||
export type FlowIntegrationType =
|
||||
| "internal" // 내부 DB (기본값)
|
||||
| "external_db" // 외부 DB
|
||||
| "procedure" // 프로시저/함수 호출
|
||||
| "rest_api" // REST API (추후 구현)
|
||||
| "webhook" // Webhook (추후 구현)
|
||||
| "hybrid"; // 복합 연동 (추후 구현)
|
||||
@@ -340,8 +342,48 @@ export interface FlowExternalDbIntegrationConfig {
|
||||
customQuery?: string; // operation이 'custom'인 경우 사용
|
||||
}
|
||||
|
||||
// 프로시저 호출 파라미터 정의
|
||||
export interface FlowProcedureParam {
|
||||
name: string;
|
||||
dataType: string;
|
||||
mode: "IN" | "OUT" | "INOUT";
|
||||
source: "record_field" | "static" | "step_variable";
|
||||
field?: string; // source가 record_field인 경우: 레코드 컬럼명
|
||||
value?: string; // source가 static인 경우: 고정값
|
||||
}
|
||||
|
||||
// 프로시저 호출 설정 (integration_config JSON)
|
||||
export interface FlowProcedureConfig {
|
||||
type: "procedure";
|
||||
dbSource: "internal" | "external";
|
||||
connectionId?: number; // 외부 DB인 경우 external_db_connections.id
|
||||
procedureName: string;
|
||||
procedureSchema?: string; // 스키마명 (기본: public)
|
||||
callType: "procedure" | "function"; // CALL vs SELECT
|
||||
parameters: FlowProcedureParam[];
|
||||
}
|
||||
|
||||
// 프로시저/함수 목록 항목
|
||||
export interface ProcedureListItem {
|
||||
name: string;
|
||||
schema: string;
|
||||
type: "PROCEDURE" | "FUNCTION";
|
||||
returnType?: string;
|
||||
}
|
||||
|
||||
// 프로시저 파라미터 정보
|
||||
export interface ProcedureParameterInfo {
|
||||
name: string;
|
||||
position: number;
|
||||
dataType: string;
|
||||
mode: "IN" | "OUT" | "INOUT";
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
// 연동 설정 통합 타입
|
||||
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가
|
||||
export type FlowIntegrationConfig =
|
||||
| FlowExternalDbIntegrationConfig
|
||||
| FlowProcedureConfig;
|
||||
|
||||
// 연동 실행 컨텍스트
|
||||
export interface FlowIntegrationContext {
|
||||
|
||||
Reference in New Issue
Block a user