import { query, queryOne } from "../database/db"; import { CreateLayoutRequest, UpdateLayoutRequest, LayoutStandard, LayoutType, LayoutCategory, } from "../types/layout"; // JSON 데이터를 안전하게 파싱하는 헬퍼 함수 function safeJSONParse(data: any): any { if (data === null || data === undefined) { return null; } // 이미 객체인 경우 그대로 반환 if (typeof data === "object") { return data; } // 문자열인 경우 파싱 시도 if (typeof data === "string") { try { return JSON.parse(data); } catch (error) { console.error("JSON 파싱 오류:", error, "Data:", data); return null; } } return data; } // JSON 데이터를 안전하게 문자열화하는 헬퍼 함수 function safeJSONStringify(data: any): string | null { if (data === null || data === undefined) { return null; } // 이미 문자열인 경우 그대로 반환 if (typeof data === "string") { return data; } // 객체인 경우 문자열로 변환 try { return JSON.stringify(data); } catch (error) { console.error("JSON 문자열화 오류:", error, "Data:", data); return null; } } export class LayoutService { /** * 레이아웃 목록 조회 */ async getLayouts(params: { page?: number; size?: number; category?: string; layoutType?: string; searchTerm?: string; companyCode: string; includePublic?: boolean; }): Promise<{ data: LayoutStandard[]; total: number }> { const { page = 1, size = 20, category, layoutType, searchTerm, companyCode, includePublic = true, } = params; const skip = (page - 1) * size; // 동적 WHERE 조건 구성 const whereConditions: string[] = ["is_active = $1"]; const values: any[] = ["Y"]; let paramIndex = 2; // company_code OR is_public 조건 if (includePublic) { whereConditions.push( `(company_code = $${paramIndex} OR is_public = $${paramIndex + 1})` ); values.push(companyCode, "Y"); paramIndex += 2; } else { whereConditions.push(`company_code = $${paramIndex++}`); values.push(companyCode); } if (category) { whereConditions.push(`category = $${paramIndex++}`); values.push(category); } if (layoutType) { whereConditions.push(`layout_type = $${paramIndex++}`); values.push(layoutType); } if (searchTerm) { whereConditions.push( `(layout_name ILIKE $${paramIndex} OR layout_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})` ); values.push(`%${searchTerm}%`); paramIndex++; } const whereClause = `WHERE ${whereConditions.join(" AND ")}`; const [data, countResult] = await Promise.all([ query( `SELECT * FROM layout_standards ${whereClause} ORDER BY sort_order ASC, created_date DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...values, size, skip] ), queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM layout_standards ${whereClause}`, values ), ]); const total = parseInt(countResult?.count || "0"); return { data: data.map( (layout) => ({ layoutCode: layout.layout_code, layoutName: layout.layout_name, layoutNameEng: layout.layout_name_eng, description: layout.description, layoutType: layout.layout_type as LayoutType, category: layout.category as LayoutCategory, iconName: layout.icon_name, defaultSize: safeJSONParse(layout.default_size), layoutConfig: safeJSONParse(layout.layout_config), zonesConfig: safeJSONParse(layout.zones_config), previewImage: layout.preview_image, sortOrder: layout.sort_order, isActive: layout.is_active, isPublic: layout.is_public, companyCode: layout.company_code, createdDate: layout.created_date, createdBy: layout.created_by, updatedDate: layout.updated_date, updatedBy: layout.updated_by, }) as LayoutStandard ), total, }; } /** * 레이아웃 상세 조회 */ async getLayoutById( layoutCode: string, companyCode: string ): Promise { const layout = await queryOne( `SELECT * FROM layout_standards WHERE layout_code = $1 AND is_active = $2 AND (company_code = $3 OR is_public = $4) LIMIT 1`, [layoutCode, "Y", companyCode, "Y"] ); if (!layout) return null; return { layoutCode: layout.layout_code, layoutName: layout.layout_name, layoutNameEng: layout.layout_name_eng, description: layout.description, layoutType: layout.layout_type as LayoutType, category: layout.category as LayoutCategory, iconName: layout.icon_name, defaultSize: safeJSONParse(layout.default_size), layoutConfig: safeJSONParse(layout.layout_config), zonesConfig: safeJSONParse(layout.zones_config), previewImage: layout.preview_image, sortOrder: layout.sort_order, isActive: layout.is_active, isPublic: layout.is_public, companyCode: layout.company_code, createdDate: layout.created_date, createdBy: layout.created_by, updatedDate: layout.updated_date, updatedBy: layout.updated_by, } as LayoutStandard; } /** * 레이아웃 생성 */ async createLayout( request: CreateLayoutRequest, companyCode: string, userId: string ): Promise { // 레이아웃 코드 생성 (자동) const layoutCode = await this.generateLayoutCode( request.layoutType, companyCode ); const layout = await queryOne( `INSERT INTO layout_standards (layout_code, layout_name, layout_name_eng, description, layout_type, category, icon_name, default_size, layout_config, zones_config, is_public, is_active, company_code, created_by, updated_by, created_date, updated_date, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW(), 0) RETURNING *`, [ layoutCode, request.layoutName, request.layoutNameEng, request.description, request.layoutType, request.category, request.iconName, safeJSONStringify(request.defaultSize), safeJSONStringify(request.layoutConfig), safeJSONStringify(request.zonesConfig), request.isPublic ? "Y" : "N", "Y", companyCode, userId, userId, ] ); return this.mapToLayoutStandard(layout); } /** * 레이아웃 수정 */ async updateLayout( request: UpdateLayoutRequest, companyCode: string, userId: string ): Promise { // 수정 권한 확인 const existing = await queryOne( `SELECT * FROM layout_standards WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`, [request.layoutCode, companyCode, "Y"] ); if (!existing) { throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다."); } // 동적 UPDATE 쿼리 생성 const updateFields: string[] = ["updated_by = $1", "updated_date = NOW()"]; const values: any[] = [userId]; let paramIndex = 2; if (request.layoutName !== undefined) { updateFields.push(`layout_name = $${paramIndex++}`); values.push(request.layoutName); } if (request.layoutNameEng !== undefined) { updateFields.push(`layout_name_eng = $${paramIndex++}`); values.push(request.layoutNameEng); } if (request.description !== undefined) { updateFields.push(`description = $${paramIndex++}`); values.push(request.description); } if (request.layoutType !== undefined) { updateFields.push(`layout_type = $${paramIndex++}`); values.push(request.layoutType); } if (request.category !== undefined) { updateFields.push(`category = $${paramIndex++}`); values.push(request.category); } if (request.iconName !== undefined) { updateFields.push(`icon_name = $${paramIndex++}`); values.push(request.iconName); } if (request.defaultSize !== undefined) { updateFields.push(`default_size = $${paramIndex++}`); values.push(safeJSONStringify(request.defaultSize)); } if (request.layoutConfig !== undefined) { updateFields.push(`layout_config = $${paramIndex++}`); values.push(safeJSONStringify(request.layoutConfig)); } if (request.zonesConfig !== undefined) { updateFields.push(`zones_config = $${paramIndex++}`); values.push(safeJSONStringify(request.zonesConfig)); } if (request.isPublic !== undefined) { updateFields.push(`is_public = $${paramIndex++}`); values.push(request.isPublic ? "Y" : "N"); } const updated = await queryOne( `UPDATE layout_standards SET ${updateFields.join(", ")} WHERE layout_code = $${paramIndex} RETURNING *`, [...values, request.layoutCode] ); return this.mapToLayoutStandard(updated); } /** * 레이아웃 삭제 (소프트 삭제) */ async deleteLayout( layoutCode: string, companyCode: string, userId: string ): Promise { const existing = await queryOne( `SELECT * FROM layout_standards WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`, [layoutCode, companyCode, "Y"] ); if (!existing) { throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다."); } await query( `UPDATE layout_standards SET is_active = $1, updated_by = $2, updated_date = NOW() WHERE layout_code = $3`, ["N", userId, layoutCode] ); return true; } /** * 레이아웃 복제 */ async duplicateLayout( layoutCode: string, newName: string, companyCode: string, userId: string ): Promise { const original = await this.getLayoutById(layoutCode, companyCode); if (!original) { throw new Error("복제할 레이아웃을 찾을 수 없습니다."); } const duplicateRequest: CreateLayoutRequest = { layoutName: newName, layoutNameEng: original.layoutNameEng ? `${original.layoutNameEng} Copy` : undefined, description: original.description, layoutType: original.layoutType, category: original.category, iconName: original.iconName, defaultSize: original.defaultSize, layoutConfig: original.layoutConfig, zonesConfig: original.zonesConfig, isPublic: false, // 복제본은 비공개로 시작 }; return this.createLayout(duplicateRequest, companyCode, userId); } /** * 카테고리별 레이아웃 개수 조회 */ async getLayoutCountsByCategory( companyCode: string ): Promise> { const counts = await query<{ category: string; count: string }>( `SELECT category, COUNT(*) as count FROM layout_standards WHERE is_active = $1 AND (company_code = $2 OR is_public = $3) GROUP BY category`, ["Y", companyCode, "Y"] ); return counts.reduce( (acc: Record, item: any) => { acc[item.category] = parseInt(item.count); return acc; }, {} as Record ); } /** * 레이아웃 코드 자동 생성 */ private async generateLayoutCode( layoutType: string, companyCode: string ): Promise { const prefix = `${layoutType.toUpperCase()}_${companyCode}`; const existingCodes = await query<{ layout_code: string }>( `SELECT layout_code FROM layout_standards WHERE layout_code LIKE $1`, [`${prefix}%`] ); const maxNumber = existingCodes.reduce((max: number, item: any) => { const match = item.layout_code.match(/_(\d+)$/); if (match) { const number = parseInt(match[1], 10); return Math.max(max, number); } return max; }, 0); return `${prefix}_${String(maxNumber + 1).padStart(3, "0")}`; } /** * 데이터베이스 모델을 LayoutStandard 타입으로 변환 */ private mapToLayoutStandard(layout: any): LayoutStandard { return { layoutCode: layout.layout_code, layoutName: layout.layout_name, layoutNameEng: layout.layout_name_eng, description: layout.description, layoutType: layout.layout_type, category: layout.category, iconName: layout.icon_name, defaultSize: layout.default_size, layoutConfig: layout.layout_config, zonesConfig: layout.zones_config, previewImage: layout.preview_image, sortOrder: layout.sort_order, isActive: layout.is_active, isPublic: layout.is_public, companyCode: layout.company_code, createdDate: layout.created_date, createdBy: layout.created_by, updatedDate: layout.updated_date, updatedBy: layout.updated_by, }; } } export const layoutService = new LayoutService();