import { PrismaClient } from "@prisma/client"; import prisma from "../config/database"; 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; // 검색 조건 구성 const where: any = { is_active: "Y", OR: [ { company_code: companyCode }, ...(includePublic ? [{ is_public: "Y" }] : []), ], }; if (category) { where.category = category; } if (layoutType) { where.layout_type = layoutType; } if (searchTerm) { where.OR = [ ...where.OR, { layout_name: { contains: searchTerm, mode: "insensitive" } }, { layout_name_eng: { contains: searchTerm, mode: "insensitive" } }, { description: { contains: searchTerm, mode: "insensitive" } }, ]; } const [data, total] = await Promise.all([ prisma.layout_standards.findMany({ where, skip, take: size, orderBy: [{ sort_order: "asc" }, { created_date: "desc" }], }), prisma.layout_standards.count({ where }), ]); 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 prisma.layout_standards.findFirst({ where: { layout_code: layoutCode, is_active: "Y", OR: [{ company_code: companyCode }, { is_public: "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 prisma.layout_standards.create({ data: { layout_code: layoutCode, layout_name: request.layoutName, layout_name_eng: request.layoutNameEng, description: request.description, layout_type: request.layoutType, category: request.category, icon_name: request.iconName, default_size: safeJSONStringify(request.defaultSize) as any, layout_config: safeJSONStringify(request.layoutConfig) as any, zones_config: safeJSONStringify(request.zonesConfig) as any, is_public: request.isPublic ? "Y" : "N", company_code: companyCode, created_by: userId, updated_by: userId, }, }); return this.mapToLayoutStandard(layout); } /** * 레이아웃 수정 */ async updateLayout( request: UpdateLayoutRequest, companyCode: string, userId: string ): Promise { // 수정 권한 확인 const existing = await prisma.layout_standards.findFirst({ where: { layout_code: request.layoutCode, company_code: companyCode, is_active: "Y", }, }); if (!existing) { throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다."); } const updateData: any = { updated_by: userId, updated_date: new Date(), }; // 수정할 필드만 업데이트 if (request.layoutName !== undefined) updateData.layout_name = request.layoutName; if (request.layoutNameEng !== undefined) updateData.layout_name_eng = request.layoutNameEng; if (request.description !== undefined) updateData.description = request.description; if (request.layoutType !== undefined) updateData.layout_type = request.layoutType; if (request.category !== undefined) updateData.category = request.category; if (request.iconName !== undefined) updateData.icon_name = request.iconName; if (request.defaultSize !== undefined) updateData.default_size = safeJSONStringify(request.defaultSize) as any; if (request.layoutConfig !== undefined) updateData.layout_config = safeJSONStringify(request.layoutConfig) as any; if (request.zonesConfig !== undefined) updateData.zones_config = safeJSONStringify(request.zonesConfig) as any; if (request.isPublic !== undefined) updateData.is_public = request.isPublic ? "Y" : "N"; const updated = await prisma.layout_standards.update({ where: { layout_code: request.layoutCode }, data: updateData, }); return this.mapToLayoutStandard(updated); } /** * 레이아웃 삭제 (소프트 삭제) */ async deleteLayout( layoutCode: string, companyCode: string, userId: string ): Promise { const existing = await prisma.layout_standards.findFirst({ where: { layout_code: layoutCode, company_code: companyCode, is_active: "Y", }, }); if (!existing) { throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다."); } await prisma.layout_standards.update({ where: { layout_code: layoutCode }, data: { is_active: "N", updated_by: userId, updated_date: new Date(), }, }); 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 prisma.layout_standards.groupBy({ by: ["category"], _count: { layout_code: true, }, where: { is_active: "Y", OR: [{ company_code: companyCode }, { is_public: "Y" }], }, }); return counts.reduce( (acc: Record, item: any) => { acc[item.category] = item._count.layout_code; return acc; }, {} as Record ); } /** * 레이아웃 코드 자동 생성 */ private async generateLayoutCode( layoutType: string, companyCode: string ): Promise { const prefix = `${layoutType.toUpperCase()}_${companyCode}`; const existingCodes = await prisma.layout_standards.findMany({ where: { layout_code: { startsWith: prefix, }, }, select: { layout_code: true, }, }); 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();