- 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.
1227 lines
40 KiB
TypeScript
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();
|