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

This commit is contained in:
kjs
2026-03-31 11:19:55 +09:00
140 changed files with 27511 additions and 9366 deletions

View File

@@ -1,15 +1,14 @@
/**
* 리포트 관리 컨트롤러
*/
import { Request, Response, NextFunction } from "express";
import { Response, NextFunction } from "express";
import reportService from "../services/reportService";
import {
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
CreateTemplateRequest,
GetReportsParams,
} from "../types/report";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
import path from "path";
import fs from "fs";
import {
@@ -35,92 +34,91 @@ import {
import { WatermarkConfig } from "../types/report";
import bwipjs from "bwip-js";
function getUserInfo(req: AuthenticatedRequest) {
return {
userId: req.user?.userId || "SYSTEM",
companyCode: req.user?.companyCode || "*",
};
}
export class ReportController {
/**
* 리포트 목록 조회
* GET /api/admin/reports
*/
async getReports(req: Request, res: Response, next: NextFunction) {
async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const {
page = "1",
limit = "20",
searchText = "",
reportType = "",
useYn = "Y",
sortBy = "created_at",
sortOrder = "DESC",
page = "1", limit = "20", searchText = "", searchField,
startDate, endDate, reportType = "", useYn = "Y",
sortBy = "created_at", sortOrder = "DESC",
} = req.query;
const result = await reportService.getReports({
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
searchText: searchText as string,
searchField: searchField as GetReportsParams["searchField"],
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
reportType: reportType as string,
useYn: useYn as string,
sortBy: sortBy as string,
sortOrder: sortOrder as "ASC" | "DESC",
});
}, companyCode);
return res.json({
success: true,
data: result,
});
return res.json({ success: true, data: result });
} catch (error) {
return next(error);
}
}
/**
* 리포트 상세 조회
* GET /api/admin/reports/:reportId
*/
async getReportById(req: Request, res: Response, next: NextFunction) {
async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { reportId } = req.params;
const report = await reportService.getReportById(reportId);
const report = await reportService.getReportById(reportId, companyCode);
if (!report) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
}
return res.json({
success: true,
data: report,
});
return res.json({ success: true, data: report });
} catch (error) {
return next(error);
}
}
/**
* 리포트 생성
* POST /api/admin/reports
*/
async createReport(req: Request, res: Response, next: NextFunction) {
async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const data: CreateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
const { companyCode } = getUserInfo(req);
const { menuObjid } = req.params;
const menuObjidNum = parseInt(menuObjid, 10);
// 필수 필드 검증
if (!data.reportNameKor || !data.reportType) {
return res.status(400).json({
success: false,
message: "리포트명과 리포트 타입은 필수입니다.",
});
if (isNaN(menuObjidNum)) {
return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." });
}
const result = await reportService.getReportsByMenuObjid(menuObjidNum, companyCode);
return res.json({ success: true, data: result });
} catch (error) {
return next(error);
}
}
async createReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const data: CreateReportRequest = req.body;
if (!data.reportNameKor || !data.reportType) {
return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다." });
}
data.companyCode = companyCode;
const reportId = await reportService.createReport(data, userId);
return res.status(201).json({
success: true,
data: {
reportId,
},
data: { reportId },
message: "리포트가 생성되었습니다.",
});
} catch (error) {
@@ -128,83 +126,56 @@ export class ReportController {
}
}
/**
* 리포트 수정
* PUT /api/admin/reports/:reportId
*/
async updateReport(req: Request, res: Response, next: NextFunction) {
async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const { reportId } = req.params;
const data: UpdateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
const success = await reportService.updateReport(reportId, data, userId);
const success = await reportService.updateReport(reportId, data, userId, companyCode);
if (!success) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." });
}
return res.json({
success: true,
message: "리포트가 수정되었습니다.",
});
return res.json({ success: true, message: "리포트가 수정되었습니다." });
} catch (error) {
return next(error);
}
}
/**
* 리포트 삭제
* DELETE /api/admin/reports/:reportId
*/
async deleteReport(req: Request, res: Response, next: NextFunction) {
async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { reportId } = req.params;
const success = await reportService.deleteReport(reportId);
const success = await reportService.deleteReport(reportId, companyCode);
if (!success) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
}
return res.json({
success: true,
message: "리포트가 삭제되었습니다.",
});
return res.json({ success: true, message: "리포트가 삭제되었습니다." });
} catch (error) {
return next(error);
}
}
/**
* 리포트 복사
* POST /api/admin/reports/:reportId/copy
*/
async copyReport(req: Request, res: Response, next: NextFunction) {
async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const { reportId } = req.params;
const userId = (req as any).user?.userId || "SYSTEM";
const { newName } = req.body;
const newReportId = await reportService.copyReport(reportId, userId);
const newReportId = await reportService.copyReport(reportId, userId, companyCode, newName);
if (!newReportId) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
}
return res.status(201).json({
success: true,
data: {
reportId: newReportId,
},
data: { reportId: newReportId },
message: "리포트가 복사되었습니다.",
});
} catch (error) {
@@ -212,132 +183,92 @@ export class ReportController {
}
}
/**
* 레이아웃 조회
* GET /api/admin/reports/:reportId/layout
*/
async getLayout(req: Request, res: Response, next: NextFunction) {
async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { reportId } = req.params;
const layout = await reportService.getLayout(reportId);
const layout = await reportService.getLayout(reportId, companyCode);
if (!layout) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." });
}
// components 컬럼에서 JSON 파싱
const parsedComponents = layout.components
? JSON.parse(layout.components)
: null;
const storedData = layout.components;
let layoutData;
// 새 구조 (layoutConfig.pages)인지 확인
if (
parsedComponents &&
parsedComponents.pages &&
Array.isArray(parsedComponents.pages)
storedData &&
typeof storedData === "object" &&
!Array.isArray(storedData) &&
Array.isArray((storedData as Record<string, unknown>).pages)
) {
// pages 배열을 직접 포함하여 반환
const parsed = storedData as Record<string, unknown>;
layoutData = {
...layout,
pages: parsedComponents.pages,
components: [], // 호환성을 위해 빈 배열
pages: parsed.pages,
watermark: parsed.watermark,
components: storedData,
};
} else {
// 기존 구조: components 배열
layoutData = {
...layout,
components: parsedComponents || [],
};
layoutData = { ...layout, components: storedData || [] };
}
return res.json({
success: true,
data: layoutData,
});
return res.json({ success: true, data: layoutData });
} catch (error) {
return next(error);
}
}
/**
* 레이아웃 저장
* PUT /api/admin/reports/:reportId/layout
*/
async saveLayout(req: Request, res: Response, next: NextFunction) {
async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId, companyCode } = getUserInfo(req);
const { reportId } = req.params;
const data: SaveLayoutRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증 (페이지 기반 구조)
if (
!data.layoutConfig ||
!data.layoutConfig.pages ||
data.layoutConfig.pages.length === 0
) {
return res.status(400).json({
success: false,
message: "레이아웃 설정이 필요합니다.",
});
if (!data.layoutConfig?.pages?.length) {
return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." });
}
await reportService.saveLayout(reportId, data, userId);
return res.json({
success: true,
message: "레이아웃이 저장되었습니다.",
});
await reportService.saveLayout(reportId, data, userId, companyCode);
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
} catch (error) {
return next(error);
}
}
/**
* 템플릿 목록 조회
* GET /api/admin/reports/templates
*/
async getTemplates(req: Request, res: Response, next: NextFunction) {
async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const templates = await reportService.getTemplates();
return res.json({
success: true,
data: templates,
});
return res.json({ success: true, data: templates });
} catch (error) {
return next(error);
}
}
/**
* 템플릿 생성
* POST /api/admin/reports/templates
*/
async createTemplate(req: Request, res: Response, next: NextFunction) {
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const data: CreateTemplateRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
const categories = await reportService.getCategories();
return res.json({ success: true, data: categories });
} catch (error) {
return next(error);
}
}
async createTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId } = getUserInfo(req);
const data: CreateTemplateRequest = req.body;
// 필수 필드 검증
if (!data.templateNameKor || !data.templateType) {
return res.status(400).json({
success: false,
message: "템플릿명과 템플릿 타입은 필수입니다.",
});
return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." });
}
const templateId = await reportService.createTemplate(data, userId);
return res.status(201).json({
success: true,
data: {
templateId,
},
data: { templateId },
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
@@ -345,37 +276,23 @@ export class ReportController {
}
}
/**
* 현재 리포트를 템플릿으로 저장
* POST /api/admin/reports/:reportId/save-as-template
*/
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId } = getUserInfo(req);
const { reportId } = req.params;
const { templateNameKor, templateNameEng, description } = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
}
const templateId = await reportService.saveAsTemplate(
reportId,
templateNameKor,
templateNameEng,
description,
userId
reportId, templateNameKor, templateNameEng, description, userId
);
return res.status(201).json({
success: true,
data: {
templateId,
},
data: { templateId },
message: "템플릿이 저장되었습니다.",
});
} catch (error) {
@@ -383,39 +300,20 @@ export class ReportController {
}
}
/**
* 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
* POST /api/admin/reports/templates/create-from-layout
*/
async createTemplateFromLayout(
req: Request,
res: Response,
next: NextFunction
) {
async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { userId } = getUserInfo(req);
const {
templateNameKor,
templateNameEng,
templateType,
description,
layoutConfig,
defaultQueries = [],
templateNameKor, templateNameEng, templateType,
description, layoutConfig, defaultQueries = [],
} = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
}
if (!layoutConfig) {
return res.status(400).json({
success: false,
message: "레이아웃 설정은 필수입니다.",
});
return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." });
}
const templateId = await reportService.createTemplateFromLayout(
@@ -440,78 +338,47 @@ export class ReportController {
}
}
/**
* 템플릿 삭제
* DELETE /api/admin/reports/templates/:templateId
*/
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
async deleteTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { templateId } = req.params;
const success = await reportService.deleteTemplate(templateId);
if (!success) {
return res.status(404).json({
success: false,
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
});
return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." });
}
return res.json({
success: true,
message: "템플릿이 삭제되었습니다.",
});
return res.json({ success: true, message: "템플릿이 삭제되었습니다." });
} catch (error) {
return next(error);
}
}
/**
* 쿼리 실행
* POST /api/admin/reports/:reportId/queries/:queryId/execute
*/
async executeQuery(req: Request, res: Response, next: NextFunction) {
async executeQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { reportId, queryId } = req.params;
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
const result = await reportService.executeQuery(
reportId,
queryId,
parameters,
sqlQuery,
externalConnectionId
reportId, queryId, parameters, sqlQuery, externalConnectionId
);
return res.json({
success: true,
data: result,
});
} catch (error: any) {
return res.status(400).json({
success: false,
message: error.message || "쿼리 실행에 실패했습니다.",
});
return res.json({ success: true, data: result });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
return res.status(400).json({ success: false, message });
}
}
/**
* 외부 DB 연결 목록 조회 (활성화된 것만)
* GET /api/admin/reports/external-connections
*/
async getExternalConnections(
req: Request,
res: Response,
next: NextFunction
) {
async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { companyCode } = getUserInfo(req);
const { ExternalDbConnectionService } = await import(
"../services/externalDbConnectionService"
);
const result = await ExternalDbConnectionService.getConnections({
is_active: "Y",
company_code: req.body.companyCode || "",
company_code: companyCode,
});
return res.json(result);
@@ -520,52 +387,34 @@ export class ReportController {
}
}
/**
* 이미지 파일 업로드
* POST /api/admin/reports/upload-image
*/
async uploadImage(req: Request, res: Response, next: NextFunction) {
async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: "이미지 파일이 필요합니다.",
});
return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." });
}
const companyCode = req.body.companyCode || "SYSTEM";
const { companyCode } = getUserInfo(req);
const file = req.file;
// 파일 저장 경로 생성
const uploadDir = path.join(
process.cwd(),
"uploads",
`company_${companyCode}`,
"reports"
);
const uploadDir = path.join(process.cwd(), "uploads", `company_${companyCode}`, "reports");
// 디렉토리가 없으면 생성
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
const timestamp = Date.now();
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileName = `${timestamp}_${safeFileName}`;
const filePath = path.join(uploadDir, fileName);
// 파일 저장
fs.writeFileSync(filePath, file.buffer);
// 웹에서 접근 가능한 URL 반환
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
return res.json({
success: true,
data: {
fileName,
fileUrl,
fileName, fileUrl,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
@@ -576,11 +425,7 @@ export class ReportController {
}
}
/**
* 컴포넌트 데이터를 WORD(DOCX)로 변환
* POST /api/admin/reports/export-word
*/
async exportToWord(req: Request, res: Response, next: NextFunction) {
async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
@@ -591,22 +436,15 @@ export class ReportController {
});
}
// mm를 twip으로 변환
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
const MM_TO_PX = 4;
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
// px를 twip으로 변환: px -> mm -> twip
const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx)
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
// 쿼리 결과 맵
const queryResultsMap: Record<
string,
{ fields: string[]; rows: Record<string, unknown>[] }
> = queryResults || {};
// 컴포넌트 값 가져오기
const getComponentValue = (component: any): string => {
if (component.queryId && component.fieldName) {
const queryResult = queryResultsMap[component.queryId];
@@ -621,11 +459,9 @@ export class ReportController {
return component.defaultValue || "";
};
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용)
// px * 0.75 * 2 = px * 1.5
// px → half-point (1px = 0.75pt, px * 1.5)
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
const createCellContent = (
component: any,
displayValue: string,
@@ -1557,7 +1393,7 @@ export class ReportController {
const base64 = png.toString("base64");
return `data:image/png;base64,${base64}`;
} catch (error) {
console.error("바코드 생성 오류:", error);
logger.error("바코드 생성 오류:", error);
return null;
}
};
@@ -1891,7 +1727,7 @@ export class ReportController {
children.push(paragraph);
lastBottomY = adjustedY + component.height;
} catch (imgError) {
console.error("이미지 처리 오류:", imgError);
logger.error("이미지 처리 오류:", imgError);
}
}
@@ -2005,7 +1841,7 @@ export class ReportController {
});
children.push(paragraph);
} catch (imgError) {
console.error("서명 이미지 오류:", imgError);
logger.error("서명 이미지 오류:", imgError);
textRuns.push(
new TextRun({
text: "_".repeat(20),
@@ -2083,7 +1919,7 @@ export class ReportController {
});
children.push(paragraph);
} catch (imgError) {
console.error("도장 이미지 오류:", imgError);
logger.error("도장 이미지 오류:", imgError);
textRuns.push(
new TextRun({
text: "(인)",
@@ -2886,7 +2722,7 @@ export class ReportController {
})
);
} catch (imgError) {
console.error("바코드 이미지 오류:", imgError);
logger.error("바코드 이미지 오류:", imgError);
// 바코드 이미지 생성 실패 시 텍스트로 대체
const barcodeValue = component.barcodeValue || "BARCODE";
children.push(
@@ -3164,13 +3000,57 @@ export class ReportController {
return res.send(docxBuffer);
} catch (error: any) {
console.error("WORD 변환 오류:", error);
logger.error("WORD 변환 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "WORD 변환에 실패했습니다.",
});
}
}
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────────
async getSchemaTables(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const tables = await reportService.getSchemaTables();
return res.json({ success: true, data: tables });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "테이블 목록 조회에 실패했습니다.";
logger.error("스키마 테이블 조회 오류:", { error: message });
return res.status(500).json({ success: false, message });
}
}
async getSchemaTableColumns(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { tableName } = req.params;
if (!tableName) {
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
}
const columns = await reportService.getSchemaTableColumns(tableName);
return res.json({ success: true, data: columns });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "컬럼 목록 조회에 실패했습니다.";
logger.error("테이블 컬럼 조회 오류:", { error: message });
return res.status(500).json({ success: false, message });
}
}
async previewVisualQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { visualQuery } = req.body;
if (!visualQuery || !visualQuery.tableName) {
return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." });
}
const result = await reportService.executeVisualQuery(visualQuery);
const generatedSql = reportService.buildVisualQuerySql(visualQuery);
return res.json({ success: true, data: { ...result, sql: generatedSql } });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
logger.error("비주얼 쿼리 미리보기 오류:", { error: message });
return res.status(500).json({ success: false, message });
}
}
}
export default new ReportController();