Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
DDD1542
2026-03-04 22:45:40 +09:00
2402 changed files with 15007 additions and 851502 deletions

View File

@@ -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";
/**
* 관리자 메뉴 목록 조회
@@ -1177,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,
@@ -1188,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);
@@ -1375,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);
@@ -1554,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);
@@ -1717,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);
@@ -1813,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);
@@ -2442,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" ? "활성" : "비활성"}으로 변경되었습니다.`,
@@ -2579,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 });
@@ -2769,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();
@@ -2938,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
@@ -2994,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 });
@@ -3055,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 });
@@ -3221,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: "프로필이 성공적으로 업데이트되었습니다.",
@@ -3334,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,
@@ -3535,6 +3714,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: "메뉴 복사 완료",
@@ -3849,6 +4041,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 ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",

View File

@@ -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: "사용자 목록 조회 중 오류가 발생했습니다.",
});
}
};

View File

@@ -0,0 +1,218 @@
/**
* 바코드 라벨 관리 컨트롤러
* ZD421 등 바코드 프린터용 라벨 CRUD 및 레이아웃/템플릿
*/
import { Request, Response, NextFunction } from "express";
import barcodeLabelService from "../services/barcodeLabelService";
function getUserId(req: Request): string {
return (req as any).user?.userId || "SYSTEM";
}
export class BarcodeLabelController {
async getLabels(req: Request, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt((req.query.page as string) || "1", 10));
const limit = Math.min(100, Math.max(1, parseInt((req.query.limit as string) || "20", 10)));
const searchText = (req.query.searchText as string) || "";
const useYn = (req.query.useYn as string) || "Y";
const sortBy = (req.query.sortBy as string) || "created_at";
const sortOrder = (req.query.sortOrder as "ASC" | "DESC") || "DESC";
const data = await barcodeLabelService.getLabels({
page,
limit,
searchText,
useYn,
sortBy,
sortOrder,
});
return res.json({ success: true, data });
} catch (error) {
return next(error);
}
}
async getLabelById(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const label = await barcodeLabelService.getLabelById(labelId);
if (!label) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: label });
} catch (error) {
return next(error);
}
}
async getLayout(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const layout = await barcodeLabelService.getLayout(labelId);
if (!layout) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error) {
return next(error);
}
}
async createLabel(req: Request, res: Response, next: NextFunction) {
try {
const body = req.body as {
labelNameKor?: string;
labelNameEng?: string;
description?: string;
templateId?: string;
};
if (!body?.labelNameKor?.trim()) {
return res.status(400).json({
success: false,
message: "라벨명(한글)은 필수입니다.",
});
}
const labelId = await barcodeLabelService.createLabel(
{
labelNameKor: body.labelNameKor.trim(),
labelNameEng: body.labelNameEng?.trim(),
description: body.description?.trim(),
templateId: body.templateId?.trim(),
},
getUserId(req)
);
return res.status(201).json({
success: true,
data: { labelId },
message: "바코드 라벨이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
async updateLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const body = req.body as {
labelNameKor?: string;
labelNameEng?: string;
description?: string;
useYn?: string;
};
const success = await barcodeLabelService.updateLabel(
labelId,
{
labelNameKor: body.labelNameKor?.trim(),
labelNameEng: body.labelNameEng?.trim(),
description: body.description !== undefined ? body.description : undefined,
useYn: body.useYn,
},
getUserId(req)
);
if (!success) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "수정되었습니다." });
} catch (error) {
return next(error);
}
}
async saveLayout(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const layout = req.body as { width_mm: number; height_mm: number; components: any[] };
if (!layout || typeof layout.width_mm !== "number" || typeof layout.height_mm !== "number" || !Array.isArray(layout.components)) {
return res.status(400).json({
success: false,
message: "width_mm, height_mm, components 배열이 필요합니다.",
});
}
await barcodeLabelService.saveLayout(
labelId,
{ width_mm: layout.width_mm, height_mm: layout.height_mm, components: layout.components },
getUserId(req)
);
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
} catch (error) {
return next(error);
}
}
async deleteLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const success = await barcodeLabelService.deleteLabel(labelId);
if (!success) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "삭제되었습니다." });
} catch (error) {
return next(error);
}
}
async copyLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const newId = await barcodeLabelService.copyLabel(labelId, getUserId(req));
if (!newId) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: { labelId: newId },
message: "복사되었습니다.",
});
} catch (error) {
return next(error);
}
}
async getTemplates(req: Request, res: Response, next: NextFunction) {
try {
const templates = await barcodeLabelService.getTemplates();
return res.json({ success: true, data: templates });
} catch (error) {
return next(error);
}
}
async getTemplateById(req: Request, res: Response, next: NextFunction) {
try {
const { templateId } = req.params;
const template = await barcodeLabelService.getTemplateById(templateId);
if (!template) {
return res.status(404).json({
success: false,
message: "템플릿을 찾을 수 없습니다.",
});
}
const layout = JSON.parse(template.layout_json);
return res.json({ success: true, data: { ...template, layout } });
} catch (error) {
return next(error);
}
}
}
export default new BarcodeLabelController();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -10,6 +10,7 @@ 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;
@@ -86,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,
@@ -188,6 +202,7 @@ export class FlowController {
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,
@@ -202,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,
@@ -234,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",
@@ -321,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,
@@ -373,6 +428,7 @@ export class FlowController {
}
}
const beforeStep = existingStep;
const step = await this.flowStepService.update(id, {
stepName,
stepOrder,
@@ -399,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,
@@ -444,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",
@@ -530,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,
@@ -575,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",

View File

@@ -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("찾을 수 없거나")) {

View File

@@ -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 });

View File

@@ -20,7 +20,7 @@ const pool = getPool();
export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const { page = 1, size = 20, searchTerm } = req.query;
const { page = 1, size = 20, searchTerm, excludePop } = req.query;
const offset = (parseInt(page as string) - 1) * parseInt(size as string);
let whereClause = "WHERE 1=1";
@@ -34,6 +34,11 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
paramIndex++;
}
// POP 그룹 제외 (PC 화면관리용)
if (excludePop === "true") {
whereClause += ` AND (hierarchy_path IS NULL OR (hierarchy_path NOT LIKE 'POP/%' AND hierarchy_path != 'POP'))`;
}
// 검색어 필터링
if (searchTerm) {
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
@@ -308,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');
@@ -380,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
});
}
}
}
}
@@ -2574,11 +2578,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
const companyCode = req.user?.companyCode || "*";
const { searchTerm } = req.query;
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'";
let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')";
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링 (멀티테넌시)
// 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만
if (companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
@@ -2592,11 +2596,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
paramIndex++;
}
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
// POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함)
const dataQuery = `
SELECT
sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
(SELECT COUNT(*) FROM screen_group_screens sgs
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count,
(SELECT json_agg(
json_build_object(
'id', sgs.id,
@@ -2609,7 +2615,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
) ORDER BY sgs.display_order
) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
) as screens
FROM screen_groups sg
${whereClause}
@@ -2768,6 +2775,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) {
// 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공
const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]);
if (anyGroup.rows.length > 0) {
return res.status(403).json({
success: false,
message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.`
});
}
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
}
@@ -2782,7 +2797,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id]
);
if (parseInt(childCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
return res.status(400).json({
success: false,
message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.`
});
}
// 연결된 화면 확인
@@ -2791,7 +2809,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id]
);
if (parseInt(screenCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
return res.status(400).json({
success: false,
message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.`
});
}
// 삭제
@@ -2806,33 +2827,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
}
};
// POP 루트 그룹 확보 (없으면 자동 생성)
// POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포)
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
// POP 루트 그룹 확인
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = $1
`;
const existing = await pool.query(checkQuery, [companyCode]);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
// 최고관리자만 자동 생성
if (companyCode !== "*") {
const existing = await pool.query(
`SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`,
[companyCode]
);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0] });
}
return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." });
}
// 최고관리자(*): 루트 그룹 확인 후 없으면 생성
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = '*'
`;
const existing = await pool.query(checkQuery, []);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0] });
}
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
const insertQuery = `
INSERT INTO screen_groups (
group_name, group_code, hierarchy_path, company_code,
description, display_order, is_active, writer
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1)
RETURNING *
`;
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
const result = await pool.query(insertQuery, [req.user?.userId || ""]);
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id });
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
} catch (error: any) {

View File

@@ -1,12 +1,13 @@
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) => {
try {
const userCompanyCode = (req.user as any).companyCode;
const { page = 1, size = 20, searchTerm, companyCode } = req.query;
const { page = 1, size = 20, searchTerm, companyCode, excludePop } = req.query;
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
// 아니면 현재 사용자의 companyCode 사용
@@ -24,7 +25,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
targetCompanyCode,
parseInt(page as string),
parseInt(size as string),
searchTerm as string // 검색어 전달
searchTerm as string,
{ excludePop: excludePop === "true" },
);
res.json({
@@ -108,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);
@@ -125,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);
@@ -140,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,
@@ -170,6 +233,8 @@ export const updateScreenInfo = async (
restApiJsonPath,
});
const beforeScreen = await screenManagementService.getScreenById(parseInt(id));
await screenManagementService.updateScreenInfo(
parseInt(id),
{
@@ -186,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);
@@ -227,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,
@@ -234,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);
@@ -513,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,
@@ -548,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,
@@ -647,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);
@@ -723,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);
@@ -895,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);
@@ -1364,3 +1538,82 @@ export const copyCascadingRelation = async (
});
}
};
// POP 화면 연결 분석
export const analyzePopScreenLinks = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const result = await screenManagementService.analyzePopScreenLinks(
parseInt(screenId),
companyCode,
);
res.json({ success: true, data: result });
} catch (error: any) {
console.error("POP 화면 연결 분석 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 연결 분석에 실패했습니다.",
});
}
};
// POP 화면 배포 (다른 회사로 복사)
export const deployPopScreens = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screens, targetCompanyCode, groupStructure } = req.body;
const { companyCode, userId } = req.user as any;
if (!screens || !Array.isArray(screens) || screens.length === 0) {
res.status(400).json({
success: false,
message: "배포할 화면 목록이 필요합니다.",
});
return;
}
if (!targetCompanyCode) {
res.status(400).json({
success: false,
message: "대상 회사 코드가 필요합니다.",
});
return;
}
if (companyCode !== "*") {
res.status(403).json({
success: false,
message: "최고 관리자만 POP 화면을 배포할 수 있습니다.",
});
return;
}
const result = await screenManagementService.deployPopScreens({
screens,
groupStructure: groupStructure || undefined,
targetCompanyCode,
companyCode,
userId,
});
res.json({
success: true,
data: result,
message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`,
});
} catch (error: any) {
console.error("POP 화면 배포 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 배포에 실패했습니다.",
});
}
};

View File

@@ -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: "다중 테이블 저장이 완료되었습니다.",