Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal

This commit is contained in:
kjs
2025-12-23 09:47:57 +09:00
38 changed files with 6336 additions and 687 deletions

View File

@@ -94,7 +94,9 @@ export class CommonCodeController {
sortOrder: code.sort_order,
isActive: code.is_active,
useYn: code.is_active,
companyCode: code.company_code, // 추가
companyCode: code.company_code,
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
depth: code.depth, // 계층구조: 깊이
// 기존 필드명도 유지 (하위 호환성)
code_category: code.code_category,
@@ -103,7 +105,9 @@ export class CommonCodeController {
code_name_eng: code.code_name_eng,
sort_order: code.sort_order,
is_active: code.is_active,
company_code: code.company_code, // 추가
company_code: code.company_code,
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
created_date: code.created_date,
created_by: code.created_by,
updated_date: code.updated_date,
@@ -286,19 +290,17 @@ export class CommonCodeController {
});
}
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "메뉴 OBJID는 필수입니다.",
});
}
// menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
// 공통코드관리 메뉴 OBJID: 1757401858940
const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
const code = await this.commonCodeService.createCode(
categoryCode,
codeData,
userId,
companyCode,
Number(menuObjid)
effectiveMenuObjid
);
return res.status(201).json({
@@ -588,4 +590,129 @@ export class CommonCodeController {
});
}
}
/**
* 계층구조 코드 조회
* GET /api/common-codes/categories/:categoryCode/hierarchy
* Query: parentCodeValue (optional), depth (optional), menuObjid (optional)
*/
async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { parentCodeValue, depth, menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
// parentCodeValue가 빈 문자열이면 최상위 코드 조회
const parentValue = parentCodeValue === '' || parentCodeValue === undefined
? null
: parentCodeValue as string;
const codes = await this.commonCodeService.getHierarchicalCodes(
categoryCode,
parentValue,
depth ? parseInt(depth as string) : undefined,
userCompanyCode,
menuObjidNum
);
// 프론트엔드 형식으로 변환
const transformedData = codes.map((code: any) => ({
codeValue: code.code_value,
codeName: code.code_name,
codeNameEng: code.code_name_eng,
description: code.description,
sortOrder: code.sort_order,
isActive: code.is_active,
parentCodeValue: code.parent_code_value,
depth: code.depth,
// 기존 필드도 유지
code_category: code.code_category,
code_value: code.code_value,
code_name: code.code_name,
code_name_eng: code.code_name_eng,
sort_order: code.sort_order,
is_active: code.is_active,
parent_code_value: code.parent_code_value,
}));
return res.json({
success: true,
data: transformedData,
message: `계층구조 코드 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "계층구조 코드 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 코드 트리 조회
* GET /api/common-codes/categories/:categoryCode/tree
*/
async getCodeTree(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
const result = await this.commonCodeService.getCodeTree(
categoryCode,
userCompanyCode,
menuObjidNum
);
return res.json({
success: true,
data: result,
message: `코드 트리 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "코드 트리 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 자식 코드 존재 여부 확인
* GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children
*/
async hasChildren(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode, codeValue } = req.params;
const companyCode = req.user?.companyCode;
const hasChildren = await this.commonCodeService.hasChildren(
categoryCode,
codeValue,
companyCode
);
return res.json({
success: true,
data: { hasChildren },
message: "자식 코드 확인 완료",
});
} catch (error) {
logger.error(
`자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
error
);
return res.status(500).json({
success: false,
message: "자식 코드 확인 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}

View File

@@ -27,7 +27,12 @@ import {
BorderStyle,
PageOrientation,
convertMillimetersToTwip,
Header,
Footer,
HeadingLevel,
} from "docx";
import { WatermarkConfig } from "../types/report";
import bwipjs from "bwip-js";
export class ReportController {
/**
@@ -1326,6 +1331,82 @@ export class ReportController {
);
}
// Barcode 컴포넌트 (바코드 이미지가 미리 생성되어 전달된 경우)
else if (component.type === "barcode" && component.barcodeImageBase64) {
try {
const base64Data =
component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64;
const imageBuffer = Buffer.from(base64Data, "base64");
result.push(
new ParagraphRef({
children: [
new ImageRunRef({
data: imageBuffer,
transformation: {
width: Math.round(component.width * 0.75),
height: Math.round(component.height * 0.75),
},
type: "png",
}),
],
})
);
} catch (e) {
// 바코드 이미지 생성 실패 시 텍스트로 대체
const barcodeValue = component.barcodeValue || "BARCODE";
result.push(
new ParagraphRef({
children: [
new TextRunRef({
text: `[${barcodeValue}]`,
size: pxToHalfPtFn(12),
font: "맑은 고딕",
}),
],
})
);
}
}
// Checkbox 컴포넌트
else if (component.type === "checkbox") {
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
let isChecked = component.checkboxChecked === true;
if (component.checkboxFieldName && component.queryId && queryResultsMapRef[component.queryId]) {
const qResult = queryResultsMapRef[component.queryId];
if (qResult.rows && qResult.rows.length > 0) {
const row = qResult.rows[0];
const val = row[component.checkboxFieldName];
// truthy/falsy 값 판정
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
isChecked = true;
} else {
isChecked = false;
}
}
}
const checkboxSymbol = isChecked ? "☑" : "☐";
const checkboxLabel = component.checkboxLabel || "";
const labelPosition = component.checkboxLabelPosition || "right";
const displayText = labelPosition === "left"
? `${checkboxLabel} ${checkboxSymbol}`
: `${checkboxSymbol} ${checkboxLabel}`;
result.push(
new ParagraphRef({
children: [
new TextRunRef({
text: displayText.trim(),
size: pxToHalfPtFn(component.fontSize || 14),
font: "맑은 고딕",
color: (component.fontColor || "#374151").replace("#", ""),
}),
],
})
);
}
// Divider - 테이블 셀로 감싸서 정확한 너비 적용
else if (
component.type === "divider" &&
@@ -1354,6 +1435,135 @@ export class ReportController {
return result;
};
// 바코드 이미지 생성 헬퍼 함수
const generateBarcodeImage = async (
component: any,
queryResultsMapRef: Record<string, { fields: string[]; rows: Record<string, unknown>[] }>
): Promise<string | null> => {
try {
const barcodeType = component.barcodeType || "CODE128";
const barcodeColor = (component.barcodeColor || "#000000").replace("#", "");
const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", "");
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
let barcodeValue = component.barcodeValue || "SAMPLE123";
// QR코드 다중 필드 모드
if (
barcodeType === "QR" &&
component.qrUseMultiField &&
component.qrDataFields &&
component.qrDataFields.length > 0 &&
component.queryId &&
queryResultsMapRef[component.queryId]
) {
const qResult = queryResultsMapRef[component.queryId];
if (qResult.rows && qResult.rows.length > 0) {
// 모든 행 포함 모드
if (component.qrIncludeAllRows) {
const allRowsData: Record<string, string>[] = [];
qResult.rows.forEach((row) => {
const rowData: Record<string, string> = {};
component.qrDataFields!.forEach((field: { fieldName: string; label: string }) => {
if (field.fieldName && field.label) {
const val = row[field.fieldName];
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
}
});
allRowsData.push(rowData);
});
barcodeValue = JSON.stringify(allRowsData);
} else {
// 단일 행 (첫 번째 행만)
const row = qResult.rows[0];
const jsonData: Record<string, string> = {};
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
if (field.fieldName && field.label) {
const val = row[field.fieldName];
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
}
});
barcodeValue = JSON.stringify(jsonData);
}
}
}
// 단일 필드 바인딩
else if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) {
const qResult = queryResultsMapRef[component.queryId];
if (qResult.rows && qResult.rows.length > 0) {
// QR코드 + 모든 행 포함
if (barcodeType === "QR" && component.qrIncludeAllRows) {
const allValues = qResult.rows
.map((row) => {
const val = row[component.barcodeFieldName!];
return val !== null && val !== undefined ? String(val) : "";
})
.filter((v) => v !== "");
barcodeValue = JSON.stringify(allValues);
} else {
// 단일 행 (첫 번째 행만)
const row = qResult.rows[0];
const val = row[component.barcodeFieldName];
if (val !== null && val !== undefined) {
barcodeValue = String(val);
}
}
}
}
// bwip-js 바코드 타입 매핑
const bcidMap: Record<string, string> = {
"CODE128": "code128",
"CODE39": "code39",
"EAN13": "ean13",
"EAN8": "ean8",
"UPC": "upca",
"QR": "qrcode",
};
const bcid = bcidMap[barcodeType] || "code128";
const isQR = barcodeType === "QR";
// 바코드 옵션 설정
const options: any = {
bcid: bcid,
text: barcodeValue,
scale: 3,
includetext: !isQR && component.showBarcodeText !== false,
textxalign: "center",
barcolor: barcodeColor,
backgroundcolor: barcodeBackground,
};
// QR 코드 옵션
if (isQR) {
options.eclevel = component.qrErrorCorrectionLevel || "M";
}
// 바코드 이미지 생성
const png = await bwipjs.toBuffer(options);
const base64 = png.toString("base64");
return `data:image/png;base64,${base64}`;
} catch (error) {
console.error("바코드 생성 오류:", error);
return null;
}
};
// 모든 페이지의 바코드 컴포넌트에 대해 이미지 생성
for (const page of layoutConfig.pages) {
if (page.components) {
for (const component of page.components) {
if (component.type === "barcode") {
const barcodeImage = await generateBarcodeImage(component, queryResultsMap);
if (barcodeImage) {
component.barcodeImageBase64 = barcodeImage;
}
}
}
}
}
// 섹션 생성 (페이지별)
const sortedPages = layoutConfig.pages.sort(
(a: any, b: any) => a.page_order - b.page_order
@@ -2624,6 +2834,129 @@ export class ReportController {
lastBottomY = adjustedY + component.height;
}
// Barcode 컴포넌트
else if (component.type === "barcode") {
if (component.barcodeImageBase64) {
try {
const base64Data =
component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64;
const imageBuffer = Buffer.from(base64Data, "base64");
// spacing을 위한 빈 paragraph
if (spacingBefore > 0) {
children.push(
new Paragraph({
spacing: { before: spacingBefore, after: 0 },
children: [],
})
);
}
children.push(
new Paragraph({
indent: { left: indentLeft },
children: [
new ImageRun({
data: imageBuffer,
transformation: {
width: Math.round(component.width * 0.75),
height: Math.round(component.height * 0.75),
},
type: "png",
}),
],
})
);
} catch (imgError) {
console.error("바코드 이미지 오류:", imgError);
// 바코드 이미지 생성 실패 시 텍스트로 대체
const barcodeValue = component.barcodeValue || "BARCODE";
children.push(
new Paragraph({
spacing: { before: spacingBefore, after: 0 },
indent: { left: indentLeft },
children: [
new TextRun({
text: `[${barcodeValue}]`,
size: pxToHalfPt(12),
font: "맑은 고딕",
}),
],
})
);
}
} else {
// 바코드 이미지가 없는 경우 텍스트로 대체
const barcodeValue = component.barcodeValue || "BARCODE";
children.push(
new Paragraph({
spacing: { before: spacingBefore, after: 0 },
indent: { left: indentLeft },
children: [
new TextRun({
text: `[${barcodeValue}]`,
size: pxToHalfPt(12),
font: "맑은 고딕",
}),
],
})
);
}
lastBottomY = adjustedY + component.height;
}
// Checkbox 컴포넌트
else if (component.type === "checkbox") {
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
let isChecked = component.checkboxChecked === true;
if (component.checkboxFieldName && component.queryId && queryResultsMap[component.queryId]) {
const qResult = queryResultsMap[component.queryId];
if (qResult.rows && qResult.rows.length > 0) {
const row = qResult.rows[0];
const val = row[component.checkboxFieldName];
// truthy/falsy 값 판정
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
isChecked = true;
} else {
isChecked = false;
}
}
}
const checkboxSymbol = isChecked ? "☑" : "☐";
const checkboxLabel = component.checkboxLabel || "";
const labelPosition = component.checkboxLabelPosition || "right";
const displayText = labelPosition === "left"
? `${checkboxLabel} ${checkboxSymbol}`
: `${checkboxSymbol} ${checkboxLabel}`;
// spacing을 위한 빈 paragraph
if (spacingBefore > 0) {
children.push(
new Paragraph({
spacing: { before: spacingBefore, after: 0 },
children: [],
})
);
}
children.push(
new Paragraph({
indent: { left: indentLeft },
children: [
new TextRun({
text: displayText.trim(),
size: pxToHalfPt(component.fontSize || 14),
font: "맑은 고딕",
color: (component.fontColor || "#374151").replace("#", ""),
}),
],
})
);
lastBottomY = adjustedY + component.height;
}
// Table 컴포넌트
else if (component.type === "table" && component.queryId) {
const queryResult = queryResultsMap[component.queryId];
@@ -2734,6 +3067,36 @@ export class ReportController {
children.push(new Paragraph({ children: [] }));
}
// 워터마크 헤더 생성 (전체 페이지 공유 워터마크)
const watermark: WatermarkConfig | undefined = layoutConfig.watermark;
let headers: { default?: Header } | undefined;
if (watermark?.enabled && watermark.type === "text" && watermark.text) {
// 워터마크 색상을 hex로 변환 (alpha 적용)
const opacity = watermark.opacity ?? 0.3;
const fontColor = watermark.fontColor || "#CCCCCC";
// hex 색상에서 # 제거
const cleanColor = fontColor.replace("#", "");
headers = {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({
text: watermark.text,
size: (watermark.fontSize || 48) * 2, // Word는 half-point 사용
color: cleanColor,
bold: true,
}),
],
}),
],
}),
};
}
return {
properties: {
page: {
@@ -2753,6 +3116,7 @@ export class ReportController {
},
},
},
headers,
children,
};
});