Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user