Files
vexplor_dev/backend-node/src/services/reportService.ts
kjs 9737805bf9 feat: Enhance outbound and receiving update functionalities with inventory adjustments
- Updated the `update` function in the outbound controller to include detailed inventory adjustments when modifying outbound records, ensuring accurate stock management.
- Implemented rollback mechanisms for both outbound and receiving updates to maintain data integrity in case of errors.
- Enhanced the `deleteOutbound` function to include inventory recovery and historical logging for deleted outbound records.
- Introduced a new utility function `adjustInventory` to handle inventory changes consistently across different controllers.
- Improved error handling and logging for better traceability during outbound and receiving operations.
2026-04-20 14:14:24 +09:00

1227 lines
40 KiB
TypeScript

import { v4 as uuidv4 } from "uuid";
import { query, queryOne, transaction } from "../database/db";
import {
ReportMaster,
ReportLayout,
ReportQuery,
ReportTemplate,
ReportDetail,
GetReportsParams,
GetReportsResponse,
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
GetTemplatesResponse,
CreateTemplateRequest,
VisualQuery,
} from "../types/report";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { ExternalDbConnectionService } from "./externalDbConnectionService";
import { logger } from "../utils/logger";
const REPORT_TYPE_LABELS: Record<string, string> = {
ORDER: "발주서",
INVOICE: "청구서",
STATEMENT: "거래명세서",
RECEIPT: "영수증",
BASIC: "기본",
};
const ALLOWED_SORT_COLUMNS = [
"created_at",
"updated_at",
"report_name_kor",
"report_name_eng",
"report_type",
"use_yn",
] as const;
const ALLOWED_SORT_ORDERS = ["ASC", "DESC"] as const;
const DEFAULT_MARGINS = { top: 20, bottom: 20, left: 20, right: 20 };
const DEFAULT_CANVAS_WIDTH = 210;
const DEFAULT_CANVAS_HEIGHT = 297;
function generateReportId(): string {
return `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
}
function generateLayoutId(): string {
return `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
}
function generateQueryId(): string {
return `QRY_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
}
function generateTemplateId(): string {
return `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
}
function findTypeCodesByLabel(searchText: string): string[] {
const lower = searchText.toLowerCase();
return Object.entries(REPORT_TYPE_LABELS)
.filter(([code, label]) => label.includes(searchText) || code.toLowerCase().includes(lower))
.map(([code]) => code);
}
function parseJsonComponents(raw: string | Record<string, unknown> | null): Record<string, unknown> | null {
if (raw === null || raw === undefined) return null;
if (typeof raw === "string") {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
return raw;
}
function sanitizeSortBy(sortBy: string): string {
if ((ALLOWED_SORT_COLUMNS as readonly string[]).includes(sortBy)) {
return sortBy;
}
return "created_at";
}
function sanitizeSortOrder(sortOrder: string): "ASC" | "DESC" {
const upper = sortOrder.toUpperCase();
if ((ALLOWED_SORT_ORDERS as readonly string[]).includes(upper)) {
return upper as "ASC" | "DESC";
}
return "DESC";
}
export class ReportService {
private validateQuerySafety(sql: string): void {
const dangerousKeywords = [
"DELETE",
"DROP",
"TRUNCATE",
"INSERT",
"UPDATE",
"ALTER",
"CREATE",
"REPLACE",
"MERGE",
"GRANT",
"REVOKE",
"EXECUTE",
"EXEC",
"CALL",
];
const upperSql = sql.toUpperCase().trim();
for (const keyword of dangerousKeywords) {
const regex = new RegExp(`\\b${keyword}\\b`, "i");
if (regex.test(upperSql)) {
throw new Error(
`보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.`
);
}
}
if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) {
throw new Error(
"SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다."
);
}
const semicolonCount = (sql.match(/;/g) || []).length;
if (
semicolonCount > 1 ||
(semicolonCount === 1 && !sql.trim().endsWith(";"))
) {
throw new Error(
"보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다."
);
}
}
async getReports(params: GetReportsParams, companyCode: string): Promise<GetReportsResponse> {
const {
page = 1,
limit = 20,
searchText = "",
searchField,
startDate,
endDate,
reportType = "",
useYn = "Y",
sortBy = "created_at",
sortOrder = "DESC",
} = params;
const offset = (page - 1) * limit;
const safeSortBy = sanitizeSortBy(sortBy);
const safeSortOrder = sanitizeSortOrder(sortOrder);
const conditions: string[] = [];
const values: (string | number)[] = [];
let paramIndex = 1;
this.applyCompanyCodeFilter(conditions, values, paramIndex, companyCode, "rm");
paramIndex = values.length + 1;
if (useYn) {
conditions.push(`rm.use_yn = $${paramIndex++}`);
values.push(useYn);
}
paramIndex = this.applySearchConditions(
conditions, values, paramIndex, searchText, searchField, startDate, endDate
);
if (reportType) {
conditions.push(`rm.report_type = $${paramIndex++}`);
values.push(reportType);
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const countResult = await queryOne<{ total: string }>(
`SELECT COUNT(*) as total FROM report_master rm ${whereClause}`,
values
);
const total = parseInt(countResult?.total || "0", 10);
const listQuery = `
SELECT
rm.report_id, rm.report_name_kor, rm.report_name_eng,
rm.template_id, rt.template_name_kor AS template_name,
rm.report_type, rm.company_code, rm.description, rm.use_yn,
rm.created_at, rm.created_by, rm.updated_at, rm.updated_by
FROM report_master rm
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
${whereClause}
ORDER BY rm.${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`;
const items = await query<ReportMaster>(listQuery, [...values, limit, offset]);
const { typeSummary, allTypes, recentActivity, recentTotal } =
await this.getReportStatistics(companyCode);
return {
items, total, page, limit,
typeSummary, allTypes, recentActivity, recentTotal,
};
}
private applyCompanyCodeFilter(
conditions: string[],
values: (string | number)[],
paramIndex: number,
companyCode: string,
alias: string
): void {
if (companyCode !== "*") {
conditions.push(`${alias}.company_code = $${paramIndex}`);
values.push(companyCode);
}
}
private applySearchConditions(
conditions: string[],
values: (string | number)[],
paramIndex: number,
searchText: string,
searchField?: string,
startDate?: string,
endDate?: string
): number {
const isDateRangeSearch =
(searchField === "created_at" || searchField === "updated_at") && startDate && endDate;
if (isDateRangeSearch) {
const dateColumn = searchField === "created_at"
? "rm.created_at"
: "COALESCE(rm.updated_at, rm.created_at)";
conditions.push(`${dateColumn} >= $${paramIndex}::date`);
values.push(startDate!);
paramIndex++;
conditions.push(`${dateColumn} < ($${paramIndex}::date + INTERVAL '1 day')`);
values.push(endDate!);
paramIndex++;
} else if (searchText) {
paramIndex = this.applyTextSearch(conditions, values, paramIndex, searchText, searchField);
}
return paramIndex;
}
private applyTextSearch(
conditions: string[],
values: (string | number)[],
paramIndex: number,
searchText: string,
searchField?: string
): number {
if (searchField === "created_by") {
conditions.push(`rm.created_by LIKE $${paramIndex}`);
values.push(`%${searchText}%`);
paramIndex++;
} else if (searchField === "report_type") {
const matchedCodes = findTypeCodesByLabel(searchText);
if (matchedCodes.length > 0) {
const placeholders = matchedCodes.map(() => `$${paramIndex++}`).join(", ");
conditions.push(`rm.report_type IN (${placeholders})`);
values.push(...matchedCodes);
} else {
conditions.push(`rm.report_type LIKE $${paramIndex}`);
values.push(`%${searchText}%`);
paramIndex++;
}
} else if (searchField === "updated_at") {
conditions.push(`CAST(rm.updated_at AS TEXT) LIKE $${paramIndex}`);
values.push(`%${searchText}%`);
paramIndex++;
} else {
conditions.push(
`(rm.report_name_kor LIKE $${paramIndex} OR rm.report_name_eng LIKE $${paramIndex})`
);
values.push(`%${searchText}%`);
paramIndex++;
}
return paramIndex;
}
private async getReportStatistics(companyCode: string) {
const companyFilter = companyCode !== "*" ? " AND company_code = $1" : "";
const companyParams = companyCode !== "*" ? [companyCode] : [];
const typeSummaryRows = await query<{ report_type: string; count: string }>(
`SELECT report_type, COUNT(*) as count
FROM report_master
WHERE use_yn = 'Y' AND report_type IS NOT NULL AND report_type != ''${companyFilter}
GROUP BY report_type
ORDER BY count DESC`,
companyParams
);
const typeSummary = typeSummaryRows.map((r) => ({
type: r.report_type,
count: parseInt(r.count, 10),
}));
const allTypes = typeSummary.map((t) => t.type).sort();
const recentActivityRows = await query<{ date_label: string; date_raw: string; count: string }>(
`SELECT TO_CHAR(COALESCE(updated_at, created_at), 'MM/DD') AS date_label,
MAX(COALESCE(updated_at, created_at)) AS date_raw,
COUNT(*) AS count
FROM report_master
WHERE use_yn = 'Y'
AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'${companyFilter}
GROUP BY date_label
ORDER BY count DESC, date_raw DESC
LIMIT 3`,
companyParams
);
const recentActivity = recentActivityRows
.map((r) => ({ date: r.date_label, count: parseInt(r.count, 10) }))
.sort((a, b) => a.count - b.count);
const recentCountResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) AS count FROM report_master
WHERE use_yn = 'Y'
AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'${companyFilter}`,
companyParams
);
const recentTotal = parseInt(recentCountResult?.count || "0", 10);
return { typeSummary, allTypes, recentActivity, recentTotal };
}
async getReportById(reportId: string, companyCode: string): Promise<ReportDetail | null> {
const companyCondition = companyCode !== "*" ? " AND company_code = $2" : "";
const reportParams = companyCode !== "*" ? [reportId, companyCode] : [reportId];
const report = await queryOne<ReportMaster>(
`SELECT report_id, report_name_kor, report_name_eng, template_id,
report_type, company_code, description, use_yn,
created_at, created_by, updated_at, updated_by
FROM report_master
WHERE report_id = $1${companyCondition}`,
reportParams
);
if (!report) return null;
const layout = await this.getLayoutInternal(reportId);
const queries = await query<ReportQuery>(
`SELECT query_id, report_id, query_name, query_type, sql_query,
parameters, external_connection_id, display_order,
created_at, created_by, updated_at, updated_by
FROM report_query
WHERE report_id = $1
ORDER BY display_order, created_at`,
[reportId]
);
const menuMappings = await query<{ menu_objid: number }>(
`SELECT menu_objid FROM report_menu_mapping WHERE report_id = $1 ORDER BY created_at`,
[reportId]
);
const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || [];
return { report, layout, queries: queries || [], menuObjids };
}
async createReport(
data: CreateReportRequest,
userId: string
): Promise<string> {
const reportId = generateReportId();
return transaction(async (client) => {
await client.query(
`INSERT INTO report_master (
report_id, report_name_kor, report_name_eng, template_id,
report_type, company_code, description, use_yn, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8)`,
[
reportId, data.reportNameKor, data.reportNameEng || null,
data.templateId || null, data.reportType,
data.companyCode || null, data.description || null, userId,
]
);
if (data.templateId) {
await this.createLayoutFromTemplate(client, data.templateId, reportId, userId);
}
return reportId;
});
}
private async createLayoutFromTemplate(
client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record<string, unknown>[] }> },
templateId: string,
reportId: string,
userId: string
): Promise<void> {
const template = await client.query(
`SELECT layout_config FROM report_template WHERE template_id = $1`,
[templateId]
);
if (template.rows.length === 0 || !template.rows[0].layout_config) return;
const layoutConfig = JSON.parse(template.rows[0].layout_config as string);
await client.query(
`INSERT INTO report_layout (
layout_id, report_id, canvas_width, canvas_height, page_orientation,
margin_top, margin_bottom, margin_left, margin_right, components, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
generateLayoutId(), reportId,
layoutConfig.width || DEFAULT_CANVAS_WIDTH,
layoutConfig.height || DEFAULT_CANVAS_HEIGHT,
layoutConfig.orientation || "portrait",
DEFAULT_MARGINS.top, DEFAULT_MARGINS.bottom,
DEFAULT_MARGINS.left, DEFAULT_MARGINS.right,
JSON.stringify([]), userId,
]
);
}
async updateReport(
reportId: string,
data: UpdateReportRequest,
userId: string,
companyCode: string
): Promise<boolean> {
const setClauses: string[] = [];
const values: (string | number | null)[] = [];
let paramIndex = 1;
const fieldMap: Array<[keyof UpdateReportRequest, string]> = [
["reportNameKor", "report_name_kor"],
["reportNameEng", "report_name_eng"],
["reportType", "report_type"],
["description", "description"],
["useYn", "use_yn"],
];
for (const [key, column] of fieldMap) {
if (data[key] !== undefined) {
setClauses.push(`${column} = $${paramIndex++}`);
values.push(data[key] as string);
}
}
if (setClauses.length === 0) return false;
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
setClauses.push(`updated_by = $${paramIndex++}`);
values.push(userId);
values.push(reportId);
let whereClause = `WHERE report_id = $${paramIndex}`;
if (companyCode !== "*") {
paramIndex++;
values.push(companyCode);
whereClause += ` AND company_code = $${paramIndex}`;
}
await query(`UPDATE report_master SET ${setClauses.join(", ")} ${whereClause}`, values);
return true;
}
async deleteReport(reportId: string, companyCode: string): Promise<boolean> {
return transaction(async (client) => {
const companyCondition = companyCode !== "*" ? " AND company_code = $2" : "";
const params = companyCode !== "*" ? [reportId, companyCode] : [reportId];
const existing = await client.query(
`SELECT report_id FROM report_master WHERE report_id = $1${companyCondition}`,
params
);
if (existing.rows.length === 0) return false;
await client.query(`DELETE FROM report_menu_mapping WHERE report_id = $1`, [reportId]);
await client.query(`DELETE FROM report_query WHERE report_id = $1`, [reportId]);
await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [reportId]);
const result = await client.query(
`DELETE FROM report_master WHERE report_id = $1${companyCondition}`,
params
);
return (result.rowCount ?? 0) > 0;
});
}
async copyReport(
reportId: string,
userId: string,
companyCode: string,
newName?: string
): Promise<string | null> {
return transaction(async (client) => {
const companyCondition = companyCode !== "*" ? " AND company_code = $2" : "";
const params = companyCode !== "*" ? [reportId, companyCode] : [reportId];
const originalResult = await client.query(
`SELECT * FROM report_master WHERE report_id = $1${companyCondition}`,
params
);
if (originalResult.rows.length === 0) return null;
const original = originalResult.rows[0];
const newReportId = generateReportId();
await client.query(
`INSERT INTO report_master (
report_id, report_name_kor, report_name_eng, template_id,
report_type, company_code, description, use_yn, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
newReportId,
newName || `${original.report_name_kor} (복사)`,
original.report_name_eng ? `${original.report_name_eng} (Copy)` : null,
original.template_id,
original.report_type,
original.company_code,
original.description,
original.use_yn,
userId,
]
);
await this.copyLayoutData(client, reportId, newReportId, userId);
await this.copyQueryData(client, reportId, newReportId, userId);
return newReportId;
});
}
private async copyLayoutData(
client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record<string, unknown>[] }> },
sourceReportId: string,
targetReportId: string,
userId: string
): Promise<void> {
const layoutResult = await client.query(
`SELECT * FROM report_layout WHERE report_id = $1`,
[sourceReportId]
);
if (layoutResult.rows.length === 0) return;
const originalLayout = layoutResult.rows[0];
const componentsData = typeof originalLayout.components === "string"
? originalLayout.components
: JSON.stringify(originalLayout.components);
await client.query(
`INSERT INTO report_layout (
layout_id, report_id, canvas_width, canvas_height, page_orientation,
margin_top, margin_bottom, margin_left, margin_right, components, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
generateLayoutId(), targetReportId,
originalLayout.canvas_width, originalLayout.canvas_height,
originalLayout.page_orientation,
originalLayout.margin_top, originalLayout.margin_bottom,
originalLayout.margin_left, originalLayout.margin_right,
componentsData, userId,
]
);
}
private async copyQueryData(
client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record<string, unknown>[] }> },
sourceReportId: string,
targetReportId: string,
userId: string
): Promise<void> {
const queriesResult = await client.query(
`SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order`,
[sourceReportId]
);
for (const originalQuery of queriesResult.rows) {
await client.query(
`INSERT INTO report_query (
query_id, report_id, query_name, query_type, sql_query,
parameters, external_connection_id, display_order, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
generateQueryId(), targetReportId,
originalQuery.query_name, originalQuery.query_type,
originalQuery.sql_query, JSON.stringify(originalQuery.parameters),
originalQuery.external_connection_id || null,
originalQuery.display_order, userId,
]
);
}
}
async getLayout(reportId: string, companyCode?: string): Promise<ReportLayout | null> {
if (companyCode && companyCode !== "*") {
const ownerCheck = await queryOne<{ report_id: string }>(
`SELECT report_id FROM report_master WHERE report_id = $1 AND company_code = $2`,
[reportId, companyCode]
);
if (!ownerCheck) return null;
}
return this.getLayoutInternal(reportId);
}
private async getLayoutInternal(reportId: string): Promise<ReportLayout | null> {
const layoutRaw = await queryOne<ReportLayout>(
`SELECT layout_id, report_id, canvas_width, canvas_height,
page_orientation, margin_top, margin_bottom, margin_left, margin_right,
components, created_at, created_by, updated_at, updated_by
FROM report_layout
WHERE report_id = $1`,
[reportId]
);
if (!layoutRaw) return null;
return {
...layoutRaw,
components: parseJsonComponents(layoutRaw.components as string | Record<string, unknown> | null) as unknown as string,
};
}
async saveLayout(
reportId: string,
data: SaveLayoutRequest,
userId: string,
companyCode: string
): Promise<boolean> {
return transaction(async (client) => {
if (companyCode !== "*") {
const ownerCheck = await client.query(
`SELECT report_id FROM report_master WHERE report_id = $1 AND company_code = $2`,
[reportId, companyCode]
);
if (ownerCheck.rows.length === 0) return false;
}
const firstPage = data.layoutConfig.pages[0];
const canvasWidth = firstPage?.width || DEFAULT_CANVAS_WIDTH;
const canvasHeight = firstPage?.height || DEFAULT_CANVAS_HEIGHT;
const pageOrientation = canvasWidth > canvasHeight ? "landscape" : "portrait";
const margins = firstPage?.margins || DEFAULT_MARGINS;
await this.upsertLayout(client, reportId, {
canvasWidth, canvasHeight, pageOrientation, margins,
componentsJson: JSON.stringify(data.layoutConfig),
userId,
});
if (data.queries && data.queries.length > 0) {
await this.replaceQueries(client, reportId, data.queries, userId);
}
if (data.menuObjids !== undefined) {
await this.replaceMenuMappings(client, reportId, data.menuObjids, companyCode, userId);
}
await client.query(
`UPDATE report_master SET updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE report_id = $2`,
[userId, reportId]
);
return true;
});
}
private async upsertLayout(
client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record<string, unknown>[] }> },
reportId: string,
opts: {
canvasWidth: number; canvasHeight: number; pageOrientation: string;
margins: { top: number; bottom: number; left: number; right: number };
componentsJson: string; userId: string;
}
): Promise<void> {
const existing = await client.query(
`SELECT layout_id FROM report_layout WHERE report_id = $1`,
[reportId]
);
const layoutParams = [
opts.canvasWidth, opts.canvasHeight, opts.pageOrientation,
opts.margins.top, opts.margins.bottom, opts.margins.left, opts.margins.right,
opts.componentsJson, opts.userId,
];
if (existing.rows.length > 0) {
await client.query(
`UPDATE report_layout SET
canvas_width = $1, canvas_height = $2, page_orientation = $3,
margin_top = $4, margin_bottom = $5, margin_left = $6, margin_right = $7,
components = $8, updated_at = CURRENT_TIMESTAMP, updated_by = $9
WHERE report_id = $10`,
[...layoutParams, reportId]
);
} else {
await client.query(
`INSERT INTO report_layout (
layout_id, report_id, canvas_width, canvas_height, page_orientation,
margin_top, margin_bottom, margin_left, margin_right, components, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[generateLayoutId(), reportId, ...layoutParams]
);
}
}
private async replaceQueries(
client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record<string, unknown>[] }> },
reportId: string,
queries: NonNullable<SaveLayoutRequest["queries"]>,
userId: string
): Promise<void> {
await client.query(`DELETE FROM report_query WHERE report_id = $1`, [reportId]);
for (let i = 0; i < queries.length; i++) {
const q = queries[i];
await client.query(
`INSERT INTO report_query (
query_id, report_id, query_name, query_type, sql_query,
parameters, external_connection_id, display_order, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
q.id, reportId, q.name, q.type, q.sqlQuery,
JSON.stringify(q.parameters),
q.externalConnectionId || null,
i, userId,
]
);
}
}
private async replaceMenuMappings(
client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record<string, unknown>[] }> },
reportId: string,
menuObjids: number[],
companyCode: string,
userId: string
): Promise<void> {
await client.query(`DELETE FROM report_menu_mapping WHERE report_id = $1`, [reportId]);
if (menuObjids.length === 0) return;
const reportResult = await client.query(
`SELECT company_code FROM report_master WHERE report_id = $1`,
[reportId]
);
const resolvedCompanyCode = (reportResult.rows[0]?.company_code as string) || companyCode;
for (const menuObjid of menuObjids) {
await client.query(
`INSERT INTO report_menu_mapping (report_id, menu_objid, company_code, created_by)
VALUES ($1, $2, $3, $4)`,
[reportId, menuObjid, resolvedCompanyCode, userId]
);
}
}
async executeQuery(
reportId: string,
queryId: string,
parameters: Record<string, string | number | null>,
sqlQuery?: string,
externalConnectionId?: number | null
): Promise<{ fields: string[]; rows: Record<string, unknown>[] }> {
let sqlToExecute: string;
let queryParameters: string[] = [];
let connectionId: number | null = externalConnectionId ?? null;
if (sqlQuery) {
sqlToExecute = sqlQuery;
queryParameters = this.extractUniqueParams(sqlQuery);
} else {
const storedQuery = await queryOne<ReportQuery>(
`SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`,
[queryId, reportId]
);
if (!storedQuery) {
throw new Error("쿼리를 찾을 수 없습니다.");
}
sqlToExecute = storedQuery.sql_query;
queryParameters = Array.isArray(storedQuery.parameters) ? storedQuery.parameters : [];
connectionId = storedQuery.external_connection_id;
}
this.validateQuerySafety(sqlToExecute);
const paramArray = queryParameters.map((param) => parameters[param] ?? null);
const { sql: finalSql, params: finalParams } =
this.buildPreviewSqlIfNeeded(sqlToExecute, queryParameters, paramArray);
try {
const result = connectionId
? await this.executeOnExternalDb(finalSql, connectionId)
: await query(finalSql, finalParams);
const fields = result.length > 0 ? Object.keys(result[0]) : [];
return { fields, rows: result as Record<string, unknown>[] };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "알 수 없는 오류";
throw new Error(`쿼리 실행 오류: ${message}`);
}
}
private extractUniqueParams(sql: string): string[] {
const matches = sql.match(/\$\d+/g);
if (!matches) return [];
return [...new Set(matches)];
}
private buildPreviewSqlIfNeeded(
sql: string,
queryParameters: string[],
paramArray: (string | number | null)[]
): { sql: string; params: (string | number | null)[] } {
const allParamsNull = paramArray.length > 0 && paramArray.every((p) => p === null);
if (!allParamsNull) return { sql, params: paramArray };
let previewSql = sql;
for (const param of queryParameters) {
const escapedParam = param.replace("$", "\\$");
const conditionPattern = new RegExp(
`\\S+\\s*(?:=|!=|<>|>=|<=|>|<|LIKE|ILIKE|IN\\s*\\()\\s*${escapedParam}\\)?`,
"gi"
);
previewSql = previewSql.replace(conditionPattern, "TRUE");
}
if (!/\bLIMIT\b/i.test(previewSql)) {
previewSql = previewSql.replace(/;?\s*$/, " LIMIT 100");
}
return { sql: previewSql, params: [] };
}
private async executeOnExternalDb(
sql: string,
connectionId: number
): Promise<Record<string, unknown>[]> {
const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error("외부 DB 연결 정보를 찾을 수 없습니다.");
}
const connection = connectionResult.data;
const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type,
{
host: connection.host,
port: connection.port,
database: connection.database_name,
user: connection.username,
password: connection.password,
connectionTimeoutMillis: connection.connection_timeout || 30000,
queryTimeoutMillis: connection.query_timeout || 30000,
},
connectionId
);
await connector.connect();
try {
const queryResult = await connector.executeQuery(sql);
return (queryResult.rows || []) as Record<string, unknown>[];
} finally {
await connector.disconnect();
}
}
async getReportsByMenuObjid(
menuObjid: number,
companyCode: string
): Promise<{ items: ReportMaster[]; total: number }> {
// 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고,
// 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임.
const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : "";
const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid];
const items = await query<ReportMaster>(
`SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng,
rm.template_id, rt.template_name_kor AS template_name,
rm.report_type, rm.company_code, rm.description, rm.use_yn,
rm.created_at, rm.created_by, rm.updated_at, rm.updated_by
FROM report_master rm
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
WHERE rm.use_yn = 'Y'${companyFilter}
AND (
NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id)
OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1)
)
ORDER BY rm.report_name_kor ASC`,
params
);
return { items: items || [], total: (items || []).length };
}
async getTemplates(): Promise<GetTemplatesResponse> {
const templateQuery = `
SELECT
template_id,
template_name_kor,
template_name_eng,
template_type,
is_system,
thumbnail_url,
description,
layout_config,
default_queries,
use_yn,
sort_order,
created_at,
created_by,
updated_at,
updated_by
FROM report_template
WHERE use_yn = 'Y'
ORDER BY is_system DESC, sort_order ASC
`;
const templates = await query<ReportTemplate>(templateQuery);
const system = templates.filter((t) => t.is_system === "Y");
const custom = templates.filter((t) => t.is_system === "N");
return { system, custom };
}
async createTemplate(
data: CreateTemplateRequest,
userId: string
): Promise<string> {
const templateId = generateTemplateId();
const insertQuery = `
INSERT INTO report_template (
template_id,
template_name_kor,
template_name_eng,
template_type,
is_system,
description,
layout_config,
default_queries,
use_yn,
created_by
) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', $8)
`;
await query(insertQuery, [
templateId,
data.templateNameKor,
data.templateNameEng || null,
data.templateType,
data.description || null,
data.layoutConfig ? JSON.stringify(data.layoutConfig) : null,
data.defaultQueries ? JSON.stringify(data.defaultQueries) : null,
userId,
]);
return templateId;
}
async deleteTemplate(templateId: string): Promise<boolean> {
const deleteQuery = `
DELETE FROM report_template
WHERE template_id = $1 AND is_system = 'N'
`;
await query(deleteQuery, [templateId]);
return true;
}
async saveAsTemplate(
reportId: string,
templateNameKor: string,
templateNameEng: string | null | undefined,
description: string | null | undefined,
userId: string
): Promise<string> {
return transaction(async (client) => {
const reportQuery = `
SELECT report_type FROM report_master WHERE report_id = $1
`;
const reportResult = await client.query(reportQuery, [reportId]);
if (reportResult.rows.length === 0) {
throw new Error("리포트를 찾을 수 없습니다.");
}
const reportType = reportResult.rows[0].report_type;
const layoutQuery = `
SELECT
canvas_width,
canvas_height,
page_orientation,
margin_top,
margin_bottom,
margin_left,
margin_right,
components
FROM report_layout
WHERE report_id = $1
`;
const layoutResult = await client.query(layoutQuery, [reportId]);
if (layoutResult.rows.length === 0) {
throw new Error("레이아웃을 찾을 수 없습니다.");
}
const layout = layoutResult.rows[0];
const queriesQuery = `
SELECT
query_name,
query_type,
sql_query,
parameters,
external_connection_id,
display_order
FROM report_query
WHERE report_id = $1
ORDER BY display_order
`;
const queriesResult = await client.query(queriesQuery, [reportId]);
const layoutConfig = {
width: layout.canvas_width,
height: layout.canvas_height,
orientation: layout.page_orientation,
margins: {
top: layout.margin_top,
bottom: layout.margin_bottom,
left: layout.margin_left,
right: layout.margin_right,
},
components: typeof layout.components === "string" ? JSON.parse(layout.components || "[]") : (layout.components || []),
};
const defaultQueries = queriesResult.rows.map((q) => ({
name: q.query_name,
type: q.query_type,
sqlQuery: q.sql_query,
parameters: Array.isArray(q.parameters) ? q.parameters : [],
externalConnectionId: q.external_connection_id,
displayOrder: q.display_order,
}));
const templateId = generateTemplateId();
await client.query(
`INSERT INTO report_template (
template_id, template_name_kor, template_name_eng, template_type,
is_system, description, layout_config, default_queries, use_yn, sort_order, created_by
) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8)`,
[
templateId, templateNameKor, templateNameEng || null, reportType,
description || null, JSON.stringify(layoutConfig),
JSON.stringify(defaultQueries), userId,
]
);
return templateId;
});
}
async createTemplateFromLayout(
templateNameKor: string,
templateNameEng: string | null | undefined,
templateType: string,
description: string | null | undefined,
layoutConfig: {
width: number;
height: number;
orientation: string;
margins: { top: number; bottom: number; left: number; right: number };
components: Record<string, unknown>[];
},
defaultQueries: Array<{
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number | null;
displayOrder?: number;
}>,
userId: string
): Promise<string> {
const templateId = generateTemplateId();
await query(
`INSERT INTO report_template (
template_id, template_name_kor, template_name_eng, template_type,
is_system, description, layout_config, default_queries, use_yn, sort_order, created_by
) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8)
RETURNING template_id`,
[
templateId, templateNameKor, templateNameEng || null, templateType,
description || null, JSON.stringify(layoutConfig),
JSON.stringify(defaultQueries), userId,
]
);
return templateId;
}
// ─── 비주얼 쿼리 빌더 ─────────────────────────────────────────────────────────
/** information_schema에서 사용자 테이블 목록 조회 */
async getSchemaTables(): Promise<Array<{ table_name: string; table_type: string }>> {
const sql = `
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type IN ('BASE TABLE', 'VIEW')
ORDER BY table_name
`;
return query<{ table_name: string; table_type: string }>(sql, []);
}
/** 특정 테이블의 컬럼 정보 조회 */
async getSchemaTableColumns(
tableName: string
): Promise<Array<{ column_name: string; data_type: string; is_nullable: string }>> {
this.validateTableName(tableName);
const sql = `
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position
`;
return query<{ column_name: string; data_type: string; is_nullable: string }>(sql, [tableName]);
}
/** VisualQuery → SELECT SQL 문자열 빌드 (순수 함수) */
buildVisualQuerySql(vq: VisualQuery): string {
this.validateTableName(vq.tableName);
const selectParts: string[] = [];
for (const col of vq.columns) {
this.validateIdentifier(col);
selectParts.push(`"${col}"`);
}
for (const fc of vq.formulaColumns) {
this.validateFormulaExpression(fc.expression);
this.validateIdentifier(fc.alias);
selectParts.push(`(${fc.expression}) AS "${fc.alias}"`);
}
if (selectParts.length === 0) {
throw new Error("최소 1개 이상의 컬럼을 선택해야 합니다.");
}
const limit = Math.min(Math.max(vq.limit ?? 100, 1), 10000);
return `SELECT ${selectParts.join(", ")} FROM "${vq.tableName}" LIMIT ${limit}`;
}
async executeVisualQuery(vq: VisualQuery): Promise<{ fields: string[]; rows: Record<string, unknown>[] }> {
const sql = this.buildVisualQuerySql(vq);
this.validateQuerySafety(sql);
const result = await query(sql, []);
const fields = result.length > 0 ? Object.keys(result[0]) : [];
return { fields, rows: result as Record<string, unknown>[] };
}
// ─── 비주얼 쿼리 검증 헬퍼 ─────────────────────────────────────────────────────
/** 테이블/컬럼명 화이트리스트 검증 — 영문+숫자+밑줄만 허용 */
private validateTableName(name: string): void {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(`유효하지 않은 테이블명입니다: ${name}`);
}
}
private validateIdentifier(name: string): void {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(`유효하지 않은 식별자입니다: ${name}`);
}
}
/** 수식 표현식 안전성 검증 — 세미콜론, 주석, 서브쿼리 금지 */
private validateFormulaExpression(expr: string): void {
const forbidden = [";", "--", "/*", "*/", "SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE"];
const upper = expr.toUpperCase();
for (const keyword of forbidden) {
if (upper.includes(keyword)) {
throw new Error(`수식에 사용할 수 없는 키워드가 포함되어 있습니다: ${keyword}`);
}
}
}
// ─── 카테고리(report_type) 관리 ─────────────────────────────────────────────────
/** DB에 저장된 모든 카테고리(report_type) 목록 조회 (중복 제거, 정렬) */
async getCategories(): Promise<string[]> {
const sql = `
SELECT DISTINCT report_type
FROM report_master
WHERE report_type IS NOT NULL
AND report_type != ''
ORDER BY report_type ASC
`;
const rows = await query<{ report_type: string }>(sql, []);
return rows.map((r) => r.report_type);
}
}
export default new ReportService();