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:
@@ -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"],
|
||||
};
|
||||
@@ -90,6 +90,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import barcodeLabelRoutes from "./routes/barcodeLabelRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
|
||||
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
|
||||
@@ -114,6 +115,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
|
||||
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"; // 공차중계 운전자 관리
|
||||
@@ -126,6 +128,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
@@ -134,6 +137,8 @@ import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
// 기본 미들웨어
|
||||
app.use(
|
||||
helmet({
|
||||
@@ -242,6 +247,7 @@ app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
@@ -277,6 +283,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/admin/barcode-labels", barcodeLabelRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
|
||||
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
||||
@@ -309,6 +316,7 @@ 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/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
|
||||
@@ -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 ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
|
||||
|
||||
@@ -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: "사용자 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 화면 배포에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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: "다중 테이블 저장이 완료되었습니다.",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Router } from "express";
|
||||
import barcodeLabelController from "../controllers/barcodeLabelController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/", (req, res, next) =>
|
||||
barcodeLabelController.getLabels(req, res, next)
|
||||
);
|
||||
router.get("/templates", (req, res, next) =>
|
||||
barcodeLabelController.getTemplates(req, res, next)
|
||||
);
|
||||
router.get("/templates/:templateId", (req, res, next) =>
|
||||
barcodeLabelController.getTemplateById(req, res, next)
|
||||
);
|
||||
router.post("/", (req, res, next) =>
|
||||
barcodeLabelController.createLabel(req, res, next)
|
||||
);
|
||||
|
||||
router.get("/:labelId", (req, res, next) =>
|
||||
barcodeLabelController.getLabelById(req, res, next)
|
||||
);
|
||||
router.get("/:labelId/layout", (req, res, next) =>
|
||||
barcodeLabelController.getLayout(req, res, next)
|
||||
);
|
||||
router.put("/:labelId", (req, res, next) =>
|
||||
barcodeLabelController.updateLabel(req, res, next)
|
||||
);
|
||||
router.put("/:labelId/layout", (req, res, next) =>
|
||||
barcodeLabelController.saveLayout(req, res, next)
|
||||
);
|
||||
router.delete("/:labelId", (req, res, next) =>
|
||||
barcodeLabelController.deleteLabel(req, res, next)
|
||||
);
|
||||
router.post("/:labelId/copy", (req, res, next) =>
|
||||
barcodeLabelController.copyLabel(req, res, next)
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -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: "레코드가 삭제되었습니다.",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증
|
||||
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
function isSafeIdentifier(name: string): boolean {
|
||||
return SAFE_IDENTIFIER.test(name);
|
||||
}
|
||||
|
||||
interface MappingInfo {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
interface StatusConditionRule {
|
||||
whenColumn: string;
|
||||
operator: string;
|
||||
whenValue: string;
|
||||
thenValue: string;
|
||||
}
|
||||
|
||||
interface ConditionalValueRule {
|
||||
conditions: StatusConditionRule[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface StatusChangeRuleBody {
|
||||
targetTable: string;
|
||||
targetColumn: string;
|
||||
lookupMode?: "auto" | "manual";
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
valueType: "fixed" | "conditional";
|
||||
fixedValue?: string;
|
||||
conditionalValue?: ConditionalValueRule;
|
||||
// 하위호환: 기존 형식
|
||||
value?: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
interface ExecuteActionBody {
|
||||
action: string;
|
||||
data: {
|
||||
items?: Record<string, unknown>[];
|
||||
fieldValues?: Record<string, unknown>;
|
||||
};
|
||||
mappings?: {
|
||||
cardList?: MappingInfo | null;
|
||||
field?: MappingInfo | null;
|
||||
};
|
||||
statusChanges?: StatusChangeRuleBody[];
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
valueType: string,
|
||||
fixedValue: string,
|
||||
conditionalValue: ConditionalValueRule | undefined,
|
||||
item: Record<string, unknown>
|
||||
): string {
|
||||
if (valueType !== "conditional" || !conditionalValue) return fixedValue;
|
||||
|
||||
for (const cond of conditionalValue.conditions) {
|
||||
const actual = String(item[cond.whenColumn] ?? "");
|
||||
const expected = cond.whenValue;
|
||||
let match = false;
|
||||
|
||||
switch (cond.operator) {
|
||||
case "=": match = actual === expected; break;
|
||||
case "!=": match = actual !== expected; break;
|
||||
case ">": match = parseFloat(actual) > parseFloat(expected); break;
|
||||
case "<": match = parseFloat(actual) < parseFloat(expected); break;
|
||||
case ">=": match = parseFloat(actual) >= parseFloat(expected); break;
|
||||
case "<=": match = parseFloat(actual) <= parseFloat(expected); break;
|
||||
default: match = actual === expected;
|
||||
}
|
||||
|
||||
if (match) return cond.thenValue;
|
||||
}
|
||||
|
||||
return conditionalValue.defaultValue ?? fixedValue;
|
||||
}
|
||||
|
||||
router.post("/execute-action", authenticateToken, async (req: Request, res: Response) => {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
|
||||
const items = data?.items ?? [];
|
||||
const fieldValues = data?.fieldValues ?? {};
|
||||
|
||||
logger.info("[pop/execute-action] 요청", {
|
||||
action,
|
||||
companyCode,
|
||||
userId,
|
||||
itemCount: items.length,
|
||||
hasFieldValues: Object.keys(fieldValues).length > 0,
|
||||
hasMappings: !!mappings,
|
||||
statusChangeCount: statusChanges?.length ?? 0,
|
||||
});
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
let processedCount = 0;
|
||||
let insertedCount = 0;
|
||||
|
||||
if (action === "inbound-confirm") {
|
||||
// 1. 매핑 기반 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
|
||||
logger.info("[pop/execute-action] INSERT 실행", {
|
||||
table: cardMapping.targetTable,
|
||||
columnCount: columns.length,
|
||||
});
|
||||
|
||||
await client.query(sql, values);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMapping?.targetTable &&
|
||||
Object.keys(fieldMapping.columnMapping).length > 0 &&
|
||||
fieldMapping.targetTable !== cardMapping?.targetTable
|
||||
) {
|
||||
if (!isSafeIdentifier(fieldMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`);
|
||||
}
|
||||
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
await client.query(sql, values);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 상태 변경 규칙 실행 (설정 기반)
|
||||
if (statusChanges && statusChanges.length > 0) {
|
||||
for (const rule of statusChanges) {
|
||||
if (!rule.targetTable || !rule.targetColumn) continue;
|
||||
if (!isSafeIdentifier(rule.targetTable) || !isSafeIdentifier(rule.targetColumn)) {
|
||||
logger.warn("[pop/execute-action] 유효하지 않은 식별자, 건너뜀", { table: rule.targetTable, column: rule.targetColumn });
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueType = rule.valueType ?? "fixed";
|
||||
const fixedValue = rule.fixedValue ?? rule.value ?? "";
|
||||
const lookupMode = rule.lookupMode ?? "auto";
|
||||
|
||||
// 조회 키 결정: 아이템 필드(itemField) -> 대상 테이블 PK 컬럼(pkColumn)
|
||||
let itemField: string;
|
||||
let pkColumn: string;
|
||||
|
||||
if (lookupMode === "manual" && rule.manualItemField && rule.manualPkColumn) {
|
||||
if (!isSafeIdentifier(rule.manualPkColumn)) {
|
||||
logger.warn("[pop/execute-action] 수동 PK 컬럼 유효하지 않음", { manualPkColumn: rule.manualPkColumn });
|
||||
continue;
|
||||
}
|
||||
itemField = rule.manualItemField;
|
||||
pkColumn = rule.manualPkColumn;
|
||||
logger.info("[pop/execute-action] 수동 조회 키", { itemField, pkColumn, table: rule.targetTable });
|
||||
} else if (rule.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`,
|
||||
[rule.targetTable]
|
||||
);
|
||||
pkColumn = pkResult.rows[0]?.attname || "id";
|
||||
}
|
||||
|
||||
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
|
||||
if (lookupValues.length === 0) {
|
||||
logger.warn("[pop/execute-action] 조회 키 값 없음, 건너뜀", { table: rule.targetTable, itemField });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueType === "fixed") {
|
||||
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
||||
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
|
||||
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
||||
processedCount += lookupValues.length;
|
||||
} else {
|
||||
for (let i = 0; i < lookupValues.length; i++) {
|
||||
const item = items[i] ?? {};
|
||||
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
||||
await client.query(
|
||||
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolvedValue, companyCode, lookupValues[i]]
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[pop/execute-action] 상태 변경 실행", {
|
||||
table: rule.targetTable, column: rule.targetColumn, lookupMode, itemField, pkColumn, count: lookupValues.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/execute-action] 완료", {
|
||||
action,
|
||||
companyCode,
|
||||
processedCount,
|
||||
insertedCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
|
||||
data: { processedCount, insertedCount },
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("[pop/execute-action] 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createScreen,
|
||||
updateScreen,
|
||||
updateScreenInfo,
|
||||
updateScreenTableName,
|
||||
deleteScreen,
|
||||
bulkDeleteScreens,
|
||||
checkScreenDependencies,
|
||||
@@ -51,6 +52,8 @@ import {
|
||||
updateZone,
|
||||
deleteZone,
|
||||
addLayerToZone,
|
||||
analyzePopScreenLinks,
|
||||
deployPopScreens,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -65,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); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
@@ -145,4 +149,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns);
|
||||
// 연쇄관계 설정 복제
|
||||
router.post("/copy-cascading-relation", copyCascadingRelation);
|
||||
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
router.get("/screens/:screenId/pop-links", analyzePopScreenLinks);
|
||||
router.post("/deploy-pop-screens", deployPopScreens);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 바코드 라벨 관리 서비스
|
||||
* ZD421 등 라벨 디자인 CRUD 및 기본 템플릿 제공
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { BarcodeLabelLayout } from "../types/barcode";
|
||||
|
||||
export interface BarcodeLabelMaster {
|
||||
label_id: string;
|
||||
label_name_kor: string;
|
||||
label_name_eng: string | null;
|
||||
description: string | null;
|
||||
width_mm: number;
|
||||
height_mm: number;
|
||||
layout_json: string | null;
|
||||
use_yn: string;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
export interface BarcodeLabelTemplate {
|
||||
template_id: string;
|
||||
template_name_kor: string;
|
||||
template_name_eng: string | null;
|
||||
width_mm: number;
|
||||
height_mm: number;
|
||||
layout_json: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface GetBarcodeLabelsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchText?: string;
|
||||
useYn?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface GetBarcodeLabelsResult {
|
||||
items: BarcodeLabelMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export class BarcodeLabelService {
|
||||
async getLabels(params: GetBarcodeLabelsParams): Promise<GetBarcodeLabelsResult> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
searchText = "",
|
||||
useYn = "Y",
|
||||
sortBy = "created_at",
|
||||
sortOrder = "DESC",
|
||||
} = params;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (useYn) {
|
||||
conditions.push(`use_yn = $${idx++}`);
|
||||
values.push(useYn);
|
||||
}
|
||||
if (searchText) {
|
||||
conditions.push(`(label_name_kor LIKE $${idx} OR label_name_eng LIKE $${idx})`);
|
||||
values.push(`%${searchText}%`);
|
||||
idx++;
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const countSql = `SELECT COUNT(*) as total FROM barcode_labels ${where}`;
|
||||
const countRow = await queryOne<{ total: string }>(countSql, values);
|
||||
const total = parseInt(countRow?.total || "0", 10);
|
||||
|
||||
const listSql = `
|
||||
SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm,
|
||||
layout_json, use_yn, created_at, created_by, updated_at, updated_by
|
||||
FROM barcode_labels ${where}
|
||||
ORDER BY ${sortBy} ${sortOrder}
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`;
|
||||
const items = await query<BarcodeLabelMaster>(listSql, [...values, limit, offset]);
|
||||
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
async getLabelById(labelId: string): Promise<BarcodeLabelMaster | null> {
|
||||
const sql = `
|
||||
SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm,
|
||||
layout_json, use_yn, created_at, created_by, updated_at, updated_by
|
||||
FROM barcode_labels WHERE label_id = $1
|
||||
`;
|
||||
return queryOne<BarcodeLabelMaster>(sql, [labelId]);
|
||||
}
|
||||
|
||||
async getLayout(labelId: string): Promise<BarcodeLabelLayout | null> {
|
||||
const row = await this.getLabelById(labelId);
|
||||
if (!row?.layout_json) return null;
|
||||
try {
|
||||
return JSON.parse(row.layout_json) as BarcodeLabelLayout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createLabel(
|
||||
data: { labelNameKor: string; labelNameEng?: string; description?: string; templateId?: string },
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
const labelId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
|
||||
let widthMm = 50;
|
||||
let heightMm = 30;
|
||||
let layoutJson: string | null = null;
|
||||
|
||||
if (data.templateId) {
|
||||
const t = await this.getTemplateById(data.templateId);
|
||||
if (t) {
|
||||
widthMm = t.width_mm;
|
||||
heightMm = t.height_mm;
|
||||
layoutJson = t.layout_json;
|
||||
}
|
||||
}
|
||||
if (!layoutJson) {
|
||||
const defaultLayout: BarcodeLabelLayout = {
|
||||
width_mm: widthMm,
|
||||
height_mm: heightMm,
|
||||
components: [],
|
||||
};
|
||||
layoutJson = JSON.stringify(defaultLayout);
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8)`,
|
||||
[
|
||||
labelId,
|
||||
data.labelNameKor,
|
||||
data.labelNameEng || null,
|
||||
data.description || null,
|
||||
widthMm,
|
||||
heightMm,
|
||||
layoutJson,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
return labelId;
|
||||
}
|
||||
|
||||
async updateLabel(
|
||||
labelId: string,
|
||||
data: { labelNameKor?: string; labelNameEng?: string; description?: string; useYn?: string },
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
if (data.labelNameKor !== undefined) {
|
||||
setClauses.push(`label_name_kor = $${idx++}`);
|
||||
values.push(data.labelNameKor);
|
||||
}
|
||||
if (data.labelNameEng !== undefined) {
|
||||
setClauses.push(`label_name_eng = $${idx++}`);
|
||||
values.push(data.labelNameEng);
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
setClauses.push(`description = $${idx++}`);
|
||||
values.push(data.description);
|
||||
}
|
||||
if (data.useYn !== undefined) {
|
||||
setClauses.push(`use_yn = $${idx++}`);
|
||||
values.push(data.useYn);
|
||||
}
|
||||
if (setClauses.length === 0) return false;
|
||||
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
setClauses.push(`updated_by = $${idx++}`);
|
||||
values.push(userId);
|
||||
values.push(labelId);
|
||||
|
||||
const updated = await query<{ label_id: string }>(
|
||||
`UPDATE barcode_labels SET ${setClauses.join(", ")} WHERE label_id = $${idx} RETURNING label_id`,
|
||||
values
|
||||
);
|
||||
return updated.length > 0;
|
||||
}
|
||||
|
||||
async saveLayout(labelId: string, layout: BarcodeLabelLayout, userId: string): Promise<boolean> {
|
||||
const layoutJson = JSON.stringify(layout);
|
||||
await query(
|
||||
`UPDATE barcode_labels SET width_mm = $1, height_mm = $2, layout_json = $3, updated_at = CURRENT_TIMESTAMP, updated_by = $4 WHERE label_id = $5`,
|
||||
[layout.width_mm, layout.height_mm, layoutJson, userId, labelId]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteLabel(labelId: string): Promise<boolean> {
|
||||
const deleted = await query<{ label_id: string }>(
|
||||
`DELETE FROM barcode_labels WHERE label_id = $1 RETURNING label_id`,
|
||||
[labelId]
|
||||
);
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
async copyLabel(labelId: string, userId: string): Promise<string | null> {
|
||||
const row = await this.getLabelById(labelId);
|
||||
if (!row) return null;
|
||||
const newId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
|
||||
await query(
|
||||
`INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by)
|
||||
VALUES ($1, $2 || ' (복사)', $3, $4, $5, $6, $7, 'Y', $8)`,
|
||||
[
|
||||
newId,
|
||||
row.label_name_kor,
|
||||
row.label_name_eng,
|
||||
row.description,
|
||||
row.width_mm,
|
||||
row.height_mm,
|
||||
row.layout_json,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
return newId;
|
||||
}
|
||||
|
||||
async getTemplates(): Promise<BarcodeLabelTemplate[]> {
|
||||
const sql = `
|
||||
SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order
|
||||
FROM barcode_label_templates ORDER BY sort_order, template_id
|
||||
`;
|
||||
const rows = await query<BarcodeLabelTemplate>(sql);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async getTemplateById(templateId: string): Promise<BarcodeLabelTemplate | null> {
|
||||
const sql = `SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order
|
||||
FROM barcode_label_templates WHERE template_id = $1`;
|
||||
return queryOne<BarcodeLabelTemplate>(sql, [templateId]);
|
||||
}
|
||||
}
|
||||
|
||||
export default new BarcodeLabelService();
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -108,42 +108,49 @@ export class ScreenManagementService {
|
||||
companyCode: string,
|
||||
page: number = 1,
|
||||
size: number = 20,
|
||||
searchTerm?: string, // 검색어 추가
|
||||
searchTerm?: string,
|
||||
options?: { excludePop?: boolean },
|
||||
): Promise<PaginatedResponse<ScreenDefinition>> {
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
// WHERE 절 동적 생성
|
||||
const whereConditions: string[] = ["is_active != 'D'"];
|
||||
const whereConditions: string[] = ["sd.is_active != 'D'"];
|
||||
const params: any[] = [];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${params.length + 1}`);
|
||||
whereConditions.push(`sd.company_code = $${params.length + 1}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
whereConditions.push(`(
|
||||
screen_name ILIKE $${params.length + 1} OR
|
||||
screen_code ILIKE $${params.length + 1} OR
|
||||
table_name ILIKE $${params.length + 1}
|
||||
sd.screen_name ILIKE $${params.length + 1} OR
|
||||
sd.screen_code ILIKE $${params.length + 1} OR
|
||||
sd.table_name ILIKE $${params.length + 1}
|
||||
)`);
|
||||
params.push(`%${searchTerm.trim()}%`);
|
||||
}
|
||||
|
||||
// POP 화면 제외 필터: screen_layouts_pop에 레이아웃이 있는 화면 제외
|
||||
if (options?.excludePop) {
|
||||
whereConditions.push(
|
||||
`NOT EXISTS (SELECT 1 FROM screen_layouts_pop slp WHERE slp.screen_id = sd.screen_id)`
|
||||
);
|
||||
}
|
||||
|
||||
const whereSQL = whereConditions.join(" AND ");
|
||||
|
||||
// 페이징 쿼리 (Raw Query)
|
||||
const [screens, totalResult] = await Promise.all([
|
||||
query<any>(
|
||||
`SELECT * FROM screen_definitions
|
||||
`SELECT sd.* FROM screen_definitions sd
|
||||
WHERE ${whereSQL}
|
||||
ORDER BY created_date DESC
|
||||
ORDER BY sd.created_date DESC
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
[...params, size, offset],
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text as count FROM screen_definitions
|
||||
`SELECT COUNT(*)::text as count FROM screen_definitions sd
|
||||
WHERE ${whereSQL}`,
|
||||
params,
|
||||
),
|
||||
@@ -403,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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
|
||||
*/
|
||||
@@ -5814,28 +5853,24 @@ export class ScreenManagementService {
|
||||
async getScreenIdsWithPopLayout(
|
||||
companyCode: string,
|
||||
): Promise<number[]> {
|
||||
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
|
||||
console.log(`회사 코드: ${companyCode}`);
|
||||
|
||||
let result: { screen_id: number }[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 POP 레이아웃 조회
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
|
||||
// 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용)
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop
|
||||
WHERE company_code = $1 OR company_code = '*'`,
|
||||
WHERE company_code = $1`,
|
||||
[companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
const screenIds = result.map((r) => r.screen_id);
|
||||
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`);
|
||||
logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length });
|
||||
return screenIds;
|
||||
}
|
||||
|
||||
@@ -5873,6 +5908,512 @@ export class ScreenManagementService {
|
||||
console.log(`POP 레이아웃 삭제 완료`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* POP layout_data 내 다른 화면 참조를 스캔하여 연결 관계 분석
|
||||
*/
|
||||
async analyzePopScreenLinks(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<{
|
||||
linkedScreenIds: number[];
|
||||
references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
targetScreenId: number;
|
||||
}>;
|
||||
}> {
|
||||
const layoutResult = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
if (!layoutResult?.layout_data) {
|
||||
return { linkedScreenIds: [], references: [] };
|
||||
}
|
||||
|
||||
const layoutData = layoutResult.layout_data;
|
||||
const references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
targetScreenId: number;
|
||||
}> = [];
|
||||
|
||||
const scanComponents = (components: Record<string, any>) => {
|
||||
for (const [compId, comp] of Object.entries(components)) {
|
||||
const config = (comp as any).config || {};
|
||||
|
||||
if (config.cart?.cartScreenId) {
|
||||
const sid = parseInt(config.cart.cartScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "cartScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.cartListMode?.sourceScreenId) {
|
||||
const sid =
|
||||
typeof config.cartListMode.sourceScreenId === "number"
|
||||
? config.cartListMode.sourceScreenId
|
||||
: parseInt(config.cartListMode.sourceScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "sourceScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(config.followUpActions)) {
|
||||
for (const action of config.followUpActions) {
|
||||
if (action.targetScreenId) {
|
||||
const sid = parseInt(action.targetScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "targetScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.action?.modalScreenId) {
|
||||
const sid = parseInt(config.action.modalScreenId);
|
||||
if (!isNaN(sid) && sid !== screenId) {
|
||||
references.push({
|
||||
componentId: compId,
|
||||
referenceType: "modalScreenId",
|
||||
targetScreenId: sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (layoutData.components) {
|
||||
scanComponents(layoutData.components);
|
||||
}
|
||||
|
||||
if (Array.isArray(layoutData.modals)) {
|
||||
for (const modal of layoutData.modals) {
|
||||
if (modal.components) {
|
||||
scanComponents(modal.components);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linkedScreenIds = [
|
||||
...new Set(references.map((r) => r.targetScreenId)),
|
||||
];
|
||||
|
||||
return { linkedScreenIds, references };
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 화면 배포 (최고관리자 화면을 특정 회사로 복사)
|
||||
* - screen_definitions + screen_layouts_pop 복사
|
||||
* - 화면 간 참조(cartScreenId, sourceScreenId 등) 자동 치환
|
||||
* - numberingRuleId 초기화
|
||||
*/
|
||||
async deployPopScreens(data: {
|
||||
screens: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
groupStructure?: {
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
children?: Array<{
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
}>;
|
||||
screenIds: number[];
|
||||
};
|
||||
targetCompanyCode: string;
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
}): Promise<{
|
||||
deployedScreens: Array<{
|
||||
sourceScreenId: number;
|
||||
newScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
createdGroups?: number;
|
||||
}> {
|
||||
if (data.companyCode !== "*") {
|
||||
throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다.");
|
||||
}
|
||||
|
||||
return await transaction(async (client) => {
|
||||
const screenIdMap = new Map<number, number>();
|
||||
const deployedScreens: Array<{
|
||||
sourceScreenId: number;
|
||||
newScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}> = [];
|
||||
|
||||
// 1단계: screen_definitions 복사
|
||||
for (const screen of data.screens) {
|
||||
const sourceResult = await client.query<any>(
|
||||
`SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screen.sourceScreenId],
|
||||
);
|
||||
|
||||
if (sourceResult.rows.length === 0) {
|
||||
throw new Error(
|
||||
`원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceScreen = sourceResult.rows[0];
|
||||
|
||||
const existingResult = await client.query<any>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
LIMIT 1`,
|
||||
[screen.screenCode, data.targetCompanyCode],
|
||||
);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
throw new Error(
|
||||
`화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newScreenResult = await client.query<any>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_code, screen_name, description, company_code, table_name,
|
||||
is_active, created_by, created_date, updated_by, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
screen.screenCode,
|
||||
screen.screenName,
|
||||
sourceScreen.description,
|
||||
data.targetCompanyCode,
|
||||
sourceScreen.table_name,
|
||||
"Y",
|
||||
data.userId,
|
||||
],
|
||||
);
|
||||
|
||||
const newScreen = newScreenResult.rows[0];
|
||||
screenIdMap.set(screen.sourceScreenId, newScreen.screen_id);
|
||||
|
||||
deployedScreens.push({
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
newScreenId: newScreen.screen_id,
|
||||
screenName: screen.screenName,
|
||||
screenCode: screen.screenCode,
|
||||
});
|
||||
|
||||
logger.info("POP 화면 배포 - screen_definitions 생성", {
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
newScreenId: newScreen.screen_id,
|
||||
targetCompanyCode: data.targetCompanyCode,
|
||||
});
|
||||
}
|
||||
|
||||
// 2단계: screen_layouts_pop 복사 + 참조 치환
|
||||
for (const screen of data.screens) {
|
||||
const newScreenId = screenIdMap.get(screen.sourceScreenId);
|
||||
if (!newScreenId) continue;
|
||||
|
||||
// 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback)
|
||||
let layoutResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
[screen.sourceScreenId],
|
||||
);
|
||||
|
||||
let layoutData = layoutResult.rows[0]?.layout_data;
|
||||
if (!layoutData) {
|
||||
const fallbackResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 LIMIT 1`,
|
||||
[screen.sourceScreenId],
|
||||
);
|
||||
layoutData = fallbackResult.rows[0]?.layout_data;
|
||||
}
|
||||
|
||||
if (!layoutData) {
|
||||
logger.warn("POP 레이아웃 없음, 건너뜀", {
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedLayoutData = this.updatePopLayoutScreenReferences(
|
||||
JSON.parse(JSON.stringify(layoutData)),
|
||||
screenIdMap,
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
|
||||
[
|
||||
newScreenId,
|
||||
data.targetCompanyCode,
|
||||
JSON.stringify(updatedLayoutData),
|
||||
data.userId,
|
||||
],
|
||||
);
|
||||
|
||||
logger.info("POP 레이아웃 복사 완료", {
|
||||
sourceScreenId: screen.sourceScreenId,
|
||||
newScreenId,
|
||||
componentCount: Object.keys(updatedLayoutData.components || {})
|
||||
.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 3단계: 그룹 구조 복사 (groupStructure가 있는 경우)
|
||||
let createdGroups = 0;
|
||||
if (data.groupStructure) {
|
||||
const gs = data.groupStructure;
|
||||
|
||||
// 대상 회사의 POP 루트 그룹 찾기/생성
|
||||
let popRootResult = await client.query<any>(
|
||||
`SELECT id FROM screen_groups
|
||||
WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`,
|
||||
[data.targetCompanyCode],
|
||||
);
|
||||
|
||||
let popRootId: number;
|
||||
if (popRootResult.rows.length > 0) {
|
||||
popRootId = popRootResult.rows[0].id;
|
||||
} else {
|
||||
const createRootResult = await client.query<any>(
|
||||
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order)
|
||||
VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`,
|
||||
[data.targetCompanyCode, data.userId],
|
||||
);
|
||||
popRootId = createRootResult.rows[0].id;
|
||||
}
|
||||
|
||||
// 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가)
|
||||
const mainGroupCode = gs.groupCode + "_COPY";
|
||||
const dupCheck = await client.query<any>(
|
||||
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||
[mainGroupCode, data.targetCompanyCode],
|
||||
);
|
||||
|
||||
let mainGroupId: number;
|
||||
if (dupCheck.rows.length > 0) {
|
||||
mainGroupId = dupCheck.rows[0].id;
|
||||
} else {
|
||||
const mainGroupResult = await client.query<any>(
|
||||
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`,
|
||||
[
|
||||
gs.groupName,
|
||||
mainGroupCode,
|
||||
`POP/${mainGroupCode}`,
|
||||
data.targetCompanyCode,
|
||||
popRootId,
|
||||
data.userId,
|
||||
],
|
||||
);
|
||||
mainGroupId = mainGroupResult.rows[0].id;
|
||||
createdGroups++;
|
||||
}
|
||||
|
||||
// 메인 그룹에 화면 연결
|
||||
for (const oldScreenId of gs.screenIds) {
|
||||
const newScreenId = screenIdMap.get(oldScreenId);
|
||||
if (!newScreenId) continue;
|
||||
await client.query(
|
||||
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
|
||||
VALUES ($1, $2, 'main', 0, 'N', $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[mainGroupId, newScreenId, data.targetCompanyCode],
|
||||
);
|
||||
}
|
||||
|
||||
// 하위 그룹 생성 + 화면 연결
|
||||
if (gs.children) {
|
||||
for (let i = 0; i < gs.children.length; i++) {
|
||||
const child = gs.children[i];
|
||||
const childGroupCode = child.groupCode + "_COPY";
|
||||
|
||||
const childDupCheck = await client.query<any>(
|
||||
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||
[childGroupCode, data.targetCompanyCode],
|
||||
);
|
||||
|
||||
let childGroupId: number;
|
||||
if (childDupCheck.rows.length > 0) {
|
||||
childGroupId = childDupCheck.rows[0].id;
|
||||
} else {
|
||||
const childResult = await client.query<any>(
|
||||
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`,
|
||||
[
|
||||
child.groupName,
|
||||
childGroupCode,
|
||||
`POP/${mainGroupCode}/${childGroupCode}`,
|
||||
data.targetCompanyCode,
|
||||
mainGroupId,
|
||||
data.userId,
|
||||
i,
|
||||
],
|
||||
);
|
||||
childGroupId = childResult.rows[0].id;
|
||||
createdGroups++;
|
||||
}
|
||||
|
||||
for (const oldScreenId of child.screenIds) {
|
||||
const newScreenId = screenIdMap.get(oldScreenId);
|
||||
if (!newScreenId) continue;
|
||||
await client.query(
|
||||
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
|
||||
VALUES ($1, $2, 'main', 0, 'N', $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[childGroupId, newScreenId, data.targetCompanyCode],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("POP 그룹 구조 복사 완료", {
|
||||
targetCompanyCode: data.targetCompanyCode,
|
||||
createdGroups,
|
||||
mainGroupName: gs.groupName,
|
||||
});
|
||||
}
|
||||
|
||||
return { deployedScreens, createdGroups };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POP layout_data 내 screen_id 참조 치환
|
||||
* componentId, connectionId는 레이아웃 내부 식별자이므로 변경 불필요
|
||||
*/
|
||||
private updatePopLayoutScreenReferences(
|
||||
layoutData: any,
|
||||
screenIdMap: Map<number, number>,
|
||||
): any {
|
||||
if (!layoutData?.components) return layoutData;
|
||||
|
||||
const updateComponents = (
|
||||
components: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
const updated: Record<string, any> = {};
|
||||
|
||||
for (const [compId, comp] of Object.entries(components)) {
|
||||
const updatedComp = JSON.parse(JSON.stringify(comp));
|
||||
const config = updatedComp.config || {};
|
||||
|
||||
// cart.cartScreenId (string)
|
||||
if (config.cart?.cartScreenId) {
|
||||
const oldId = parseInt(config.cart.cartScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
config.cart.cartScreenId = String(newId);
|
||||
logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// cartListMode.sourceScreenId (number)
|
||||
if (config.cartListMode?.sourceScreenId) {
|
||||
const oldId =
|
||||
typeof config.cartListMode.sourceScreenId === "number"
|
||||
? config.cartListMode.sourceScreenId
|
||||
: parseInt(config.cartListMode.sourceScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
config.cartListMode.sourceScreenId = newId;
|
||||
logger.info(
|
||||
`POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// followUpActions[].targetScreenId (string)
|
||||
if (Array.isArray(config.followUpActions)) {
|
||||
for (const action of config.followUpActions) {
|
||||
if (action.targetScreenId) {
|
||||
const oldId = parseInt(action.targetScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
action.targetScreenId = String(newId);
|
||||
logger.info(
|
||||
`POP 참조 치환: targetScreenId ${oldId} -> ${newId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// action.modalScreenId (숫자형이면 화면 참조로 간주)
|
||||
if (config.action?.modalScreenId) {
|
||||
const oldId = parseInt(config.action.modalScreenId);
|
||||
if (!isNaN(oldId)) {
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
config.action.modalScreenId = String(newId);
|
||||
logger.info(
|
||||
`POP 참조 치환: modalScreenId ${oldId} -> ${newId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요)
|
||||
if (config.numberingRuleId) {
|
||||
logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`);
|
||||
config.numberingRuleId = "";
|
||||
}
|
||||
if (config.autoGenMappings) {
|
||||
for (const mapping of Object.values(config.autoGenMappings) as any[]) {
|
||||
if (mapping?.numberingRuleId) {
|
||||
logger.info(
|
||||
`POP 채번규칙 초기화: ${mapping.numberingRuleId}`,
|
||||
);
|
||||
mapping.numberingRuleId = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedComp.config = config;
|
||||
updated[compId] = updatedComp;
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
layoutData.components = updateComponents(layoutData.components);
|
||||
|
||||
if (Array.isArray(layoutData.modals)) {
|
||||
for (const modal of layoutData.modals) {
|
||||
if (modal.components) {
|
||||
modal.components = updateComponents(modal.components);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layoutData;
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 인스턴스 export
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 바코드 라벨 백엔드 타입
|
||||
*/
|
||||
|
||||
export interface BarcodeLabelComponent {
|
||||
id: string;
|
||||
type: "text" | "barcode" | "image" | "line" | "rectangle";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
// text
|
||||
content?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
fontWeight?: string;
|
||||
// barcode
|
||||
barcodeType?: string;
|
||||
barcodeValue?: string;
|
||||
showBarcodeText?: boolean;
|
||||
// image
|
||||
imageUrl?: string;
|
||||
objectFit?: string;
|
||||
// line/rectangle
|
||||
lineColor?: string;
|
||||
lineWidth?: number;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export interface BarcodeLabelLayout {
|
||||
width_mm: number;
|
||||
height_mm: number;
|
||||
components: BarcodeLabelComponent[];
|
||||
}
|
||||
|
||||
export interface BarcodeLabelRow {
|
||||
label_id: string;
|
||||
label_name_kor: string;
|
||||
label_name_eng: string | null;
|
||||
description: string | null;
|
||||
width_mm: number;
|
||||
height_mm: number;
|
||||
layout_json: string | null;
|
||||
use_yn: string;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
export interface BarcodeLabelTemplateRow {
|
||||
template_id: string;
|
||||
template_name_kor: string;
|
||||
template_name_eng: string | null;
|
||||
width_mm: number;
|
||||
height_mm: number;
|
||||
layout_json: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user