2018 lines
67 KiB
TypeScript
2018 lines
67 KiB
TypeScript
import { query, queryOne, transaction, getPool } from "../database/db";
|
||
import { EventTriggerService } from "./eventTriggerService";
|
||
import { DataflowControlService } from "./dataflowControlService";
|
||
|
||
export interface FormDataResult {
|
||
id: number;
|
||
screenId: number;
|
||
tableName: string;
|
||
data: Record<string, any>;
|
||
createdAt: Date | null;
|
||
updatedAt: Date | null;
|
||
createdBy: string;
|
||
updatedBy: string;
|
||
}
|
||
|
||
export interface PartialUpdateResult {
|
||
success: boolean;
|
||
data: any;
|
||
message: string;
|
||
}
|
||
|
||
export interface PaginatedFormData {
|
||
content: FormDataResult[];
|
||
totalElements: number;
|
||
totalPages: number;
|
||
currentPage: number;
|
||
size: number;
|
||
}
|
||
|
||
export interface ValidationError {
|
||
field: string;
|
||
message: string;
|
||
code: string;
|
||
}
|
||
|
||
export interface ValidationResult {
|
||
valid: boolean;
|
||
errors: ValidationError[];
|
||
}
|
||
|
||
export interface TableColumn {
|
||
columnName: string;
|
||
dataType: string;
|
||
nullable: boolean;
|
||
primaryKey: boolean;
|
||
maxLength?: number | null;
|
||
defaultValue?: any;
|
||
}
|
||
|
||
export class DynamicFormService {
|
||
private dataflowControlService = new DataflowControlService();
|
||
/**
|
||
* 값을 PostgreSQL 타입에 맞게 변환
|
||
*/
|
||
private convertValueForPostgreSQL(value: any, dataType: string): any {
|
||
if (value === null || value === undefined || value === "") {
|
||
return null;
|
||
}
|
||
|
||
const lowerDataType = dataType.toLowerCase();
|
||
|
||
// 숫자 타입 처리
|
||
if (
|
||
lowerDataType.includes("integer") ||
|
||
lowerDataType.includes("bigint") ||
|
||
lowerDataType.includes("serial")
|
||
) {
|
||
return parseInt(value) || null;
|
||
}
|
||
|
||
if (
|
||
lowerDataType.includes("numeric") ||
|
||
lowerDataType.includes("decimal") ||
|
||
lowerDataType.includes("real") ||
|
||
lowerDataType.includes("double")
|
||
) {
|
||
return parseFloat(value) || null;
|
||
}
|
||
|
||
// 불린 타입 처리
|
||
if (lowerDataType.includes("boolean")) {
|
||
if (typeof value === "boolean") return value;
|
||
if (typeof value === "string") {
|
||
return value.toLowerCase() === "true" || value === "1";
|
||
}
|
||
return Boolean(value);
|
||
}
|
||
|
||
// 날짜/시간 타입 처리
|
||
if (
|
||
lowerDataType.includes("date") ||
|
||
lowerDataType.includes("timestamp") ||
|
||
lowerDataType.includes("time")
|
||
) {
|
||
if (typeof value === "string") {
|
||
// 빈 문자열이면 null 반환
|
||
if (value.trim() === "") {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
// YYYY-MM-DD 형식인 경우
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||
// DATE 타입이면 문자열 그대로 유지
|
||
if (lowerDataType === "date") {
|
||
console.log(
|
||
`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`
|
||
);
|
||
return value; // 문자열 그대로 반환
|
||
}
|
||
// TIMESTAMP 타입이면 Date 객체로 변환
|
||
else {
|
||
console.log(
|
||
`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`
|
||
);
|
||
return new Date(value + "T00:00:00");
|
||
}
|
||
}
|
||
// 다른 날짜 형식도 Date 객체로 변환
|
||
else {
|
||
console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`);
|
||
return new Date(value);
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ 날짜 변환 실패: ${value}`, error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 이미 Date 객체인 경우 그대로 반환
|
||
if (value instanceof Date) {
|
||
return value;
|
||
}
|
||
|
||
// 숫자인 경우 timestamp로 처리
|
||
if (typeof value === "number") {
|
||
return new Date(value);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// 기본적으로 문자열로 반환
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* 테이블의 컬럼 정보 조회 (타입 포함)
|
||
*/
|
||
private async getTableColumnInfo(
|
||
tableName: string
|
||
): Promise<Array<{ column_name: string; data_type: string }>> {
|
||
try {
|
||
const result = await query<{ column_name: string; data_type: string }>(
|
||
`SELECT column_name, data_type
|
||
FROM information_schema.columns
|
||
WHERE table_name = $1
|
||
AND table_schema = 'public'`,
|
||
[tableName]
|
||
);
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.error(`테이블 ${tableName}의 컬럼 정보 조회 실패:`, error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 테이블의 컬럼명 목록 조회 (간단 버전)
|
||
*/
|
||
private async getTableColumnNames(tableName: string): Promise<string[]> {
|
||
try {
|
||
const result = await query<{ column_name: string }>(
|
||
`SELECT column_name
|
||
FROM information_schema.columns
|
||
WHERE table_name = $1
|
||
AND table_schema = 'public'`,
|
||
[tableName]
|
||
);
|
||
|
||
return result.map((row) => row.column_name);
|
||
} catch (error) {
|
||
console.error(`❌ 테이블 ${tableName} 컬럼 정보 조회 실패:`, error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 테이블의 Primary Key 컬럼 조회 (공개 메서드로 변경)
|
||
*/
|
||
async getTablePrimaryKeys(tableName: string): Promise<string[]> {
|
||
try {
|
||
const result = await query<{ column_name: string }>(
|
||
`SELECT kcu.column_name
|
||
FROM information_schema.table_constraints tc
|
||
JOIN information_schema.key_column_usage kcu
|
||
ON tc.constraint_name = kcu.constraint_name
|
||
WHERE tc.table_name = $1
|
||
AND tc.constraint_type = 'PRIMARY KEY'
|
||
AND tc.table_schema = 'public'`,
|
||
[tableName]
|
||
);
|
||
|
||
return result.map((row) => row.column_name);
|
||
} catch (error) {
|
||
console.error(`❌ 테이블 ${tableName} Primary Key 조회 실패:`, error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 폼 데이터 저장 (실제 테이블에 직접 저장)
|
||
*/
|
||
async saveFormData(
|
||
screenId: number,
|
||
tableName: string,
|
||
data: Record<string, any>,
|
||
ipAddress?: string
|
||
): Promise<FormDataResult> {
|
||
try {
|
||
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
||
screenId,
|
||
tableName,
|
||
data,
|
||
});
|
||
|
||
// 테이블의 실제 컬럼 정보와 Primary Key 조회
|
||
const tableColumns = await this.getTableColumnNames(tableName);
|
||
const primaryKeys = await this.getTablePrimaryKeys(tableName);
|
||
console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns);
|
||
console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys);
|
||
|
||
// 메타데이터 제거 (실제 테이블 컬럼이 아님)
|
||
const {
|
||
created_by,
|
||
updated_by,
|
||
writer,
|
||
company_code,
|
||
screen_id,
|
||
...actualData
|
||
} = data;
|
||
|
||
// 기본 데이터 준비
|
||
const dataToInsert: any = { ...actualData };
|
||
|
||
// 테이블에 존재하는 공통 필드들만 추가
|
||
if (tableColumns.includes("created_at")) {
|
||
dataToInsert.created_at = new Date();
|
||
}
|
||
if (tableColumns.includes("updated_at")) {
|
||
dataToInsert.updated_at = new Date();
|
||
}
|
||
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
||
dataToInsert.regdate = new Date();
|
||
}
|
||
// created_date는 항상 현재 시간으로 설정 (기존 값 무시)
|
||
if (tableColumns.includes("created_date")) {
|
||
dataToInsert.created_date = new Date();
|
||
}
|
||
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
||
dataToInsert.updated_date = new Date();
|
||
}
|
||
|
||
// 작성자 정보 추가 (writer 컬럼 우선, 없으면 created_by/updated_by)
|
||
if (writer && tableColumns.includes("writer")) {
|
||
dataToInsert.writer = writer;
|
||
}
|
||
if (created_by && tableColumns.includes("created_by")) {
|
||
dataToInsert.created_by = created_by;
|
||
}
|
||
if (updated_by && tableColumns.includes("updated_by")) {
|
||
dataToInsert.updated_by = updated_by;
|
||
}
|
||
if (company_code && tableColumns.includes("company_code")) {
|
||
// company_code가 UUID 형태(36자)라면 하이픈 제거하여 32자로 만듦
|
||
let processedCompanyCode = company_code;
|
||
if (
|
||
typeof company_code === "string" &&
|
||
company_code.length === 36 &&
|
||
company_code.includes("-")
|
||
) {
|
||
processedCompanyCode = company_code.replace(/-/g, "");
|
||
console.log(
|
||
`🔧 company_code 길이 조정: "${company_code}" -> "${processedCompanyCode}" (${processedCompanyCode.length}자)`
|
||
);
|
||
}
|
||
// 여전히 32자를 초과하면 앞의 32자만 사용
|
||
if (
|
||
typeof processedCompanyCode === "string" &&
|
||
processedCompanyCode.length > 32
|
||
) {
|
||
processedCompanyCode = processedCompanyCode.substring(0, 32);
|
||
console.log(
|
||
`⚠️ company_code 길이 제한: 앞의 32자로 자름 -> "${processedCompanyCode}"`
|
||
);
|
||
}
|
||
dataToInsert.company_code = processedCompanyCode;
|
||
}
|
||
|
||
// 날짜/시간 문자열을 적절한 형태로 변환
|
||
Object.keys(dataToInsert).forEach((key) => {
|
||
const value = dataToInsert[key];
|
||
|
||
// 날짜/시간 관련 컬럼명 패턴 체크 (regdate, created_at, updated_at 등)
|
||
if (
|
||
typeof value === "string" &&
|
||
(key.toLowerCase().includes("date") ||
|
||
key.toLowerCase().includes("time") ||
|
||
key.toLowerCase().includes("created") ||
|
||
key.toLowerCase().includes("updated") ||
|
||
key.toLowerCase().includes("reg"))
|
||
) {
|
||
// YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환
|
||
if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
||
console.log(`📅 날짜시간 변환: ${key} = "${value}" -> Date 객체`);
|
||
dataToInsert[key] = new Date(value);
|
||
}
|
||
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
||
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||
console.log(
|
||
`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`
|
||
);
|
||
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
||
}
|
||
}
|
||
});
|
||
|
||
// 📝 RepeaterInput 데이터 처리 (JSON 배열을 개별 레코드로 분해)
|
||
const repeaterData: Array<{
|
||
data: Record<string, any>[];
|
||
targetTable?: string;
|
||
componentId: string;
|
||
}> = [];
|
||
Object.keys(dataToInsert).forEach((key) => {
|
||
const value = dataToInsert[key];
|
||
|
||
// 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열)
|
||
let parsedArray: any[] | null = null;
|
||
|
||
// 1️⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등)
|
||
if (Array.isArray(value) && value.length > 0) {
|
||
parsedArray = value;
|
||
console.log(
|
||
`🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
||
);
|
||
}
|
||
// 2️⃣ JSON 문자열인 경우 (레거시 RepeaterInput)
|
||
else if (
|
||
typeof value === "string" &&
|
||
value.trim().startsWith("[") &&
|
||
value.trim().endsWith("]")
|
||
) {
|
||
try {
|
||
parsedArray = JSON.parse(value);
|
||
console.log(
|
||
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
|
||
);
|
||
} catch (parseError) {
|
||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||
}
|
||
}
|
||
|
||
// 파싱된 배열이 있으면 처리
|
||
if (
|
||
parsedArray &&
|
||
Array.isArray(parsedArray) &&
|
||
parsedArray.length > 0
|
||
) {
|
||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||
let targetTable: string | undefined;
|
||
let actualData = parsedArray;
|
||
|
||
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
||
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
||
targetTable = parsedArray[0]._targetTable;
|
||
actualData = parsedArray.map(({ _targetTable, ...item }) => item);
|
||
}
|
||
|
||
repeaterData.push({
|
||
data: actualData,
|
||
targetTable,
|
||
componentId: key,
|
||
});
|
||
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
||
|
||
console.log(`✅ Repeater 데이터 추가: ${key}`, {
|
||
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
|
||
itemCount: actualData.length,
|
||
firstItem: actualData[0],
|
||
});
|
||
}
|
||
});
|
||
|
||
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
|
||
const separateRepeaterData: typeof repeaterData = [];
|
||
const mergedRepeaterData: typeof repeaterData = [];
|
||
|
||
repeaterData.forEach((repeater) => {
|
||
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
||
// 다른 테이블: 나중에 별도 저장
|
||
separateRepeaterData.push(repeater);
|
||
} else {
|
||
// 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에)
|
||
mergedRepeaterData.push(repeater);
|
||
}
|
||
});
|
||
|
||
console.log(`🔄 Repeater 데이터 분류:`, {
|
||
separate: separateRepeaterData.length, // 별도 테이블
|
||
merged: mergedRepeaterData.length, // 메인 테이블과 병합
|
||
});
|
||
|
||
// 존재하지 않는 컬럼 제거
|
||
Object.keys(dataToInsert).forEach((key) => {
|
||
if (!tableColumns.includes(key)) {
|
||
console.log(
|
||
`⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨`
|
||
);
|
||
delete dataToInsert[key];
|
||
}
|
||
});
|
||
|
||
console.log("🎯 실제 테이블에 삽입할 데이터:", {
|
||
tableName,
|
||
dataToInsert,
|
||
});
|
||
|
||
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
||
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
||
const columnInfo = await this.getTableColumnInfo(tableName);
|
||
console.log("📊 테이블 컬럼 정보:", columnInfo);
|
||
|
||
// 각 컬럼의 타입에 맞게 데이터 변환
|
||
Object.keys(dataToInsert).forEach((columnName) => {
|
||
const column = columnInfo.find((col) => col.column_name === columnName);
|
||
if (column) {
|
||
const originalValue = dataToInsert[columnName];
|
||
const convertedValue = this.convertValueForPostgreSQL(
|
||
originalValue,
|
||
column.data_type
|
||
);
|
||
|
||
if (originalValue !== convertedValue) {
|
||
console.log(
|
||
`🔄 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}`
|
||
);
|
||
dataToInsert[columnName] = convertedValue;
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log("✅ 타입 변환 완료된 데이터:", dataToInsert);
|
||
|
||
// 동적 SQL을 사용하여 실제 테이블에 UPSERT
|
||
const columns = Object.keys(dataToInsert);
|
||
const values: any[] = Object.values(dataToInsert);
|
||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||
|
||
let upsertQuery: string;
|
||
|
||
if (primaryKeys.length > 0) {
|
||
// Primary Key가 있는 경우 UPSERT 사용
|
||
const conflictColumns = primaryKeys.join(", ");
|
||
const updateSet = columns
|
||
.filter((col) => !primaryKeys.includes(col)) // Primary Key는 UPDATE에서 제외
|
||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||
.join(", ");
|
||
|
||
if (updateSet) {
|
||
upsertQuery = `
|
||
INSERT INTO ${tableName} (${columns.join(", ")})
|
||
VALUES (${placeholders})
|
||
ON CONFLICT (${conflictColumns})
|
||
DO UPDATE SET ${updateSet}
|
||
RETURNING *
|
||
`;
|
||
} else {
|
||
// 업데이트할 컬럼이 없는 경우 (Primary Key만 있는 테이블)
|
||
upsertQuery = `
|
||
INSERT INTO ${tableName} (${columns.join(", ")})
|
||
VALUES (${placeholders})
|
||
ON CONFLICT (${conflictColumns})
|
||
DO NOTHING
|
||
RETURNING *
|
||
`;
|
||
}
|
||
} else {
|
||
// Primary Key가 없는 경우 일반 INSERT
|
||
upsertQuery = `
|
||
INSERT INTO ${tableName} (${columns.join(", ")})
|
||
VALUES (${placeholders})
|
||
RETURNING *
|
||
`;
|
||
}
|
||
|
||
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
||
console.log("📊 SQL 파라미터:", values);
|
||
|
||
// 로그 트리거를 위한 세션 변수 설정 및 UPSERT 실행 (트랜잭션 내에서)
|
||
const userId = data.updated_by || data.created_by || "system";
|
||
const clientIp = ipAddress || "unknown";
|
||
|
||
let result: any[];
|
||
|
||
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||
if (mergedRepeaterData.length > 0) {
|
||
console.log(
|
||
`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`
|
||
);
|
||
|
||
result = [];
|
||
|
||
for (const repeater of mergedRepeaterData) {
|
||
for (const item of repeater.data) {
|
||
// 헤더 + 품목을 병합
|
||
// item에서 created_date 제거 (dataToInsert의 현재 시간 유지)
|
||
const { created_date: _, ...itemWithoutCreatedDate } = item;
|
||
const rawMergedData = {
|
||
...dataToInsert,
|
||
...itemWithoutCreatedDate,
|
||
};
|
||
|
||
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
|
||
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
|
||
// 그 외의 경우는 모두 새 레코드로 처리 (INSERT)
|
||
const isExistingRecord = rawMergedData._existingRecord === true;
|
||
|
||
if (!isExistingRecord) {
|
||
// 새 레코드: id 제거하여 새 UUID 자동 생성
|
||
const oldId = rawMergedData.id;
|
||
delete rawMergedData.id;
|
||
console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`);
|
||
} else {
|
||
console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`);
|
||
}
|
||
|
||
// 메타 플래그 제거
|
||
delete rawMergedData._isNewItem;
|
||
delete rawMergedData._existingRecord;
|
||
|
||
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
||
const validColumnNames = columnInfo.map((col) => col.column_name);
|
||
const mergedData: Record<string, any> = {};
|
||
|
||
Object.keys(rawMergedData).forEach((columnName) => {
|
||
// 실제 테이블 컬럼인지 확인
|
||
if (validColumnNames.includes(columnName)) {
|
||
const column = columnInfo.find(
|
||
(col) => col.column_name === columnName
|
||
);
|
||
if (column) {
|
||
// 타입 변환
|
||
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||
rawMergedData[columnName],
|
||
column.data_type
|
||
);
|
||
} else {
|
||
mergedData[columnName] = rawMergedData[columnName];
|
||
}
|
||
} else {
|
||
console.log(
|
||
`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`
|
||
);
|
||
}
|
||
});
|
||
|
||
const mergedColumns = Object.keys(mergedData);
|
||
const mergedValues: any[] = Object.values(mergedData);
|
||
const mergedPlaceholders = mergedValues
|
||
.map((_, index) => `$${index + 1}`)
|
||
.join(", ");
|
||
|
||
let mergedUpsertQuery: string;
|
||
if (primaryKeys.length > 0) {
|
||
const conflictColumns = primaryKeys.join(", ");
|
||
const updateSet = mergedColumns
|
||
.filter((col) => !primaryKeys.includes(col))
|
||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||
.join(", ");
|
||
|
||
mergedUpsertQuery = updateSet
|
||
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||
VALUES (${mergedPlaceholders})
|
||
ON CONFLICT (${conflictColumns})
|
||
DO UPDATE SET ${updateSet}
|
||
RETURNING *`
|
||
: `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||
VALUES (${mergedPlaceholders})
|
||
ON CONFLICT (${conflictColumns})
|
||
DO NOTHING
|
||
RETURNING *`;
|
||
} else {
|
||
mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||
VALUES (${mergedPlaceholders})
|
||
RETURNING *`;
|
||
}
|
||
|
||
console.log(`📝 병합 INSERT:`, { mergedData });
|
||
|
||
const itemResult = await transaction(async (client) => {
|
||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||
const res = await client.query(mergedUpsertQuery, mergedValues);
|
||
return res.rows[0];
|
||
});
|
||
|
||
result.push(itemResult);
|
||
}
|
||
}
|
||
|
||
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
|
||
} else {
|
||
// 일반 모드: 헤더만 저장
|
||
result = await transaction(async (client) => {
|
||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||
const res = await client.query(upsertQuery, values);
|
||
return res.rows;
|
||
});
|
||
|
||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||
}
|
||
|
||
// 결과를 표준 형식으로 변환
|
||
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
||
|
||
// 📝 별도 테이블 Repeater 데이터 저장
|
||
if (separateRepeaterData.length > 0) {
|
||
console.log(
|
||
`🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개`
|
||
);
|
||
|
||
for (const repeater of separateRepeaterData) {
|
||
const targetTableName = repeater.targetTable || tableName;
|
||
console.log(
|
||
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
|
||
);
|
||
|
||
// 대상 테이블의 컬럼 및 기본키 정보 조회
|
||
const targetTableColumns =
|
||
await this.getTableColumns(targetTableName);
|
||
const targetPrimaryKeys = await this.getPrimaryKeys(targetTableName);
|
||
|
||
// 컬럼명만 추출
|
||
const targetColumnNames = targetTableColumns.map(
|
||
(col) => col.columnName
|
||
);
|
||
|
||
// 각 항목을 저장
|
||
for (let i = 0; i < repeater.data.length; i++) {
|
||
const item = repeater.data[i];
|
||
const itemData: Record<string, any> = {
|
||
...item,
|
||
created_by,
|
||
updated_by,
|
||
regdate: new Date(),
|
||
// 🔥 멀티테넌시: company_code 필수 추가
|
||
company_code: data.company_code || company_code,
|
||
};
|
||
|
||
// 🔥 별도 테이블인 경우에만 외래키 추가
|
||
// (같은 테이블이면 이미 병합 모드에서 처리됨)
|
||
|
||
// 대상 테이블에 존재하는 컬럼만 필터링
|
||
Object.keys(itemData).forEach((key) => {
|
||
if (!targetColumnNames.includes(key)) {
|
||
delete itemData[key];
|
||
}
|
||
});
|
||
|
||
// 타입 변환 적용
|
||
Object.keys(itemData).forEach((columnName) => {
|
||
const column = targetTableColumns.find(
|
||
(col) => col.columnName === columnName
|
||
);
|
||
if (column) {
|
||
itemData[columnName] = this.convertValueForPostgreSQL(
|
||
itemData[columnName],
|
||
column.dataType
|
||
);
|
||
}
|
||
});
|
||
|
||
// UPSERT 쿼리 생성
|
||
const itemColumns = Object.keys(itemData);
|
||
const itemValues: any[] = Object.values(itemData);
|
||
const itemPlaceholders = itemValues
|
||
.map((_, index) => `$${index + 1}`)
|
||
.join(", ");
|
||
|
||
let itemUpsertQuery: string;
|
||
if (targetPrimaryKeys.length > 0) {
|
||
const conflictColumns = targetPrimaryKeys.join(", ");
|
||
const updateSet = itemColumns
|
||
.filter((col) => !targetPrimaryKeys.includes(col))
|
||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||
.join(", ");
|
||
|
||
if (updateSet) {
|
||
itemUpsertQuery = `
|
||
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
|
||
VALUES (${itemPlaceholders})
|
||
ON CONFLICT (${conflictColumns})
|
||
DO UPDATE SET ${updateSet}
|
||
RETURNING *
|
||
`;
|
||
} else {
|
||
itemUpsertQuery = `
|
||
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
|
||
VALUES (${itemPlaceholders})
|
||
ON CONFLICT (${conflictColumns})
|
||
DO NOTHING
|
||
RETURNING *
|
||
`;
|
||
}
|
||
} else {
|
||
itemUpsertQuery = `
|
||
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
|
||
VALUES (${itemPlaceholders})
|
||
RETURNING *
|
||
`;
|
||
}
|
||
|
||
console.log(
|
||
` 📝 항목 ${i + 1}/${repeater.data.length} 저장:`,
|
||
itemData
|
||
);
|
||
await query<any>(itemUpsertQuery, itemValues);
|
||
}
|
||
|
||
console.log(` ✅ Repeater "${repeater.componentId}" 저장 완료`);
|
||
}
|
||
|
||
console.log(`✅ 모든 RepeaterInput 데이터 저장 완료`);
|
||
}
|
||
|
||
// 🔥 조건부 연결 실행 (INSERT 트리거)
|
||
try {
|
||
if (company_code) {
|
||
await EventTriggerService.executeEventTriggers(
|
||
"insert",
|
||
tableName,
|
||
insertedRecord as Record<string, any>,
|
||
company_code
|
||
);
|
||
console.log("🚀 조건부 연결 트리거 실행 완료 (INSERT)");
|
||
}
|
||
} catch (triggerError) {
|
||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||
// 트리거 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행
|
||
}
|
||
|
||
// 🎯 제어관리 실행 (새로 추가)
|
||
try {
|
||
// savedData 또는 insertedRecord에서 company_code 추출
|
||
const recordCompanyCode =
|
||
(insertedRecord as Record<string, any>)?.company_code ||
|
||
dataToInsert.company_code ||
|
||
"*";
|
||
|
||
await this.executeDataflowControlIfConfigured(
|
||
screenId,
|
||
tableName,
|
||
insertedRecord as Record<string, any>,
|
||
"insert",
|
||
created_by || "system",
|
||
recordCompanyCode
|
||
);
|
||
} catch (controlError) {
|
||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||
// 제어관리 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행
|
||
}
|
||
|
||
return {
|
||
id: insertedRecord.id || insertedRecord.objid || 0,
|
||
screenId: screenId,
|
||
tableName: tableName,
|
||
data: insertedRecord as Record<string, any>,
|
||
createdAt: insertedRecord.created_at || new Date(),
|
||
updatedAt: insertedRecord.updated_at || new Date(),
|
||
createdBy: insertedRecord.created_by || created_by || "system",
|
||
updatedBy: insertedRecord.updated_by || updated_by || "system",
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 실제 테이블 저장 실패:", error);
|
||
throw new Error(`실제 테이블 저장 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 폼 데이터 부분 업데이트 (변경된 필드만 업데이트)
|
||
*/
|
||
async updateFormDataPartial(
|
||
id: string | number, // 🔧 UUID 문자열도 지원
|
||
tableName: string,
|
||
originalData: Record<string, any>,
|
||
newData: Record<string, any>
|
||
): Promise<PartialUpdateResult> {
|
||
try {
|
||
console.log("🔄 서비스: 부분 업데이트 시작:", {
|
||
id,
|
||
tableName,
|
||
originalData,
|
||
newData,
|
||
});
|
||
|
||
// 테이블의 실제 컬럼 정보 조회
|
||
const tableColumns = await this.getTableColumnNames(tableName);
|
||
console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns);
|
||
|
||
// 변경된 필드만 찾기
|
||
const changedFields: Record<string, any> = {};
|
||
|
||
for (const [key, value] of Object.entries(newData)) {
|
||
// 메타데이터 필드 제외
|
||
if (
|
||
["created_by", "updated_by", "company_code", "screen_id"].includes(
|
||
key
|
||
)
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
// 테이블에 존재하지 않는 컬럼 제외
|
||
if (!tableColumns.includes(key)) {
|
||
console.log(
|
||
`⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제외됨`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// 값이 실제로 변경된 경우만 포함
|
||
if (originalData[key] !== value) {
|
||
changedFields[key] = value;
|
||
console.log(
|
||
`📝 변경된 필드: ${key} = "${originalData[key]}" → "${value}"`
|
||
);
|
||
}
|
||
}
|
||
|
||
// 변경된 필드가 없으면 업데이트 건너뛰기
|
||
if (Object.keys(changedFields).length === 0) {
|
||
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
||
return {
|
||
success: true,
|
||
data: originalData,
|
||
message: "변경사항이 없어 업데이트하지 않았습니다.",
|
||
};
|
||
}
|
||
|
||
// 업데이트 관련 필드 추가 (변경사항이 있는 경우에만)
|
||
if (tableColumns.includes("updated_at")) {
|
||
changedFields.updated_at = new Date();
|
||
}
|
||
|
||
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
||
|
||
// 동적으로 기본키 조회
|
||
const primaryKeys = await this.getTablePrimaryKeys(tableName);
|
||
if (!primaryKeys || primaryKeys.length === 0) {
|
||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||
}
|
||
|
||
const primaryKeyColumn = primaryKeys[0];
|
||
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
||
|
||
// 🆕 컬럼 타입 조회 (타입 캐스팅용)
|
||
const columnTypesQuery = `
|
||
SELECT column_name, data_type
|
||
FROM information_schema.columns
|
||
WHERE table_name = $1 AND table_schema = 'public'
|
||
`;
|
||
const columnTypesResult = await query<{
|
||
column_name: string;
|
||
data_type: string;
|
||
}>(columnTypesQuery, [tableName]);
|
||
const columnTypes: Record<string, string> = {};
|
||
columnTypesResult.forEach((row) => {
|
||
columnTypes[row.column_name] = row.data_type;
|
||
});
|
||
|
||
console.log("📊 컬럼 타입 정보:", columnTypes);
|
||
|
||
// 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함)
|
||
const setClause = Object.keys(changedFields)
|
||
.map((key, index) => {
|
||
const dataType = columnTypes[key];
|
||
// 숫자 타입인 경우 명시적 캐스팅
|
||
if (
|
||
dataType === "integer" ||
|
||
dataType === "bigint" ||
|
||
dataType === "smallint"
|
||
) {
|
||
return `${key} = $${index + 1}::integer`;
|
||
} else if (
|
||
dataType === "numeric" ||
|
||
dataType === "decimal" ||
|
||
dataType === "real" ||
|
||
dataType === "double precision"
|
||
) {
|
||
return `${key} = $${index + 1}::numeric`;
|
||
} else if (dataType === "boolean") {
|
||
return `${key} = $${index + 1}::boolean`;
|
||
} else {
|
||
// 문자열 타입은 캐스팅 불필요
|
||
return `${key} = $${index + 1}`;
|
||
}
|
||
})
|
||
.join(", ");
|
||
|
||
const values: any[] = Object.values(changedFields);
|
||
values.push(id); // WHERE 조건용 ID 추가
|
||
|
||
// 🔑 Primary Key 타입에 맞게 캐스팅
|
||
const pkDataType = columnTypes[primaryKeyColumn];
|
||
let pkCast = "";
|
||
if (
|
||
pkDataType === "integer" ||
|
||
pkDataType === "bigint" ||
|
||
pkDataType === "smallint"
|
||
) {
|
||
pkCast = "::integer";
|
||
} else if (pkDataType === "numeric" || pkDataType === "decimal") {
|
||
pkCast = "::numeric";
|
||
} else if (pkDataType === "uuid") {
|
||
pkCast = "::uuid";
|
||
}
|
||
// text, varchar 등은 캐스팅 불필요
|
||
|
||
const updateQuery = `
|
||
UPDATE ${tableName}
|
||
SET ${setClause}
|
||
WHERE ${primaryKeyColumn} = $${values.length}${pkCast}
|
||
RETURNING *
|
||
`;
|
||
|
||
console.log("📝 실행할 부분 UPDATE SQL:", updateQuery);
|
||
console.log("📊 SQL 파라미터:", values);
|
||
|
||
const result = await query<any>(updateQuery, values);
|
||
|
||
console.log("✅ 서비스: 부분 업데이트 성공:", result);
|
||
|
||
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
||
|
||
return {
|
||
success: true,
|
||
data: updatedRecord,
|
||
message: "데이터가 성공적으로 업데이트되었습니다.",
|
||
};
|
||
} catch (error: any) {
|
||
console.error("❌ 서비스: 부분 업데이트 실패:", error);
|
||
throw new Error(`부분 업데이트 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트)
|
||
*/
|
||
async updateFormData(
|
||
id: string | number,
|
||
tableName: string,
|
||
data: Record<string, any>
|
||
): Promise<FormDataResult> {
|
||
try {
|
||
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
|
||
id,
|
||
tableName,
|
||
data,
|
||
});
|
||
|
||
// 테이블의 실제 컬럼 정보 조회
|
||
const tableColumns = await this.getTableColumnNames(tableName);
|
||
console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns);
|
||
|
||
// 메타데이터 제거
|
||
const { created_by, updated_by, company_code, screen_id, ...actualData } =
|
||
data;
|
||
|
||
// 기본 데이터 준비
|
||
const dataToUpdate: any = { ...actualData };
|
||
|
||
// 테이블에 존재하는 업데이트 관련 필드들만 추가
|
||
if (tableColumns.includes("updated_at")) {
|
||
dataToUpdate.updated_at = new Date();
|
||
}
|
||
if (tableColumns.includes("regdate") && !dataToUpdate.regdate) {
|
||
dataToUpdate.regdate = new Date();
|
||
}
|
||
|
||
// 수정자 정보가 있고 해당 컬럼이 존재한다면 추가
|
||
if (updated_by && tableColumns.includes("updated_by")) {
|
||
dataToUpdate.updated_by = updated_by;
|
||
}
|
||
|
||
// 존재하지 않는 컬럼 제거
|
||
Object.keys(dataToUpdate).forEach((key) => {
|
||
if (!tableColumns.includes(key)) {
|
||
console.log(
|
||
`⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제거됨`
|
||
);
|
||
delete dataToUpdate[key];
|
||
}
|
||
});
|
||
|
||
// 컬럼 타입에 맞는 데이터 변환 (UPDATE용)
|
||
const columnInfo = await this.getTableColumnInfo(tableName);
|
||
console.log(`📊 테이블 ${tableName}의 컬럼 타입 정보:`, columnInfo);
|
||
|
||
// 각 컬럼의 타입에 맞게 데이터 변환
|
||
Object.keys(dataToUpdate).forEach((columnName) => {
|
||
const column = columnInfo.find((col) => col.column_name === columnName);
|
||
if (column) {
|
||
const originalValue = dataToUpdate[columnName];
|
||
const convertedValue = this.convertValueForPostgreSQL(
|
||
originalValue,
|
||
column.data_type
|
||
);
|
||
|
||
if (originalValue !== convertedValue) {
|
||
console.log(
|
||
`🔄 UPDATE 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}`
|
||
);
|
||
dataToUpdate[columnName] = convertedValue;
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log("✅ UPDATE 타입 변환 완료된 데이터:", dataToUpdate);
|
||
|
||
console.log("🎯 실제 테이블에서 업데이트할 데이터:", {
|
||
tableName,
|
||
id,
|
||
dataToUpdate,
|
||
});
|
||
|
||
// 동적 UPDATE SQL 생성
|
||
const setClause = Object.keys(dataToUpdate)
|
||
.map((key, index) => `${key} = $${index + 1}`)
|
||
.join(", ");
|
||
|
||
const values: any[] = Object.values(dataToUpdate);
|
||
values.push(id); // WHERE 조건용 ID 추가
|
||
|
||
// 동적으로 기본키 조회
|
||
const primaryKeys = await this.getTablePrimaryKeys(tableName);
|
||
if (!primaryKeys || primaryKeys.length === 0) {
|
||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||
}
|
||
|
||
const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용
|
||
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
||
|
||
// 기본키 데이터 타입 조회하여 적절한 캐스팅 적용
|
||
const primaryKeyInfo = await query<{ data_type: string }>(
|
||
`SELECT data_type
|
||
FROM information_schema.columns
|
||
WHERE table_name = $1
|
||
AND column_name = $2
|
||
AND table_schema = 'public'`,
|
||
[tableName, primaryKeyColumn]
|
||
);
|
||
|
||
let typeCastSuffix = "";
|
||
if (primaryKeyInfo.length > 0) {
|
||
const dataType = primaryKeyInfo[0].data_type;
|
||
console.log(`🔍 기본키 ${primaryKeyColumn}의 데이터 타입: ${dataType}`);
|
||
|
||
if (dataType.includes("character") || dataType.includes("text")) {
|
||
typeCastSuffix = "::text";
|
||
} else if (dataType.includes("bigint")) {
|
||
typeCastSuffix = "::bigint";
|
||
} else if (
|
||
dataType.includes("integer") ||
|
||
dataType.includes("numeric")
|
||
) {
|
||
typeCastSuffix = "::numeric";
|
||
}
|
||
}
|
||
|
||
const updateQuery = `
|
||
UPDATE ${tableName}
|
||
SET ${setClause}
|
||
WHERE ${primaryKeyColumn} = $${values.length}${typeCastSuffix}
|
||
RETURNING *
|
||
`;
|
||
|
||
console.log("📝 실행할 UPDATE SQL:", updateQuery);
|
||
console.log("📊 SQL 파라미터:", values);
|
||
|
||
const result = await query<any>(updateQuery, values);
|
||
|
||
console.log("✅ 서비스: 실제 테이블 업데이트 성공:", result);
|
||
|
||
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
||
|
||
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
||
try {
|
||
if (company_code) {
|
||
await EventTriggerService.executeEventTriggers(
|
||
"update",
|
||
tableName,
|
||
updatedRecord as Record<string, any>,
|
||
company_code
|
||
);
|
||
console.log("🚀 조건부 연결 트리거 실행 완료 (UPDATE)");
|
||
}
|
||
} catch (triggerError) {
|
||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||
// 트리거 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행
|
||
}
|
||
|
||
// 🎯 제어관리 실행 (UPDATE 트리거)
|
||
try {
|
||
// updatedRecord에서 company_code 추출
|
||
const recordCompanyCode =
|
||
(updatedRecord as Record<string, any>)?.company_code ||
|
||
company_code ||
|
||
"*";
|
||
|
||
await this.executeDataflowControlIfConfigured(
|
||
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||
tableName,
|
||
updatedRecord as Record<string, any>,
|
||
"update",
|
||
updated_by || "system",
|
||
recordCompanyCode
|
||
);
|
||
} catch (controlError) {
|
||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||
// 제어관리 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행
|
||
}
|
||
|
||
return {
|
||
id: updatedRecord.id || updatedRecord.objid || id,
|
||
screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정
|
||
tableName: tableName,
|
||
data: updatedRecord as Record<string, any>,
|
||
createdAt: updatedRecord.created_at || new Date(),
|
||
updatedAt: updatedRecord.updated_at || new Date(),
|
||
createdBy: updatedRecord.created_by || "system",
|
||
updatedBy: updatedRecord.updated_by || updated_by || "system",
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 실제 테이블 업데이트 실패:", error);
|
||
throw new Error(`실제 테이블 업데이트 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||
*/
|
||
async deleteFormData(
|
||
id: string | number,
|
||
tableName: string,
|
||
companyCode?: string,
|
||
userId?: string
|
||
): Promise<void> {
|
||
try {
|
||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||
id,
|
||
tableName,
|
||
});
|
||
|
||
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
|
||
const primaryKeyQuery = `
|
||
SELECT kcu.column_name, c.data_type
|
||
FROM information_schema.table_constraints tc
|
||
JOIN information_schema.key_column_usage kcu
|
||
ON tc.constraint_name = kcu.constraint_name
|
||
JOIN information_schema.columns c
|
||
ON kcu.column_name = c.column_name
|
||
AND kcu.table_name = c.table_name
|
||
WHERE tc.table_name = $1
|
||
AND tc.constraint_type = 'PRIMARY KEY'
|
||
LIMIT 1
|
||
`;
|
||
|
||
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
|
||
console.log("🔍 테이블명:", tableName);
|
||
|
||
const primaryKeyResult = await query<{
|
||
column_name: string;
|
||
data_type: string;
|
||
}>(primaryKeyQuery, [tableName]);
|
||
|
||
if (!primaryKeyResult || primaryKeyResult.length === 0) {
|
||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||
}
|
||
|
||
const primaryKeyInfo = primaryKeyResult[0];
|
||
const primaryKeyColumn = primaryKeyInfo.column_name;
|
||
const primaryKeyDataType = primaryKeyInfo.data_type;
|
||
console.log("🔑 발견된 기본키:", {
|
||
column: primaryKeyColumn,
|
||
dataType: primaryKeyDataType,
|
||
});
|
||
|
||
// 2. 데이터 타입에 맞는 타입 캐스팅 적용
|
||
let typeCastSuffix = "";
|
||
if (
|
||
primaryKeyDataType.includes("character") ||
|
||
primaryKeyDataType.includes("text")
|
||
) {
|
||
typeCastSuffix = "::text";
|
||
} else if (
|
||
primaryKeyDataType.includes("integer") ||
|
||
primaryKeyDataType.includes("bigint")
|
||
) {
|
||
typeCastSuffix = "::bigint";
|
||
} else if (
|
||
primaryKeyDataType.includes("numeric") ||
|
||
primaryKeyDataType.includes("decimal")
|
||
) {
|
||
typeCastSuffix = "::numeric";
|
||
}
|
||
|
||
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
|
||
const deleteQuery = `
|
||
DELETE FROM ${tableName}
|
||
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
|
||
RETURNING *
|
||
`;
|
||
|
||
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
||
console.log("📊 SQL 파라미터:", [id]);
|
||
|
||
// 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용)
|
||
const result = await transaction(async (client) => {
|
||
// 이력 트리거에서 사용할 사용자 정보 설정
|
||
if (userId) {
|
||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||
}
|
||
const res = await client.query(deleteQuery, [id]);
|
||
return res.rows;
|
||
});
|
||
|
||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||
|
||
// 🔥 조건부 연결 실행 (DELETE 트리거)
|
||
try {
|
||
if (
|
||
companyCode &&
|
||
result &&
|
||
Array.isArray(result) &&
|
||
result.length > 0
|
||
) {
|
||
const deletedRecord = result[0] as Record<string, any>;
|
||
await EventTriggerService.executeEventTriggers(
|
||
"delete",
|
||
tableName,
|
||
deletedRecord,
|
||
companyCode
|
||
);
|
||
console.log("🚀 조건부 연결 트리거 실행 완료 (DELETE)");
|
||
}
|
||
} catch (triggerError) {
|
||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||
// 트리거 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행
|
||
}
|
||
|
||
// 🎯 제어관리 실행 (DELETE 트리거)
|
||
try {
|
||
if (result && Array.isArray(result) && result.length > 0) {
|
||
const deletedRecord = result[0] as Record<string, any>;
|
||
// deletedRecord에서 company_code 추출
|
||
const recordCompanyCode =
|
||
deletedRecord?.company_code || companyCode || "*";
|
||
|
||
await this.executeDataflowControlIfConfigured(
|
||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||
tableName,
|
||
deletedRecord,
|
||
"delete",
|
||
userId || "system",
|
||
recordCompanyCode
|
||
);
|
||
}
|
||
} catch (controlError) {
|
||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||
// 제어관리 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 실제 테이블 삭제 실패:", error);
|
||
throw new Error(`실제 테이블 삭제 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 단일 폼 데이터 조회
|
||
*/
|
||
async getFormData(id: number): Promise<FormDataResult | null> {
|
||
try {
|
||
console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id });
|
||
|
||
const result = await queryOne<{
|
||
id: number;
|
||
screen_id: number;
|
||
table_name: string;
|
||
form_data: any;
|
||
created_at: Date | null;
|
||
updated_at: Date | null;
|
||
created_by: string;
|
||
updated_by: string;
|
||
}>(
|
||
`SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by
|
||
FROM dynamic_form_data
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
|
||
if (!result) {
|
||
console.log("❌ 서비스: 폼 데이터를 찾을 수 없음");
|
||
return null;
|
||
}
|
||
|
||
console.log("✅ 서비스: 폼 데이터 단건 조회 성공");
|
||
|
||
return {
|
||
id: result.id,
|
||
screenId: result.screen_id,
|
||
tableName: result.table_name,
|
||
data: result.form_data as Record<string, any>,
|
||
createdAt: result.created_at,
|
||
updatedAt: result.updated_at,
|
||
createdBy: result.created_by,
|
||
updatedBy: result.updated_by,
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 폼 데이터 단건 조회 실패:", error);
|
||
throw new Error(`폼 데이터 조회 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 화면별 폼 데이터 목록 조회 (페이징)
|
||
*/
|
||
async getFormDataList(
|
||
screenId: number,
|
||
params: {
|
||
page: number;
|
||
size: number;
|
||
search?: string;
|
||
sortBy?: string;
|
||
sortOrder?: "asc" | "desc";
|
||
}
|
||
): Promise<PaginatedFormData> {
|
||
try {
|
||
console.log("📋 서비스: 폼 데이터 목록 조회 시작:", { screenId, params });
|
||
|
||
const {
|
||
page,
|
||
size,
|
||
search,
|
||
sortBy = "created_at",
|
||
sortOrder = "desc",
|
||
} = params;
|
||
const offset = (page - 1) * size;
|
||
|
||
// 정렬 컬럼 검증 (SQL Injection 방지)
|
||
const allowedSortColumns = ["created_at", "updated_at", "id"];
|
||
const validSortBy = allowedSortColumns.includes(sortBy)
|
||
? sortBy
|
||
: "created_at";
|
||
const validSortOrder = sortOrder === "asc" ? "ASC" : "DESC";
|
||
|
||
// 검색 조건 및 파라미터 구성
|
||
const queryParams: any[] = [screenId];
|
||
let searchCondition = "";
|
||
|
||
if (search) {
|
||
searchCondition = ` AND (
|
||
form_data::text ILIKE $2
|
||
OR table_name ILIKE $2
|
||
)`;
|
||
queryParams.push(`%${search}%`);
|
||
}
|
||
|
||
// 데이터 조회 쿼리
|
||
const dataQuery = `
|
||
SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by
|
||
FROM dynamic_form_data
|
||
WHERE screen_id = $1
|
||
${searchCondition}
|
||
ORDER BY ${validSortBy} ${validSortOrder}
|
||
LIMIT ${size} OFFSET ${offset}
|
||
`;
|
||
|
||
// 전체 개수 조회 쿼리
|
||
const countQuery = `
|
||
SELECT COUNT(*) as total
|
||
FROM dynamic_form_data
|
||
WHERE screen_id = $1
|
||
${searchCondition}
|
||
`;
|
||
|
||
// 병렬 실행
|
||
const [results, countResult] = await Promise.all([
|
||
query<{
|
||
id: number;
|
||
screen_id: number;
|
||
table_name: string;
|
||
form_data: any;
|
||
created_at: Date | null;
|
||
updated_at: Date | null;
|
||
created_by: string;
|
||
updated_by: string;
|
||
}>(dataQuery, queryParams),
|
||
query<{ total: string }>(countQuery, queryParams),
|
||
]);
|
||
|
||
const totalCount = parseInt(countResult[0]?.total || "0");
|
||
|
||
const formDataResults: FormDataResult[] = results.map((result) => ({
|
||
id: result.id,
|
||
screenId: result.screen_id,
|
||
tableName: result.table_name,
|
||
data: result.form_data as Record<string, any>,
|
||
createdAt: result.created_at,
|
||
updatedAt: result.updated_at,
|
||
createdBy: result.created_by,
|
||
updatedBy: result.updated_by,
|
||
}));
|
||
|
||
const totalPages = Math.ceil(totalCount / size);
|
||
|
||
console.log("✅ 서비스: 폼 데이터 목록 조회 성공:", {
|
||
totalCount,
|
||
totalPages,
|
||
currentPage: page,
|
||
});
|
||
|
||
return {
|
||
content: formDataResults,
|
||
totalElements: totalCount,
|
||
totalPages,
|
||
currentPage: page,
|
||
size,
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 폼 데이터 목록 조회 실패:", error);
|
||
throw new Error(`폼 데이터 목록 조회 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 폼 데이터 검증
|
||
*/
|
||
async validateFormData(
|
||
tableName: string,
|
||
data: Record<string, any>
|
||
): Promise<ValidationResult> {
|
||
try {
|
||
console.log("✅ 서비스: 폼 데이터 검증 시작:", { tableName, data });
|
||
|
||
const errors: ValidationError[] = [];
|
||
|
||
// 기본 검증 로직 (실제로는 테이블 스키마를 확인해야 함)
|
||
Object.entries(data).forEach(([key, value]) => {
|
||
// 예시: 빈 값 검증
|
||
if (value === null || value === undefined || value === "") {
|
||
// 특정 필드가 required인지 확인하는 로직이 필요
|
||
// 지금은 간단히 모든 필드를 선택사항으로 처리
|
||
}
|
||
|
||
// 예시: 데이터 타입 검증
|
||
// 실제로는 테이블 스키마의 컬럼 타입과 비교해야 함
|
||
});
|
||
|
||
const result: ValidationResult = {
|
||
valid: errors.length === 0,
|
||
errors,
|
||
};
|
||
|
||
console.log("✅ 서비스: 폼 데이터 검증 완료:", result);
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 폼 데이터 검증 실패:", error);
|
||
throw new Error(`폼 데이터 검증 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 테이블 컬럼 정보 조회 (PostgreSQL 시스템 테이블 활용)
|
||
*/
|
||
async getTableColumns(tableName: string): Promise<TableColumn[]> {
|
||
try {
|
||
console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName });
|
||
|
||
// PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회
|
||
const columns = await query<{
|
||
column_name: string;
|
||
data_type: string;
|
||
is_nullable: string;
|
||
column_default: string | null;
|
||
character_maximum_length: number | null;
|
||
}>(
|
||
`SELECT
|
||
column_name,
|
||
data_type,
|
||
is_nullable,
|
||
column_default,
|
||
character_maximum_length
|
||
FROM information_schema.columns
|
||
WHERE table_name = $1
|
||
AND table_schema = 'public'
|
||
ORDER BY ordinal_position`,
|
||
[tableName]
|
||
);
|
||
|
||
// Primary key 정보 조회
|
||
const primaryKeys = await query<{ column_name: string }>(
|
||
`SELECT
|
||
kcu.column_name
|
||
FROM
|
||
information_schema.table_constraints tc
|
||
JOIN information_schema.key_column_usage kcu
|
||
ON tc.constraint_name = kcu.constraint_name
|
||
WHERE
|
||
tc.constraint_type = 'PRIMARY KEY'
|
||
AND tc.table_name = $1
|
||
AND tc.table_schema = 'public'`,
|
||
[tableName]
|
||
);
|
||
|
||
const primaryKeyColumns = new Set(
|
||
primaryKeys.map((pk) => pk.column_name)
|
||
);
|
||
|
||
const result: TableColumn[] = columns.map((col) => ({
|
||
columnName: col.column_name,
|
||
dataType: col.data_type,
|
||
nullable: col.is_nullable === "YES",
|
||
primaryKey: primaryKeyColumns.has(col.column_name),
|
||
maxLength: col.character_maximum_length,
|
||
defaultValue: col.column_default,
|
||
}));
|
||
|
||
console.log("✅ 서비스: 테이블 컬럼 정보 조회 성공:", result);
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 테이블 컬럼 정보 조회 실패:", error);
|
||
throw new Error(`테이블 컬럼 정보 조회 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 테이블의 기본키 컬럼명 목록 조회
|
||
*/
|
||
async getPrimaryKeys(tableName: string): Promise<string[]> {
|
||
try {
|
||
console.log("🔑 서비스: 테이블 기본키 조회 시작:", { tableName });
|
||
|
||
const result = await query<{ column_name: string }>(
|
||
`SELECT a.attname AS column_name
|
||
FROM pg_index i
|
||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||
[tableName]
|
||
);
|
||
|
||
const primaryKeys = result.map((row) => row.column_name);
|
||
console.log("✅ 서비스: 테이블 기본키 조회 성공:", primaryKeys);
|
||
|
||
return primaryKeys;
|
||
} catch (error) {
|
||
console.error("❌ 서비스: 테이블 기본키 조회 실패:", error);
|
||
throw new Error(`테이블 기본키 조회 실패: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 제어관리 실행 (화면에 설정된 경우)
|
||
*/
|
||
private async executeDataflowControlIfConfigured(
|
||
screenId: number,
|
||
tableName: string,
|
||
savedData: Record<string, any>,
|
||
triggerType: "insert" | "update" | "delete",
|
||
userId: string = "system",
|
||
companyCode: string = "*"
|
||
): Promise<void> {
|
||
try {
|
||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||
|
||
// 화면의 저장 버튼에서 제어관리 설정 조회
|
||
const screenLayouts = await query<{
|
||
component_id: string;
|
||
properties: any;
|
||
}>(
|
||
`SELECT component_id, properties
|
||
FROM screen_layouts
|
||
WHERE screen_id = $1
|
||
AND component_type = $2`,
|
||
[screenId, "component"]
|
||
);
|
||
|
||
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
||
|
||
// 저장 버튼 중에서 제어관리가 활성화된 것 찾기
|
||
let controlConfigFound = false;
|
||
for (const layout of screenLayouts) {
|
||
const properties = layout.properties as any;
|
||
|
||
// 디버깅: 모든 컴포넌트 정보 출력
|
||
console.log(`🔍 컴포넌트 검사:`, {
|
||
componentId: layout.component_id,
|
||
componentType: properties?.componentType,
|
||
actionType: properties?.componentConfig?.action?.type,
|
||
enableDataflowControl:
|
||
properties?.webTypeConfig?.enableDataflowControl,
|
||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
||
hasDiagramId:
|
||
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
||
});
|
||
|
||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||
if (
|
||
properties?.componentType === "button-primary" &&
|
||
properties?.componentConfig?.action?.type === "save" &&
|
||
properties?.webTypeConfig?.enableDataflowControl === true &&
|
||
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
|
||
) {
|
||
controlConfigFound = true;
|
||
const diagramId =
|
||
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
|
||
const relationshipId =
|
||
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
|
||
|
||
console.log(`🎯 제어관리 설정 발견:`, {
|
||
componentId: layout.component_id,
|
||
diagramId,
|
||
relationshipId,
|
||
triggerType,
|
||
});
|
||
|
||
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
|
||
let controlResult: any;
|
||
|
||
if (!relationshipId) {
|
||
// 노드 플로우 실행
|
||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||
const { NodeFlowExecutionService } = await import(
|
||
"./nodeFlowExecutionService"
|
||
);
|
||
|
||
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||
diagramId,
|
||
{
|
||
sourceData: [savedData],
|
||
dataSourceType: "formData",
|
||
buttonId: "save-button",
|
||
screenId: screenId,
|
||
userId: userId,
|
||
companyCode: companyCode,
|
||
formData: savedData,
|
||
}
|
||
);
|
||
|
||
controlResult = {
|
||
success: executionResult.success,
|
||
message: executionResult.message,
|
||
executedActions: executionResult.nodes?.map((node) => ({
|
||
nodeId: node.nodeId,
|
||
status: node.status,
|
||
duration: node.duration,
|
||
})),
|
||
errors: executionResult.nodes
|
||
?.filter((node) => node.status === "failed")
|
||
.map((node) => node.error || "실행 실패"),
|
||
};
|
||
} else {
|
||
// 관계 기반 제어관리 실행
|
||
console.log(
|
||
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
||
);
|
||
controlResult =
|
||
await this.dataflowControlService.executeDataflowControl(
|
||
diagramId,
|
||
relationshipId,
|
||
triggerType,
|
||
savedData,
|
||
tableName,
|
||
userId
|
||
);
|
||
}
|
||
|
||
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
||
|
||
if (controlResult.success) {
|
||
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
|
||
if (
|
||
controlResult.executedActions &&
|
||
controlResult.executedActions.length > 0
|
||
) {
|
||
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
|
||
}
|
||
|
||
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
|
||
if (controlResult.errors && controlResult.errors.length > 0) {
|
||
console.warn(
|
||
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
|
||
controlResult.errors
|
||
);
|
||
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
|
||
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
|
||
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
|
||
}
|
||
|
||
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!controlConfigFound) {
|
||
console.log(`ℹ️ 제어관리 설정이 없습니다. (화면 ID: ${screenId})`);
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ 제어관리 설정 확인 및 실행 오류:", error);
|
||
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 특정 테이블의 특정 필드 값만 업데이트
|
||
* (다른 테이블의 레코드 업데이트 지원)
|
||
*/
|
||
async updateFieldValue(
|
||
tableName: string,
|
||
keyField: string,
|
||
keyValue: any,
|
||
updateField: string,
|
||
updateValue: any,
|
||
companyCode: string,
|
||
userId: string
|
||
): Promise<{ affectedRows: number }> {
|
||
const pool = getPool();
|
||
const client = await pool.connect();
|
||
|
||
try {
|
||
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
|
||
tableName,
|
||
keyField,
|
||
keyValue,
|
||
updateField,
|
||
updateValue,
|
||
companyCode,
|
||
});
|
||
|
||
// 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인)
|
||
const columnQuery = `
|
||
SELECT column_name
|
||
FROM information_schema.columns
|
||
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
||
`;
|
||
const columnResult = await client.query(columnQuery, [tableName]);
|
||
const existingColumns = columnResult.rows.map(
|
||
(row: any) => row.column_name
|
||
);
|
||
|
||
const hasUpdatedBy = existingColumns.includes("updated_by");
|
||
const hasUpdatedAt = existingColumns.includes("updated_at");
|
||
const hasCompanyCode = existingColumns.includes("company_code");
|
||
|
||
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
||
hasUpdatedBy,
|
||
hasUpdatedAt,
|
||
hasCompanyCode,
|
||
});
|
||
|
||
// 동적 SET 절 구성
|
||
let setClause = `"${updateField}" = $1`;
|
||
const params: any[] = [updateValue];
|
||
let paramIndex = 2;
|
||
|
||
if (hasUpdatedBy) {
|
||
setClause += `, updated_by = $${paramIndex}`;
|
||
params.push(userId);
|
||
paramIndex++;
|
||
}
|
||
|
||
if (hasUpdatedAt) {
|
||
setClause += `, updated_at = NOW()`;
|
||
}
|
||
|
||
// WHERE 절 구성
|
||
let whereClause = `"${keyField}" = $${paramIndex}`;
|
||
params.push(keyValue);
|
||
paramIndex++;
|
||
|
||
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만)
|
||
if (hasCompanyCode && companyCode && companyCode !== "*") {
|
||
whereClause += ` AND company_code = $${paramIndex}`;
|
||
params.push(companyCode);
|
||
paramIndex++;
|
||
}
|
||
|
||
const sqlQuery = `
|
||
UPDATE "${tableName}"
|
||
SET ${setClause}
|
||
WHERE ${whereClause}
|
||
`;
|
||
|
||
console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery);
|
||
console.log("🔍 [updateFieldValue] 파라미터:", params);
|
||
|
||
const result = await client.query(sqlQuery, params);
|
||
|
||
console.log("✅ [updateFieldValue] 결과:", {
|
||
affectedRows: result.rowCount,
|
||
});
|
||
|
||
return { affectedRows: result.rowCount || 0 };
|
||
} catch (error) {
|
||
console.error("❌ [updateFieldValue] 오류:", error);
|
||
throw error;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 위치 이력 저장 (연속 위치 추적용)
|
||
*/
|
||
async saveLocationHistory(data: {
|
||
userId: string;
|
||
companyCode: string;
|
||
latitude: number;
|
||
longitude: number;
|
||
accuracy?: number;
|
||
altitude?: number;
|
||
speed?: number;
|
||
heading?: number;
|
||
tripId?: string;
|
||
tripStatus?: string;
|
||
departure?: string;
|
||
arrival?: string;
|
||
departureName?: string;
|
||
destinationName?: string;
|
||
recordedAt?: string;
|
||
vehicleId?: number;
|
||
}): Promise<{ id: number }> {
|
||
const pool = getPool();
|
||
const client = await pool.connect();
|
||
|
||
try {
|
||
console.log("📍 [saveLocationHistory] 저장 시작:", data);
|
||
|
||
const sqlQuery = `
|
||
INSERT INTO vehicle_location_history (
|
||
user_id,
|
||
company_code,
|
||
latitude,
|
||
longitude,
|
||
accuracy,
|
||
altitude,
|
||
speed,
|
||
heading,
|
||
trip_id,
|
||
trip_status,
|
||
departure,
|
||
arrival,
|
||
departure_name,
|
||
destination_name,
|
||
recorded_at,
|
||
vehicle_id
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||
RETURNING id
|
||
`;
|
||
|
||
const params = [
|
||
data.userId,
|
||
data.companyCode,
|
||
data.latitude,
|
||
data.longitude,
|
||
data.accuracy || null,
|
||
data.altitude || null,
|
||
data.speed || null,
|
||
data.heading || null,
|
||
data.tripId || null,
|
||
data.tripStatus || "active",
|
||
data.departure || null,
|
||
data.arrival || null,
|
||
data.departureName || null,
|
||
data.destinationName || null,
|
||
data.recordedAt ? new Date(data.recordedAt) : new Date(),
|
||
data.vehicleId || null,
|
||
];
|
||
|
||
const result = await client.query(sqlQuery, params);
|
||
|
||
console.log("✅ [saveLocationHistory] 저장 완료:", {
|
||
id: result.rows[0]?.id,
|
||
});
|
||
|
||
return { id: result.rows[0]?.id };
|
||
} catch (error) {
|
||
console.error("❌ [saveLocationHistory] 오류:", error);
|
||
throw error;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 위치 이력 조회 (경로 조회용)
|
||
*/
|
||
async getLocationHistory(params: {
|
||
companyCode: string;
|
||
tripId?: string;
|
||
userId?: string;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
limit?: number;
|
||
}): Promise<any[]> {
|
||
const pool = getPool();
|
||
const client = await pool.connect();
|
||
|
||
try {
|
||
console.log("📍 [getLocationHistory] 조회 시작:", params);
|
||
|
||
const conditions: string[] = [];
|
||
const queryParams: any[] = [];
|
||
let paramIndex = 1;
|
||
|
||
// 멀티테넌시: company_code 필터
|
||
if (params.companyCode && params.companyCode !== "*") {
|
||
conditions.push(`company_code = $${paramIndex}`);
|
||
queryParams.push(params.companyCode);
|
||
paramIndex++;
|
||
}
|
||
|
||
// trip_id 필터
|
||
if (params.tripId) {
|
||
conditions.push(`trip_id = $${paramIndex}`);
|
||
queryParams.push(params.tripId);
|
||
paramIndex++;
|
||
}
|
||
|
||
// user_id 필터
|
||
if (params.userId) {
|
||
conditions.push(`user_id = $${paramIndex}`);
|
||
queryParams.push(params.userId);
|
||
paramIndex++;
|
||
}
|
||
|
||
// 날짜 범위 필터
|
||
if (params.startDate) {
|
||
conditions.push(`recorded_at >= $${paramIndex}`);
|
||
queryParams.push(new Date(params.startDate));
|
||
paramIndex++;
|
||
}
|
||
|
||
if (params.endDate) {
|
||
conditions.push(`recorded_at <= $${paramIndex}`);
|
||
queryParams.push(new Date(params.endDate));
|
||
paramIndex++;
|
||
}
|
||
|
||
const whereClause =
|
||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
|
||
|
||
const sqlQuery = `
|
||
SELECT
|
||
id,
|
||
user_id,
|
||
vehicle_id,
|
||
latitude,
|
||
longitude,
|
||
accuracy,
|
||
altitude,
|
||
speed,
|
||
heading,
|
||
trip_id,
|
||
trip_status,
|
||
departure,
|
||
arrival,
|
||
departure_name,
|
||
destination_name,
|
||
recorded_at,
|
||
created_at,
|
||
company_code
|
||
FROM vehicle_location_history
|
||
${whereClause}
|
||
ORDER BY recorded_at ASC
|
||
${limitClause}
|
||
`;
|
||
|
||
console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery);
|
||
console.log("🔍 [getLocationHistory] 파라미터:", queryParams);
|
||
|
||
const result = await client.query(sqlQuery, queryParams);
|
||
|
||
console.log("✅ [getLocationHistory] 조회 완료:", {
|
||
count: result.rowCount,
|
||
});
|
||
|
||
return result.rows;
|
||
} catch (error) {
|
||
console.error("❌ [getLocationHistory] 오류:", error);
|
||
throw error;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 싱글톤 인스턴스 생성 및 export
|
||
export const dynamicFormService = new DynamicFormService();
|