diff --git a/PLAN.MD b/PLAN.MD new file mode 100644 index 00000000..7c3b1007 --- /dev/null +++ b/PLAN.MD @@ -0,0 +1,27 @@ +# 프로젝트: Digital Twin 에디터 안정화 + +## 개요 + +Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다. + +## 핵심 기능 + +1. `DigitalTwinEditor` 버그 수정 +2. 비동기 함수 입력값 유효성 검증 강화 +3. 외부 DB 연결 상태에 따른 방어 코드 추가 + +## 테스트 계획 + +### 1단계: 긴급 버그 수정 + +- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료) +- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인 + +### 2단계: 잠재적 문제 점검 + +- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사 +- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리 + +## 진행 상태 + +- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md new file mode 100644 index 00000000..570dd789 --- /dev/null +++ b/PROJECT_STATUS_2025_11_20.md @@ -0,0 +1,57 @@ +# 프로젝트 진행 상황 (2025-11-20) + +## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조) + +### 1. 핵심 변경 사항 +기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다. + +### 2. 완료된 작업 + +#### 데이터베이스 +- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql` +- **스키마 변경**: + - `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가 + - `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가 + - 기존 하드코딩된 테이블 매핑 컬럼 제거 + +#### 백엔드 (Node.js) +- **API 추가/수정**: + - `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회 + - `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회 + - 기존 레거시 API (`getWarehouses` 등) 호환성 유지 +- **컨트롤러 수정**: + - `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현 + - `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리 + +#### 프론트엔드 (React) +- **신규 컴포넌트**: `HierarchyConfigPanel.tsx` + - 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI +- **유틸리티**: `spatialContainment.ts` + - `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB) + - `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동) +- **에디터 통합 (`DigitalTwinEditor.tsx`)**: + - `HierarchyConfigPanel` 적용 + - 동적 데이터 로드 로직 구현 + - 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용 + - 객체 이동 시 그룹 이동 적용 + +### 3. 현재 상태 +- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨) +- **DB**: 마이그레이션 스크립트 실행 완료 + +### 4. 다음 단계 (테스트 필요) +새로운 세션에서 다음 시나리오를 테스트해야 합니다: +1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장 +2. **배치 검증**: + - 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함) + - 위치를 구역 **외부**에 배치 (실패해야 함) +3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인 + +### 5. 관련 파일 +- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts` +- `backend-node/src/controllers/digitalTwinDataController.ts` +- `backend-node/src/routes/digitalTwinRoutes.ts` +- `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/scripts/test-digital-twin-db.ts b/backend-node/scripts/test-digital-twin-db.ts new file mode 100644 index 00000000..7d0efce7 --- /dev/null +++ b/backend-node/scripts/test-digital-twin-db.ts @@ -0,0 +1,209 @@ +/** + * 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트 + * READ-ONLY: SELECT 쿼리만 실행 + */ + +import { Pool } from "pg"; +import mysql from "mysql2/promise"; +import { CredentialEncryption } from "../src/utils/credentialEncryption"; + +async function testDigitalTwinDb() { + // 내부 DB 연결 (연결 정보 저장용) + const internalPool = new Pool({ + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + database: process.env.DB_NAME || "plm", + user: process.env.DB_USER || "postgres", + password: process.env.DB_PASSWORD || "ph0909!!", + }); + + const encryptionKey = + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; + const encryption = new CredentialEncryption(encryptionKey); + + try { + console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n"); + + // 디지털 트윈 외부 DB 연결 정보 + const digitalTwinConnection = { + name: "디지털트윈_DO_DY", + description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)", + dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용 + host: "1.240.13.83", + port: 4307, + databaseName: "DO_DY", + username: "root", + password: "pohangms619!#", + sslEnabled: false, + isActive: true, + }; + + console.log("📝 연결 정보:"); + console.log(` - 이름: ${digitalTwinConnection.name}`); + console.log(` - DB 타입: ${digitalTwinConnection.dbType}`); + console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`); + console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`); + + // 1. 외부 DB 직접 연결 테스트 + console.log("🔍 외부 DB 직접 연결 테스트 중..."); + + const externalConnection = await mysql.createConnection({ + host: digitalTwinConnection.host, + port: digitalTwinConnection.port, + database: digitalTwinConnection.databaseName, + user: digitalTwinConnection.username, + password: digitalTwinConnection.password, + connectTimeout: 10000, + }); + + console.log("✅ 외부 DB 연결 성공!\n"); + + // 2. SELECT 쿼리 실행 + console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n"); + + const query = ` + SELECT + SKUMKEY -- 제품번호 + , SKUDESC -- 자재명 + , SKUTHIC -- 두께 + , SKUWIDT -- 폭 + , SKULENG -- 길이 + , SKUWEIG -- 중량 + , STOTQTY -- 수량 + , SUOMKEY -- 단위 + FROM DO_DY.WSTKKY + LIMIT 10 + `; + + const [rows] = await externalConnection.execute(query); + + console.log("✅ 쿼리 실행 성공!\n"); + console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`); + + if (Array.isArray(rows) && rows.length > 0) { + console.log("🔍 샘플 데이터 (첫 3건):\n"); + rows.slice(0, 3).forEach((row: any, index: number) => { + console.log(`[${index + 1}]`); + console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`); + console.log(` 자재명(SKUDESC): ${row.SKUDESC}`); + console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`); + console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`); + console.log(` 길이(SKULENG): ${row.SKULENG}`); + console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`); + console.log(` 수량(STOTQTY): ${row.STOTQTY}`); + console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`); + }); + + // 전체 데이터 JSON 출력 + console.log("📄 전체 데이터 (JSON):"); + console.log(JSON.stringify(rows, null, 2)); + console.log("\n"); + } + + await externalConnection.end(); + + // 3. 내부 DB에 연결 정보 저장 + console.log("💾 내부 DB에 연결 정보 저장 중..."); + + const encryptedPassword = encryption.encrypt(digitalTwinConnection.password); + + // 중복 체크 + const existingResult = await internalPool.query( + "SELECT id FROM flow_external_db_connection WHERE name = $1", + [digitalTwinConnection.name] + ); + + let connectionId: number; + + if (existingResult.rows.length > 0) { + connectionId = existingResult.rows[0].id; + console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`); + + // 기존 연결 업데이트 + await internalPool.query( + `UPDATE flow_external_db_connection + SET description = $1, + db_type = $2, + host = $3, + port = $4, + database_name = $5, + username = $6, + password_encrypted = $7, + ssl_enabled = $8, + is_active = $9, + updated_at = NOW(), + updated_by = 'system' + WHERE name = $10`, + [ + digitalTwinConnection.description, + digitalTwinConnection.dbType, + digitalTwinConnection.host, + digitalTwinConnection.port, + digitalTwinConnection.databaseName, + digitalTwinConnection.username, + encryptedPassword, + digitalTwinConnection.sslEnabled, + digitalTwinConnection.isActive, + digitalTwinConnection.name, + ] + ); + console.log(`✅ 연결 정보 업데이트 완료`); + } else { + // 새 연결 추가 + const result = await internalPool.query( + `INSERT INTO flow_external_db_connection ( + name, + description, + db_type, + host, + port, + database_name, + username, + password_encrypted, + ssl_enabled, + is_active, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system') + RETURNING id`, + [ + digitalTwinConnection.name, + digitalTwinConnection.description, + digitalTwinConnection.dbType, + digitalTwinConnection.host, + digitalTwinConnection.port, + digitalTwinConnection.databaseName, + digitalTwinConnection.username, + encryptedPassword, + digitalTwinConnection.sslEnabled, + digitalTwinConnection.isActive, + ] + ); + connectionId = result.rows[0].id; + console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`); + } + + console.log("\n✅ 모든 테스트 완료!"); + console.log(`\n📌 연결 ID: ${connectionId}`); + console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다."); + + } catch (error: any) { + console.error("\n❌ 오류 발생:", error.message); + console.error("상세 정보:", error); + throw error; + } finally { + await internalPool.end(); + } +} + +// 스크립트 실행 +testDigitalTwinDb() + .then(() => { + console.log("\n🎉 스크립트 완료"); + process.exit(0); + }) + .catch((error) => { + console.error("\n💥 스크립트 실패:", error); + process.exit(1); + }); + + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 37936f36..be51e70e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -59,6 +59,7 @@ import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 +import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 @@ -68,6 +69,8 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 +import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 +import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -221,6 +224,7 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D // app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) +app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제) app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 @@ -230,6 +234,8 @@ app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 +app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 +app.use("/api/orders", orderRoutes); // 수주 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts new file mode 100644 index 00000000..80cb8ccd --- /dev/null +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -0,0 +1,403 @@ +import { Request, Response } from "express"; +import { pool, queryOne } from "../database/db"; +import logger from "../utils/logger"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; + +// 외부 DB 커넥터를 가져오는 헬퍼 함수 +export async function getExternalDbConnector(connectionId: number) { + // 외부 DB 연결 정보 조회 + const connection = await queryOne( + `SELECT * FROM external_db_connections WHERE id = $1`, + [connectionId] + ); + + if (!connection) { + throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`); + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + return await DatabaseConnectorFactory.createConnector( + connection.db_type || "mariadb", + config, + connectionId + ); +} + +// 동적 계층 구조 데이터 조회 (범용) +export const getHierarchyData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + const result: any = { + warehouse: null, + levels: [], + materials: [], + }; + + // 창고 데이터 조회 + if (config.warehouse) { + const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`; + const warehouseResult = await connector.executeQuery(warehouseQuery); + result.warehouse = warehouseResult.rows; + } + + // 각 레벨 데이터 조회 + if (config.levels && Array.isArray(config.levels)) { + for (const level of config.levels) { + const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`; + const levelResult = await connector.executeQuery(levelQuery); + + result.levels.push({ + level: level.level, + name: level.name, + data: levelResult.rows, + }); + } + } + + // 자재 데이터 조회 (개수만) + if (config.material) { + const materialQuery = ` + SELECT + ${config.material.locationKeyColumn} as location_key, + COUNT(*) as count + FROM ${config.material.tableName} + GROUP BY ${config.material.locationKeyColumn} + `; + const materialResult = await connector.executeQuery(materialQuery); + result.materials = materialResult.rows; + } + + logger.info("동적 계층 구조 데이터 조회", { + externalDbConnectionId, + warehouseCount: result.warehouse?.length || 0, + levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })), + }); + + return res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("동적 계층 구조 데이터 조회 실패", error); + return res.status(500).json({ + success: false, + message: "데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 특정 레벨의 하위 데이터 조회 +export const getChildrenData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + // 다음 레벨 찾기 + const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); + + if (!nextLevel) { + return res.json({ + success: true, + data: [], + message: "하위 레벨이 없습니다.", + }); + } + + // 하위 데이터 조회 + const query = ` + SELECT * FROM ${nextLevel.tableName} + WHERE ${nextLevel.parentKeyColumn} = '${parentKey}' + LIMIT 1000 + `; + + const result = await connector.executeQuery(query); + + logger.info("하위 데이터 조회", { + externalDbConnectionId, + parentLevel, + parentKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("하위 데이터 조회 실패", error); + return res.status(500).json({ + success: false, + message: "하위 데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 +export const getWarehouses = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName } = req.query; + + if (!externalDbConnectionId) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID가 필요합니다.", + }); + } + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + const query = `SELECT * FROM ${tableName} LIMIT 100`; + + const result = await connector.executeQuery(query); + + logger.info("창고 목록 조회", { + externalDbConnectionId, + tableName, + count: result.rows.length, + data: result.rows, // 실제 데이터 확인 + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("창고 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "창고 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 +export const getAreas = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, warehouseKey, tableName } = req.query; + + if (!externalDbConnectionId || !warehouseKey || !tableName) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + const query = ` + SELECT * FROM ${tableName} + WHERE WAREKEY = '${warehouseKey}' + LIMIT 1000 + `; + + const result = await connector.executeQuery(query); + + logger.info("구역 목록 조회", { + externalDbConnectionId, + tableName, + warehouseKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("구역 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "구역 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 +export const getLocations = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, areaKey, tableName } = req.query; + + if (!externalDbConnectionId || !areaKey || !tableName) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + const query = ` + SELECT * FROM ${tableName} + WHERE AREAKEY = '${areaKey}' + LIMIT 1000 + `; + + const result = await connector.executeQuery(query); + + logger.info("위치 목록 조회", { + externalDbConnectionId, + tableName, + areaKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("위치 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "위치 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 자재 목록 조회 (동적 컬럼 매핑 지원) +export const getMaterials = async (req: Request, res: Response): Promise => { + try { + const { + externalDbConnectionId, + locaKey, + tableName, + keyColumn, + locationKeyColumn, + layerColumn + } = req.query; + + if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 동적 쿼리 생성 + const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; + const query = ` + SELECT * FROM ${tableName} + WHERE ${locationKeyColumn} = '${locaKey}' + ${orderByClause} + LIMIT 1000 + `; + + logger.info(`자재 조회 쿼리: ${query}`); + + const result = await connector.executeQuery(query); + + logger.info("자재 목록 조회", { + externalDbConnectionId, + tableName, + locaKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("자재 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "자재 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지 +export const getMaterialCounts = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, locationKeys, tableName } = req.body; + + if (!externalDbConnectionId || !locationKeys || !tableName) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + const keysString = locationKeys.map((key: string) => `'${key}'`).join(","); + + const query = ` + SELECT + LOCAKEY as location_key, + COUNT(*) as count + FROM ${tableName} + WHERE LOCAKEY IN (${keysString}) + GROUP BY LOCAKEY + `; + + const result = await connector.executeQuery(query); + + logger.info("자재 개수 조회", { + externalDbConnectionId, + tableName, + locationCount: locationKeys.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("자재 개수 조회 실패", error); + return res.status(500).json({ + success: false, + message: "자재 개수 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/controllers/digitalTwinLayoutController.ts b/backend-node/src/controllers/digitalTwinLayoutController.ts new file mode 100644 index 00000000..d7ecbae1 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinLayoutController.ts @@ -0,0 +1,450 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +// 레이아웃 목록 조회 +export const getLayouts = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { externalDbConnectionId, warehouseKey } = req.query; + + let query = ` + SELECT + l.*, + u1.user_name as created_by_name, + u2.user_name as updated_by_name, + COUNT(o.id) as object_count + FROM digital_twin_layout l + LEFT JOIN user_info u1 ON l.created_by = u1.user_id + LEFT JOIN user_info u2 ON l.updated_by = u2.user_id + LEFT JOIN digital_twin_objects o ON l.id = o.layout_id + WHERE l.company_code = $1 + `; + + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (externalDbConnectionId) { + query += ` AND l.external_db_connection_id = $${paramIndex}`; + params.push(externalDbConnectionId); + paramIndex++; + } + + if (warehouseKey) { + query += ` AND l.warehouse_key = $${paramIndex}`; + params.push(warehouseKey); + paramIndex++; + } + + query += ` + GROUP BY l.id, u1.user_name, u2.user_name + ORDER BY l.updated_at DESC + `; + + const result = await pool.query(query, params); + + logger.info("레이아웃 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("레이아웃 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 레이아웃 상세 조회 (객체 포함) +export const getLayoutById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + // 레이아웃 기본 정보 + const layoutQuery = ` + SELECT l.* + FROM digital_twin_layout l + WHERE l.id = $1 AND l.company_code = $2 + `; + + const layoutResult = await pool.query(layoutQuery, [id, companyCode]); + + if (layoutResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + // 배치된 객체들 조회 + const objectsQuery = ` + SELECT * + FROM digital_twin_objects + WHERE layout_id = $1 + ORDER BY display_order, created_at + `; + + const objectsResult = await pool.query(objectsQuery, [id]); + + logger.info("레이아웃 상세 조회", { + companyCode, + layoutId: id, + objectCount: objectsResult.rowCount, + }); + + return res.json({ + success: true, + data: { + layout: layoutResult.rows[0], + objects: objectsResult.rows, + }, + }); + } catch (error: any) { + logger.error("레이아웃 상세 조회 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 레이아웃 생성 +export const createLayout = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const { + externalDbConnectionId, + warehouseKey, + layoutName, + description, + hierarchyConfig, + objects, + } = req.body; + + await client.query("BEGIN"); + + // 레이아웃 생성 + const layoutQuery = ` + INSERT INTO digital_twin_layout ( + company_code, external_db_connection_id, warehouse_key, + layout_name, description, hierarchy_config, created_by, updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + RETURNING * + `; + + const layoutResult = await client.query(layoutQuery, [ + companyCode, + externalDbConnectionId, + warehouseKey, + layoutName, + description, + hierarchyConfig ? JSON.stringify(hierarchyConfig) : null, + userId, + ]); + + const layoutId = layoutResult.rows[0].id; + + // 객체들 저장 + if (objects && objects.length > 0) { + const objectQuery = ` + INSERT INTO digital_twin_objects ( + layout_id, object_type, object_name, + position_x, position_y, position_z, + size_x, size_y, size_z, + rotation, color, + area_key, loca_key, loc_type, + material_count, material_preview_height, + parent_id, display_order, locked, + hierarchy_level, parent_key, external_key + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + `; + + for (const obj of objects) { + await client.query(objectQuery, [ + layoutId, + obj.type, + obj.name, + obj.position.x, + obj.position.y, + obj.position.z, + obj.size.x, + obj.size.y, + obj.size.z, + obj.rotation || 0, + obj.color, + obj.areaKey || null, + obj.locaKey || null, + obj.locType || null, + obj.materialCount || 0, + obj.materialPreview?.height || null, + obj.parentId || null, + obj.displayOrder || 0, + obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("레이아웃 생성", { + companyCode, + layoutId, + objectCount: objects?.length || 0, + }); + + return res.status(201).json({ + success: true, + data: layoutResult.rows[0], + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("레이아웃 생성 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +}; + +// 레이아웃 수정 +export const updateLayout = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const { id } = req.params; + const { + layoutName, + description, + hierarchyConfig, + externalDbConnectionId, + warehouseKey, + objects, + } = req.body; + + await client.query("BEGIN"); + + // 레이아웃 기본 정보 수정 + const updateLayoutQuery = ` + UPDATE digital_twin_layout + SET layout_name = $1, + description = $2, + hierarchy_config = $3, + external_db_connection_id = $4, + warehouse_key = $5, + updated_by = $6, + updated_at = NOW() + WHERE id = $7 AND company_code = $8 + RETURNING * + `; + + const layoutResult = await client.query(updateLayoutQuery, [ + layoutName, + description, + hierarchyConfig ? JSON.stringify(hierarchyConfig) : null, + externalDbConnectionId || null, + warehouseKey || null, + userId, + id, + companyCode, + ]); + + if (layoutResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + // 기존 객체 삭제 + await client.query( + "DELETE FROM digital_twin_objects WHERE layout_id = $1", + [id] + ); + + // 새 객체 저장 (부모-자식 관계 처리) + if (objects && objects.length > 0) { + const objectQuery = ` + INSERT INTO digital_twin_objects ( + layout_id, object_type, object_name, + position_x, position_y, position_z, + size_x, size_y, size_z, + rotation, color, + area_key, loca_key, loc_type, + material_count, material_preview_height, + parent_id, display_order, locked, + hierarchy_level, parent_key, external_key + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + RETURNING id + `; + + // 임시 ID (음수) → 실제 DB ID 매핑 + const idMapping: { [tempId: number]: number } = {}; + + // 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들) + for (const obj of objects.filter((o) => !o.parentId)) { + const result = await client.query(objectQuery, [ + id, + obj.type, + obj.name, + obj.position.x, + obj.position.y, + obj.position.z, + obj.size.x, + obj.size.y, + obj.size.z, + obj.rotation || 0, + obj.color, + obj.areaKey || null, + obj.locaKey || null, + obj.locType || null, + obj.materialCount || 0, + obj.materialPreview?.height || null, + null, // parent_id + obj.displayOrder || 0, + obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, + ]); + + // 임시 ID와 실제 DB ID 매핑 + if (obj.id) { + idMapping[obj.id] = result.rows[0].id; + } + } + + // 2단계: 자식 객체 저장 (parentId가 있는 것들) + for (const obj of objects.filter((o) => o.parentId)) { + const realParentId = idMapping[obj.parentId!] || null; + + await client.query(objectQuery, [ + id, + obj.type, + obj.name, + obj.position.x, + obj.position.y, + obj.position.z, + obj.size.x, + obj.size.y, + obj.size.z, + obj.rotation || 0, + obj.color, + obj.areaKey || null, + obj.locaKey || null, + obj.locType || null, + obj.materialCount || 0, + obj.materialPreview?.height || null, + realParentId, // 실제 DB ID 사용 + obj.displayOrder || 0, + obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("레이아웃 수정", { + companyCode, + layoutId: id, + objectCount: objects?.length || 0, + }); + + return res.json({ + success: true, + data: layoutResult.rows[0], + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("레이아웃 수정 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +}; + +// 레이아웃 삭제 +export const deleteLayout = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + const query = ` + DELETE FROM digital_twin_layout + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + logger.info("레이아웃 삭제", { + companyCode, + layoutId: id, + }); + + return res.json({ + success: true, + message: "레이아웃이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("레이아웃 삭제 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts new file mode 100644 index 00000000..880c54fc --- /dev/null +++ b/backend-node/src/controllers/entitySearchController.ts @@ -0,0 +1,122 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 엔티티 검색 API + * GET /api/entity-search/:tableName + */ +export async function searchEntity(req: AuthenticatedRequest, res: Response) { + try { + const { tableName } = req.params; + const { + searchText = "", + searchFields = "", + filterCondition = "{}", + page = "1", + limit = "20", + } = req.query; + + // tableName 유효성 검증 + if (!tableName || tableName === "undefined" || tableName === "null") { + logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName }); + return res.status(400).json({ + success: false, + message: + "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.", + }); + } + + // 멀티테넌시 + const companyCode = req.user!.companyCode; + + // 검색 필드 파싱 + const fields = searchFields + ? (searchFields as string).split(",").map((f) => f.trim()) + : []; + + // WHERE 조건 생성 + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // 검색 조건 + if (searchText && fields.length > 0) { + const searchConditions = fields.map((field) => { + const condition = `${field}::text ILIKE $${paramIndex}`; + paramIndex++; + return condition; + }); + whereConditions.push(`(${searchConditions.join(" OR ")})`); + + // 검색어 파라미터 추가 + fields.forEach(() => { + params.push(`%${searchText}%`); + }); + } + + // 추가 필터 조건 + const additionalFilter = JSON.parse(filterCondition as string); + for (const [key, value] of Object.entries(additionalFilter)) { + whereConditions.push(`${key} = $${paramIndex}`); + params.push(value); + paramIndex++; + } + + // 페이징 + const offset = (parseInt(page as string) - 1) * parseInt(limit as string); + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // 쿼리 실행 + const pool = getPool(); + const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; + const dataQuery = ` + SELECT * FROM ${tableName} ${whereClause} + ORDER BY id DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + params.push(parseInt(limit as string)); + params.push(offset); + + const countResult = await pool.query( + countQuery, + params.slice(0, params.length - 2) + ); + const dataResult = await pool.query(dataQuery, params); + + logger.info("엔티티 검색 성공", { + tableName, + searchText, + companyCode, + rowCount: dataResult.rowCount, + }); + + res.json({ + success: true, + data: dataResult.rows, + pagination: { + total: parseInt(countResult.rows[0].count), + page: parseInt(page as string), + limit: parseInt(limit as string), + }, + }); + } catch (error: any) { + logger.error("엔티티 검색 오류", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts new file mode 100644 index 00000000..e38f2466 --- /dev/null +++ b/backend-node/src/controllers/orderController.ts @@ -0,0 +1,238 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 수주 번호 생성 함수 + * 형식: ORD + YYMMDD + 4자리 시퀀스 + * 예: ORD250114001 + */ +async function generateOrderNumber(companyCode: string): Promise { + const pool = getPool(); + const today = new Date(); + const year = today.getFullYear().toString().slice(2); // 25 + const month = String(today.getMonth() + 1).padStart(2, "0"); // 01 + const day = String(today.getDate()).padStart(2, "0"); // 14 + const dateStr = `${year}${month}${day}`; // 250114 + + // 당일 수주 카운트 조회 + const countQuery = ` + SELECT COUNT(*) as count + FROM order_mng_master + WHERE objid LIKE $1 + AND writer LIKE $2 + `; + + const pattern = `ORD${dateStr}%`; + const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]); + const count = parseInt(result.rows[0]?.count || "0"); + const seq = count + 1; + + return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001 +} + +/** + * 수주 등록 API + * POST /api/orders + */ +export async function createOrder(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + + try { + const { + inputMode, // 입력 방식 + customerCode, // 거래처 코드 + deliveryDate, // 납품일 + items, // 품목 목록 + memo, // 메모 + } = req.body; + + // 멀티테넌시 + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + // 유효성 검사 + if (!customerCode) { + return res.status(400).json({ + success: false, + message: "거래처 코드는 필수입니다", + }); + } + + if (!items || items.length === 0) { + return res.status(400).json({ + success: false, + message: "품목은 최소 1개 이상 필요합니다", + }); + } + + // 수주 번호 생성 + const orderNo = await generateOrderNumber(companyCode); + + // 전체 금액 계산 + const totalAmount = items.reduce( + (sum: number, item: any) => sum + (item.amount || 0), + 0 + ); + + // 수주 마스터 생성 + const masterQuery = ` + INSERT INTO order_mng_master ( + objid, + partner_objid, + final_delivery_date, + reason, + status, + reg_date, + writer + ) VALUES ($1, $2, $3, $4, $5, NOW(), $6) + RETURNING * + `; + + const masterResult = await pool.query(masterQuery, [ + orderNo, + customerCode, + deliveryDate || null, + memo || null, + "진행중", + `${userId}|${companyCode}`, + ]); + + const masterObjid = masterResult.rows[0].objid; + + // 수주 상세 (품목) 생성 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const subObjid = `${orderNo}_${i + 1}`; + + const subQuery = ` + INSERT INTO order_mng_sub ( + objid, + order_mng_master_objid, + part_objid, + partner_objid, + partner_price, + partner_qty, + delivery_date, + status, + regdate, + writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9) + `; + + await pool.query(subQuery, [ + subObjid, + masterObjid, + item.item_code || item.id, // 품목 코드 + customerCode, + item.unit_price || 0, + item.quantity || 0, + item.delivery_date || deliveryDate || null, + "진행중", + `${userId}|${companyCode}`, + ]); + } + + logger.info("수주 등록 성공", { + companyCode, + orderNo, + masterObjid, + itemCount: items.length, + totalAmount, + }); + + res.json({ + success: true, + data: { + orderNo, + masterObjid, + itemCount: items.length, + totalAmount, + }, + message: "수주가 등록되었습니다", + }); + } catch (error: any) { + logger.error("수주 등록 오류", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: error.message || "수주 등록 중 오류가 발생했습니다", + }); + } +} + +/** + * 수주 목록 조회 API + * GET /api/orders + */ +export async function getOrders(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + + try { + const { page = "1", limit = "20", searchText = "" } = req.query; + const companyCode = req.user!.companyCode; + + const offset = (parseInt(page as string) - 1) * parseInt(limit as string); + + // WHERE 조건 + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 (writer 필드에 company_code 포함) + if (companyCode !== "*") { + whereConditions.push(`writer LIKE $${paramIndex}`); + params.push(`%${companyCode}%`); + paramIndex++; + } + + // 검색 + if (searchText) { + whereConditions.push(`objid LIKE $${paramIndex}`); + params.push(`%${searchText}%`); + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // 카운트 쿼리 + const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`; + const countResult = await pool.query(countQuery, params); + const total = parseInt(countResult.rows[0]?.count || "0"); + + // 데이터 쿼리 + const dataQuery = ` + SELECT * FROM order_mng_master + ${whereClause} + ORDER BY reg_date DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + params.push(parseInt(limit as string)); + params.push(offset); + + const dataResult = await pool.query(dataQuery, params); + + res.json({ + success: true, + data: dataResult.rows, + pagination: { + total, + page: parseInt(page as string), + limit: parseInt(limit as string), + }, + }); + } catch (error: any) { + logger.error("수주 목록 조회 오류", { error: error.message }); + res.status(500).json({ + success: false, + message: error.message, + }); + } +} diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index dd589fdd..be3a16a3 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => { const result = await screenManagementService.getScreensByCompany( targetCompanyCode, parseInt(page as string), - parseInt(size as string) + parseInt(size as string), + searchTerm as string // 검색어 전달 ); res.json({ diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 8bb2b0db..c25b4127 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -187,6 +187,16 @@ export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Respon }); } catch (error: any) { logger.error(`카테고리 값 삭제 실패: ${error.message}`); + + // 사용 중인 경우 상세 에러 메시지 반환 (400) + if (error.message.includes("삭제할 수 없습니다")) { + return res.status(400).json({ + success: false, + message: error.message, + }); + } + + // 기타 에러 (500) return res.status(500).json({ success: false, message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다", diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index beade4e6..f552124f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1604,10 +1604,14 @@ export async function toggleLogTable( } /** - * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 + * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) * * @route GET /api/table-management/menu/:menuObjid/category-columns - * @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회 + * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 + * + * 예시: + * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 + * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) */ export async function getCategoryColumnsByMenu( req: AuthenticatedRequest, @@ -1627,40 +1631,10 @@ export async function getCategoryColumnsByMenu( return; } - // 1. 형제 메뉴 조회 - const { getSiblingMenuObjids } = await import("../services/menuService"); - const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); - - logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids }); - - // 2. 형제 메뉴들이 사용하는 테이블 조회 const { getPool } = await import("../database/db"); const pool = getPool(); - - const tablesQuery = ` - SELECT DISTINCT sd.table_name - FROM screen_menu_assignments sma - INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id - WHERE sma.menu_objid = ANY($1) - AND sma.company_code = $2 - AND sd.table_name IS NOT NULL - `; - - const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); - const tableNames = tablesResult.rows.map((row: any) => row.table_name); - - logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); - if (tableNames.length === 0) { - res.json({ - success: true, - data: [], - message: "형제 메뉴에 연결된 테이블이 없습니다.", - }); - return; - } - - // 3. category_column_mapping 테이블 존재 여부 확인 + // 1. category_column_mapping 테이블 존재 여부 확인 const tableExistsResult = await pool.query(` SELECT EXISTS ( SELECT FROM information_schema.tables @@ -1672,33 +1646,42 @@ export async function getCategoryColumnsByMenu( let columnsResult; if (mappingTableExists) { - // 🆕 category_column_mapping을 사용한 필터링 - logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + // 🆕 category_column_mapping을 사용한 계층 구조 기반 조회 + logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode }); // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) const ancestorMenuQuery = ` WITH RECURSIVE menu_hierarchy AS ( -- 현재 메뉴 - SELECT objid, parent_obj_id, menu_type + SELECT objid, parent_obj_id, menu_type, menu_name_kor FROM menu_info WHERE objid = $1 UNION ALL -- 부모 메뉴 재귀 조회 - SELECT m.objid, m.parent_obj_id, m.menu_type + SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor FROM menu_info m INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외 ) - SELECT ARRAY_AGG(objid) as menu_objids + SELECT + ARRAY_AGG(objid) as menu_objids, + ARRAY_AGG(menu_name_kor) as menu_names FROM menu_hierarchy `; const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; + const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || []; + logger.info("✅ 상위 메뉴 계층 조회 완료", { + ancestorMenuObjids, + ancestorMenuNames, + hierarchyDepth: ancestorMenuObjids.length + }); + // 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거) const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1711,7 +1694,8 @@ export async function getCategoryColumnsByMenu( cl.column_label, initcap(replace(ccm.logical_column_name, '_', ' ')) ) AS "columnLabel", - ttc.input_type AS "inputType" + ttc.input_type AS "inputType", + ccm.menu_objid AS "definedAtMenuObjid" FROM category_column_mapping ccm INNER JOIN table_type_columns ttc ON ccm.table_name = ttc.table_name @@ -1721,18 +1705,48 @@ export async function getCategoryColumnsByMenu( AND ttc.column_name = cl.column_name LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name - WHERE ccm.table_name = ANY($1) - AND ccm.company_code = $2 - AND ccm.menu_objid = ANY($3) + WHERE ccm.company_code = $1 + AND ccm.menu_objid = ANY($2) AND ttc.input_type = 'category' ORDER BY ttc.table_name, ccm.logical_column_name `; - columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]); - logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length }); + columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]); + logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", { + rowCount: columnsResult.rows.length, + columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`) + }); } else { - // 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회 - logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode }); + // 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회 + logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + + // 형제 메뉴 조회 + const { getSiblingMenuObjids } = await import("../services/menuService"); + const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); + + // 형제 메뉴들이 사용하는 테이블 조회 + const tablesQuery = ` + SELECT DISTINCT sd.table_name + FROM screen_menu_assignments sma + INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id + WHERE sma.menu_objid = ANY($1) + AND sma.company_code = $2 + AND sd.table_name IS NOT NULL + `; + + const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + const tableNames = tablesResult.rows.map((row: any) => row.table_name); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); + + if (tableNames.length === 0) { + res.json({ + success: true, + data: [], + message: "형제 메뉴에 연결된 테이블이 없습니다.", + }); + return; + } const columnsQuery = ` SELECT diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 5193977a..f87aa5d6 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -14,8 +14,17 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } = - req.query; + const { + leftTable, + rightTable, + leftColumn, + rightColumn, + leftValue, + dataFilter, + enableEntityJoin, + displayColumns, + deduplication, + } = req.query; // 입력값 검증 if (!leftTable || !rightTable || !leftColumn || !rightColumn) { @@ -37,6 +46,11 @@ router.get( } } + // 🆕 enableEntityJoin 파싱 + const enableEntityJoinFlag = + enableEntityJoin === "true" || + (typeof enableEntityJoin === "boolean" && enableEntityJoin); + // SQL 인젝션 방지를 위한 검증 const tables = [leftTable as string, rightTable as string]; const columns = [leftColumn as string, rightColumn as string]; @@ -64,6 +78,35 @@ router.get( // 회사 코드 추출 (멀티테넌시 필터링) const userCompany = req.user?.companyCode; + // displayColumns 파싱 (item_info.item_name 등) + let parsedDisplayColumns: + | Array<{ name: string; label?: string }> + | undefined; + if (displayColumns) { + try { + parsedDisplayColumns = JSON.parse(displayColumns as string); + } catch (e) { + console.error("displayColumns 파싱 실패:", e); + } + } + + // 🆕 deduplication 파싱 + let parsedDeduplication: + | { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + | undefined; + if (deduplication) { + try { + parsedDeduplication = JSON.parse(deduplication as string); + } catch (e) { + console.error("deduplication 파싱 실패:", e); + } + } + console.log(`🔗 조인 데이터 조회:`, { leftTable, rightTable, @@ -71,10 +114,13 @@ router.get( rightColumn, leftValue, userCompany, - dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그 + dataFilter: parsedDataFilter, + enableEntityJoin: enableEntityJoinFlag, + displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그 + deduplication: parsedDeduplication, // 🆕 중복 제거 로그 }); - // 조인 데이터 조회 (회사 코드 + 데이터 필터 전달) + // 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달) const result = await dataService.getJoinedData( leftTable as string, rightTable as string, @@ -82,7 +128,10 @@ router.get( rightColumn as string, leftValue as string, userCompany, - parsedDataFilter // 🆕 데이터 필터 전달 + parsedDataFilter, + enableEntityJoinFlag, + parsedDisplayColumns, // 🆕 표시 컬럼 전달 + parsedDeduplication // 🆕 중복 제거 설정 전달 ); if (!result.success) { @@ -305,10 +354,38 @@ router.get( }); } - console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`); + const { enableEntityJoin, groupByColumns } = req.query; + const enableEntityJoinFlag = + enableEntityJoin === "true" || + (typeof enableEntityJoin === "boolean" && enableEntityJoin); - // 레코드 상세 조회 - const result = await dataService.getRecordDetail(tableName, id); + // groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분) + let groupByColumnsArray: string[] = []; + if (groupByColumns) { + try { + if (typeof groupByColumns === "string") { + // JSON 형식이면 파싱, 아니면 쉼표로 분리 + groupByColumnsArray = groupByColumns.startsWith("[") + ? JSON.parse(groupByColumns) + : groupByColumns.split(",").map((c) => c.trim()); + } + } catch (error) { + console.warn("groupByColumns 파싱 실패:", error); + } + } + + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { + enableEntityJoin: enableEntityJoinFlag, + groupByColumns: groupByColumnsArray, + }); + + // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) + const result = await dataService.getRecordDetail( + tableName, + id, + enableEntityJoinFlag, + groupByColumnsArray + ); if (!result.success) { return res.status(400).json(result); @@ -338,6 +415,87 @@ router.get( } ); +/** + * 그룹화된 데이터 UPSERT API + * POST /api/data/upsert-grouped + * + * 요청 본문: + * { + * tableName: string, + * parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }, + * records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ] + * } + */ +router.post( + "/upsert-grouped", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, parentKeys, records } = req.body; + + // 입력값 검증 + if (!tableName || !parentKeys || !records || !Array.isArray(records)) { + return res.status(400).json({ + success: false, + message: + "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", + error: "MISSING_PARAMETERS", + }); + } + + // 테이블명 검증 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, { + parentKeys, + recordCount: records.length, + userCompany: req.user?.companyCode, + userId: req.user?.userId, + }); + + // UPSERT 수행 + const result = await dataService.upsertGroupedRecords( + tableName, + parentKeys, + records, + req.user?.companyCode, + req.user?.userId + ); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { + inserted: result.data?.inserted || 0, + updated: result.data?.updated || 0, + deleted: result.data?.deleted || 0, + }); + + return res.json({ + success: true, + message: "데이터가 저장되었습니다.", + inserted: result.data?.inserted || 0, + updated: result.data?.updated || 0, + deleted: result.data?.deleted || 0, + }); + } catch (error) { + console.error("그룹화된 데이터 UPSERT 오류:", error); + return res.status(500).json({ + success: false, + message: "데이터 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + /** * 레코드 생성 API * POST /api/data/{tableName} @@ -371,16 +529,22 @@ router.post( // company_code와 company_name 자동 추가 (멀티테넌시) const enrichedData = { ...data }; - + // 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가 - const hasCompanyCode = await dataService.checkColumnExists(tableName, "company_code"); + const hasCompanyCode = await dataService.checkColumnExists( + tableName, + "company_code" + ); if (hasCompanyCode && req.user?.companyCode) { enrichedData.company_code = req.user.companyCode; console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`); } - + // 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가 - const hasCompanyName = await dataService.checkColumnExists(tableName, "company_name"); + const hasCompanyName = await dataService.checkColumnExists( + tableName, + "company_name" + ); if (hasCompanyName && req.user?.companyName) { enrichedData.company_name = req.user.companyName; console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`); @@ -523,6 +687,49 @@ router.post( } ); +/** + * 그룹 삭제 API + * POST /api/data/:tableName/delete-group + */ +router.post( + "/:tableName/delete-group", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + const filterConditions = req.body; + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + }); + } + + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); + + const result = await dataService.deleteGroupRecords( + tableName, + filterConditions + ); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 그룹 삭제: ${result.data?.deleted}개`); + return res.json(result); + } catch (error: any) { + console.error("그룹 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "그룹 삭제 실패", + error: error.message, + }); + } + } +); + router.delete( "/:tableName/:id", authenticateToken, diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts new file mode 100644 index 00000000..904096f7 --- /dev/null +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -0,0 +1,75 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; + +// 레이아웃 관리 +import { + getLayouts, + getLayoutById, + createLayout, + updateLayout, + deleteLayout, +} from "../controllers/digitalTwinLayoutController"; + +// 외부 DB 데이터 조회 +import { + getHierarchyData, + getChildrenData, + getWarehouses, + getAreas, + getLocations, + getMaterials, + getMaterialCounts, +} from "../controllers/digitalTwinDataController"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// ========== 레이아웃 관리 API ========== +router.get("/layouts", getLayouts); // 레이아웃 목록 +router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 +router.post("/layouts", createLayout); // 레이아웃 생성 +router.put("/layouts/:id", updateLayout); // 레이아웃 수정 +router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 + +// ========== 외부 DB 데이터 조회 API ========== + +// 동적 계층 구조 API +router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회 +router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회 + +// 테이블 메타데이터 API +router.get("/data/tables/:connectionId", async (req, res) => { + // 테이블 목록 조회 + try { + const { ExternalDbConnectionService } = await import("../services/externalDbConnectionService"); + const result = await ExternalDbConnectionService.getTablesFromConnection(Number(req.params.connectionId)); + return res.json(result); + } catch (error: any) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => { + // 테이블 미리보기 (10개 레코드) + try { + const { connectionId, tableName } = req.params; + const { getExternalDbConnector } = await import("../controllers/digitalTwinDataController"); + const connector = await getExternalDbConnector(Number(connectionId)); + const result = await connector.executeQuery(`SELECT * FROM ${tableName} LIMIT 10`); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 레거시 API (호환성 유지) +router.get("/data/warehouses", getWarehouses); // 창고 목록 +router.get("/data/areas", getAreas); // Area 목록 +router.get("/data/locations", getLocations); // Location 목록 +router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location) +router.post("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) - POST로 변경 + +export default router; + diff --git a/backend-node/src/routes/entitySearchRoutes.ts b/backend-node/src/routes/entitySearchRoutes.ts new file mode 100644 index 00000000..7677279a --- /dev/null +++ b/backend-node/src/routes/entitySearchRoutes.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { searchEntity } from "../controllers/entitySearchController"; + +const router = Router(); + +/** + * 엔티티 검색 API + * GET /api/entity-search/:tableName + */ +router.get("/:tableName", authenticateToken, searchEntity); + +export default router; + diff --git a/backend-node/src/routes/orderRoutes.ts b/backend-node/src/routes/orderRoutes.ts new file mode 100644 index 00000000..a59b5f43 --- /dev/null +++ b/backend-node/src/routes/orderRoutes.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { createOrder, getOrders } from "../controllers/orderController"; + +const router = Router(); + +/** + * 수주 등록 + * POST /api/orders + */ +router.post("/", authenticateToken, createOrder); + +/** + * 수주 목록 조회 + * GET /api/orders + */ +router.get("/", authenticateToken, getOrders); + +export default router; + diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index bd7f74e1..fd85248d 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -14,7 +14,9 @@ * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 */ import { query, queryOne } from "../database/db"; +import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 +import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성 interface GetTableDataParams { tableName: string; @@ -53,6 +55,103 @@ const BLOCKED_TABLES = [ const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; class DataService { + /** + * 중복 데이터 제거 (메모리 내 처리) + */ + private deduplicateData( + data: any[], + config: { + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + ): any[] { + if (!data || data.length === 0) return data; + + // 그룹별로 데이터 분류 + const groups: Record = {}; + + for (const row of data) { + const groupKey = row[config.groupByColumn]; + if (groupKey === undefined || groupKey === null) continue; + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(row); + } + + // 각 그룹에서 하나의 행만 선택 + const result: any[] = []; + + for (const [groupKey, rows] of Object.entries(groups)) { + if (rows.length === 0) continue; + + let selectedRow: any; + + switch (config.keepStrategy) { + case "latest": + // 정렬 컬럼 기준 최신 (가장 큰 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal > bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "earliest": + // 정렬 컬럼 기준 최초 (가장 작은 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal < bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "base_price": + // base_price = true인 행 찾기 + selectedRow = rows.find(row => row.base_price === true) || rows[0]; + break; + + case "current_date": + // start_date <= CURRENT_DATE <= end_date 조건에 맞는 행 + const today = new Date(); + today.setHours(0, 0, 0, 0); // 시간 제거 + + selectedRow = rows.find(row => { + const startDate = row.start_date ? new Date(row.start_date) : null; + const endDate = row.end_date ? new Date(row.end_date) : null; + + if (startDate) startDate.setHours(0, 0, 0, 0); + if (endDate) endDate.setHours(0, 0, 0, 0); + + const afterStart = !startDate || today >= startDate; + const beforeEnd = !endDate || today <= endDate; + + return afterStart && beforeEnd; + }) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행 + break; + + default: + selectedRow = rows[0]; + } + + result.push(selectedRow); + } + + return result; + } + /** * 테이블 접근 검증 (공통 메서드) */ @@ -374,11 +473,13 @@ class DataService { } /** - * 레코드 상세 조회 + * 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회) */ async getRecordDetail( tableName: string, - id: string | number + id: string | number, + enableEntityJoin: boolean = false, + groupByColumns: string[] = [] ): Promise> { try { // 테이블 접근 검증 @@ -401,6 +502,108 @@ class DataService { pkColumn = pkResult[0].attname; } + // 🆕 Entity Join이 활성화된 경우 + if (enableEntityJoin) { + const { EntityJoinService } = await import("./entityJoinService"); + const entityJoinService = new EntityJoinService(); + + // Entity Join 구성 감지 + const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + + if (joinConfigs.length > 0) { + console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`); + + // Entity Join 쿼리 생성 (개별 파라미터로 전달) + const { query: joinQuery } = entityJoinService.buildJoinQuery( + tableName, + joinConfigs, + ["*"], + `main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결 + ); + + const result = await pool.query(joinQuery, [id]); + + if (result.rows.length === 0) { + return { + success: false, + message: "레코드를 찾을 수 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + + // 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환 + const normalizeDates = (rows: any[]) => { + return rows.map(row => { + const normalized: any = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) { + // Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시) + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + normalized[key] = `${year}-${month}-${day}`; + } else { + normalized[key] = value; + } + } + return normalized; + }); + }; + + const normalizedRows = normalizeDates(result.rows); + console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]); + + // 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회 + if (groupByColumns.length > 0) { + const baseRecord = result.rows[0]; + + // 그룹핑 컬럼들의 값 추출 + const groupConditions: string[] = []; + const groupValues: any[] = []; + let paramIndex = 1; + + for (const col of groupByColumns) { + const value = normalizedRows[0][col]; + if (value !== undefined && value !== null) { + groupConditions.push(`main."${col}" = $${paramIndex}`); + groupValues.push(value); + paramIndex++; + } + } + + if (groupConditions.length > 0) { + const groupWhereClause = groupConditions.join(" AND "); + + console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues); + + // 그룹핑 기준으로 모든 레코드 조회 + const { query: groupQuery } = entityJoinService.buildJoinQuery( + tableName, + joinConfigs, + ["*"], + groupWhereClause + ); + + const groupResult = await pool.query(groupQuery, groupValues); + + const normalizedGroupRows = normalizeDates(groupResult.rows); + console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`); + + return { + success: true, + data: normalizedGroupRows, // 🔧 배열로 반환! + }; + } + } + + return { + success: true, + data: normalizedRows[0], // 그룹핑 없으면 단일 레코드 + }; + } + } + + // 기본 쿼리 (Entity Join 없음) const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`; const result = await query(queryText, [id]); @@ -427,7 +630,7 @@ class DataService { } /** - * 조인된 데이터 조회 + * 조인된 데이터 조회 (🆕 Entity 조인 지원) */ async getJoinedData( leftTable: string, @@ -436,7 +639,15 @@ class DataService { rightColumn: string, leftValue?: string | number, userCompany?: string, - dataFilter?: any // 🆕 데이터 필터 + dataFilter?: any, // 🆕 데이터 필터 + enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화 + displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등) + deduplication?: { // 🆕 중복 제거 설정 + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } ): Promise> { try { // 왼쪽 테이블 접근 검증 @@ -451,6 +662,162 @@ class DataService { return rightValidation.error!; } + // 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용 + if (enableEntityJoin) { + try { + const { entityJoinService } = await import("./entityJoinService"); + const joinConfigs = await entityJoinService.detectEntityJoins(rightTable); + + // 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등) + if (displayColumns && Array.isArray(displayColumns)) { + // 테이블별로 요청된 컬럼들을 그룹핑 + const tableColumns: Record> = {}; + + for (const col of displayColumns) { + if (col.name && col.name.includes('.')) { + const [refTable, refColumn] = col.name.split('.'); + if (!tableColumns[refTable]) { + tableColumns[refTable] = new Set(); + } + tableColumns[refTable].add(refColumn); + } + } + + // 각 테이블별로 처리 + for (const [refTable, refColumns] of Object.entries(tableColumns)) { + // 이미 조인 설정에 있는지 확인 + const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable); + + if (existingJoins.length > 0) { + // 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리 + for (const refColumn of refColumns) { + // 이미 해당 컬럼을 표시하는 조인이 있는지 확인 + const existingJoin = existingJoins.find( + jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn + ); + + if (!existingJoin) { + // 없으면 새 조인 설정 복제하여 추가 + const baseJoin = existingJoins[0]; + const newJoin = { + ...baseJoin, + displayColumns: [refColumn], + aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size) + // ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴 + referenceTable: refTable, + referenceColumn: baseJoin.referenceColumn, // item_number 등 + }; + joinConfigs.push(newJoin); + console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`); + } + } + } else { + console.warn(`⚠️ 조인 설정 없음: ${refTable}`); + } + } + } + + if (joinConfigs.length > 0) { + console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`); + + // WHERE 조건 생성 + const whereConditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + // 좌측 테이블 조인 조건 (leftValue로 필터링) + // rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002') + if (leftValue !== undefined && leftValue !== null) { + whereConditions.push(`main."${rightColumn}" = $${paramIndex}`); + values.push(leftValue); + paramIndex++; + } + + // 회사별 필터링 + if (userCompany && userCompany !== "*") { + const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); + if (hasCompanyCode) { + whereConditions.push(`main.company_code = $${paramIndex}`); + values.push(userCompany); + paramIndex++; + } + } + + // 데이터 필터 적용 (buildDataFilterWhereClause 사용) + if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { + const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil"); + const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex); + if (filterResult.whereClause) { + whereConditions.push(filterResult.whereClause); + values.push(...filterResult.params); + paramIndex += filterResult.params.length; + console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); + console.log(`📊 필터 파라미터:`, filterResult.params); + } + } + + const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : ""; + + // Entity 조인 쿼리 빌드 + // buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달 + const selectColumns = ["*"]; + + const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery( + rightTable, + joinConfigs, + selectColumns, + whereClause, + "", + undefined, + undefined + ); + + console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery); + console.log(`🔍 파라미터:`, values); + + const result = await pool.query(finalQuery, values); + + // 🔧 날짜 타입 타임존 문제 해결 + const normalizeDates = (rows: any[]) => { + return rows.map(row => { + const normalized: any = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + normalized[key] = `${year}-${month}-${day}`; + } else { + normalized[key] = value; + } + } + return normalized; + }); + }; + + const normalizedRows = normalizeDates(result.rows); + console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`); + + // 🆕 중복 제거 처리 + let finalData = normalizedRows; + if (deduplication?.enabled && deduplication.groupByColumn) { + console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + finalData = this.deduplicateData(normalizedRows, deduplication); + console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`); + } + + return { + success: true, + data: finalData, + }; + } + } catch (error) { + console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error); + // Entity 조인 실패 시 기본 조인으로 폴백 + } + } + + // 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시) let queryText = ` SELECT DISTINCT r.* FROM "${rightTable}" r @@ -501,9 +868,17 @@ class DataService { const result = await query(queryText, values); + // 🆕 중복 제거 처리 + let finalData = result; + if (deduplication?.enabled && deduplication.groupByColumn) { + console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + finalData = this.deduplicateData(result, deduplication); + console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`); + } + return { success: true, - data: result, + data: finalData, }; } catch (error) { console.error( @@ -728,6 +1103,290 @@ class DataService { }; } } + + /** + * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + */ + async deleteGroupRecords( + tableName: string, + filterConditions: Record + ): Promise> { + try { + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; + } + + const whereConditions: string[] = []; + const whereValues: any[] = []; + let paramIndex = 1; + + for (const [key, value] of Object.entries(filterConditions)) { + whereConditions.push(`"${key}" = $${paramIndex}`); + whereValues.push(value); + paramIndex++; + } + + if (whereConditions.length === 0) { + return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" }; + } + + const whereClause = whereConditions.join(" AND "); + const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; + + console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); + + const result = await pool.query(deleteQuery, whereValues); + + console.log(`✅ 그룹 삭제 성공: ${result.rowCount}개`); + + return { success: true, data: { deleted: result.rowCount || 0 } }; + } catch (error) { + console.error("그룹 삭제 오류:", error); + return { + success: false, + message: "그룹 삭제 실패", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 그룹화된 데이터 UPSERT + * - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아 + * - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행 + * - 각 레코드의 모든 필드 조합을 고유 키로 사용 + */ + async upsertGroupedRecords( + tableName: string, + parentKeys: Record, + records: Array>, + userCompany?: string, + userId?: string + ): Promise> { + try { + // 테이블 접근 권한 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; + } + + // Primary Key 감지 + const pkColumns = await this.getPrimaryKeyColumns(tableName); + if (!pkColumns || pkColumns.length === 0) { + return { + success: false, + message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`, + error: "PRIMARY_KEY_NOT_FOUND", + }; + } + const pkColumn = pkColumns[0]; // 첫 번째 PK 사용 + + console.log(`🔍 UPSERT 시작: ${tableName}`, { + parentKeys, + newRecordsCount: records.length, + primaryKey: pkColumn, + }); + + // 1. 기존 DB 레코드 조회 (parentKeys 기준) + const whereConditions: string[] = []; + const whereValues: any[] = []; + let paramIndex = 1; + + for (const [key, value] of Object.entries(parentKeys)) { + whereConditions.push(`"${key}" = $${paramIndex}`); + whereValues.push(value); + paramIndex++; + } + + const whereClause = whereConditions.join(" AND "); + const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`; + + console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues }); + + const existingRecords = await pool.query(selectQuery, whereValues); + + console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); + + // 2. 새 레코드와 기존 레코드 비교 + let inserted = 0; + let updated = 0; + let deleted = 0; + + // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 + const normalizeDateValue = (value: any): any => { + if (value == null) return value; + + // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value.split('T')[0]; // YYYY-MM-DD 만 추출 + } + + return value; + }; + + // 새 레코드 처리 (INSERT or UPDATE) + for (const newRecord of records) { + console.log(`🔍 처리할 새 레코드:`, newRecord); + + // 날짜 필드 정규화 + const normalizedRecord: Record = {}; + for (const [key, value] of Object.entries(newRecord)) { + normalizedRecord[key] = normalizeDateValue(value); + } + + console.log(`🔄 정규화된 레코드:`, normalizedRecord); + + // 전체 레코드 데이터 (parentKeys + normalizedRecord) + const fullRecord = { ...parentKeys, ...normalizedRecord }; + + // 고유 키: parentKeys 제외한 나머지 필드들 + const uniqueFields = Object.keys(normalizedRecord); + + console.log(`🔑 고유 필드들:`, uniqueFields); + + // 기존 레코드에서 일치하는 것 찾기 + const existingRecord = existingRecords.rows.find((existing) => { + return uniqueFields.every((field) => { + const existingValue = existing[field]; + const newValue = normalizedRecord[field]; + + // null/undefined 처리 + if (existingValue == null && newValue == null) return true; + if (existingValue == null || newValue == null) return false; + + // Date 타입 처리 + if (existingValue instanceof Date && typeof newValue === 'string') { + return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; + } + + // 문자열 비교 + return String(existingValue) === String(newValue); + }); + }); + + if (existingRecord) { + // UPDATE: 기존 레코드가 있으면 업데이트 + const updateFields: string[] = []; + const updateValues: any[] = []; + let updateParamIndex = 1; + + for (const [key, value] of Object.entries(fullRecord)) { + if (key !== pkColumn) { // Primary Key는 업데이트하지 않음 + updateFields.push(`"${key}" = $${updateParamIndex}`); + updateValues.push(value); + updateParamIndex++; + } + } + + updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateFields.join(", ")}, updated_date = NOW() + WHERE "${pkColumn}" = $${updateParamIndex} + `; + + await pool.query(updateQuery, updateValues); + updated++; + + console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); + } else { + // INSERT: 기존 레코드가 없으면 삽입 + + // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) + const recordWithMeta: Record = { + ...fullRecord, + id: uuidv4(), // 새 ID 생성 + created_date: "NOW()", + updated_date: "NOW()", + }; + + // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) + if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { + recordWithMeta.company_code = userCompany; + } + + // writer가 없으면 userId 사용 + if (!recordWithMeta.writer && userId) { + recordWithMeta.writer = userId; + } + + const insertFields = Object.keys(recordWithMeta).filter(key => + recordWithMeta[key] !== "NOW()" + ); + const insertPlaceholders: string[] = []; + const insertValues: any[] = []; + let insertParamIndex = 1; + + for (const field of Object.keys(recordWithMeta)) { + if (recordWithMeta[field] === "NOW()") { + insertPlaceholders.push("NOW()"); + } else { + insertPlaceholders.push(`$${insertParamIndex}`); + insertValues.push(recordWithMeta[field]); + insertParamIndex++; + } + } + + const insertQuery = ` + INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")}) + VALUES (${insertPlaceholders.join(", ")}) + `; + + console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues }); + + await pool.query(insertQuery, insertValues); + inserted++; + + console.log(`➕ INSERT: 새 레코드`); + } + } + + // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) + for (const existingRecord of existingRecords.rows) { + const uniqueFields = Object.keys(records[0] || {}); + + const stillExists = records.some((newRecord) => { + return uniqueFields.every((field) => { + const existingValue = existingRecord[field]; + const newValue = newRecord[field]; + + if (existingValue == null && newValue == null) return true; + if (existingValue == null || newValue == null) return false; + + if (existingValue instanceof Date && typeof newValue === 'string') { + return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; + } + + return String(existingValue) === String(newValue); + }); + }); + + if (!stillExists) { + // DELETE: 새 레코드에 없으면 삭제 + const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await pool.query(deleteQuery, [existingRecord[pkColumn]]); + deleted++; + + console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + } + } + + console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); + + return { + success: true, + data: { inserted, updated, deleted }, + }; + } catch (error) { + console.error(`UPSERT 오류 (${tableName}):`, error); + return { + success: false, + message: "데이터 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } } export const dataService = new DataService(); diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index fef50914..a8f6c482 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -81,18 +81,18 @@ export class EntityJoinService { let referenceColumn = column.reference_column; let displayColumn = column.display_column; - if (column.input_type === 'category') { - // 카테고리 타입: reference 정보가 비어있어도 자동 설정 - referenceTable = referenceTable || 'table_column_category_values'; - referenceColumn = referenceColumn || 'value_code'; - displayColumn = displayColumn || 'value_label'; - - logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { - referenceTable, - referenceColumn, - displayColumn, - }); - } + if (column.input_type === "category") { + // 카테고리 타입: reference 정보가 비어있어도 자동 설정 + referenceTable = referenceTable || "table_column_category_values"; + referenceColumn = referenceColumn || "value_code"; + displayColumn = displayColumn || "value_label"; + + logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { + referenceTable, + referenceColumn, + displayColumn, + }); + } logger.info(`🔍 Entity 컬럼 상세 정보:`, { column_name: column.column_name, @@ -134,23 +134,32 @@ export class EntityJoinService { `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { - // display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정 - let defaultDisplayColumn = referenceColumn; - if (referenceTable === "dept_info") { - defaultDisplayColumn = "dept_name"; - } else if (referenceTable === "company_info") { - defaultDisplayColumn = "company_name"; - } else if (referenceTable === "user_info") { - defaultDisplayColumn = "user_name"; - } else if (referenceTable === "category_values") { - defaultDisplayColumn = "category_name"; - } + // display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기 + logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`); - displayColumns = [defaultDisplayColumn]; - logger.info( - `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})` + // 참조 테이블의 모든 컬럼 이름 가져오기 + const tableColumnsResult = await query<{ column_name: string }>( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position`, + [referenceTable] ); - logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns); + + if (tableColumnsResult.length > 0) { + displayColumns = tableColumnsResult.map((col) => col.column_name); + logger.info( + `✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`, + displayColumns.join(", ") + ); + } else { + // 테이블 컬럼을 못 찾으면 기본값 사용 + displayColumns = [referenceColumn]; + logger.warn( + `⚠️ ${referenceTable}의 컬럼 조회 실패, 기본값 사용: ${referenceColumn}` + ); + } } // 별칭 컬럼명 생성 (writer -> writer_name) @@ -200,6 +209,25 @@ export class EntityJoinService { } } + /** + * 날짜 컬럼을 YYYY-MM-DD 형식으로 변환하는 SQL 표현식 + */ + private formatDateColumn( + tableAlias: string, + columnName: string, + dataType?: string + ): string { + // date, timestamp 타입이면 TO_CHAR로 변환 + if ( + dataType && + (dataType.includes("date") || dataType.includes("timestamp")) + ) { + return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`; + } + // 기본은 TEXT 캐스팅 + return `${tableAlias}.${columnName}::TEXT`; + } + /** * Entity 조인이 포함된 SQL 쿼리 생성 */ @@ -210,13 +238,30 @@ export class EntityJoinService { whereClause: string = "", orderBy: string = "", limit?: number, - offset?: number + offset?: number, + columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 ): { query: string; aliasMap: Map } { try { - // 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지) - const baseColumns = selectColumns - .map((col) => `main.${col}::TEXT AS ${col}`) - .join(", "); + // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) + // 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해 + // jsonb_build_object를 사용하여 명시적으로 변환 + let baseColumns: string; + if (selectColumns.length === 1 && selectColumns[0] === "*") { + // main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환 + // PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지 + baseColumns = `main.*`; + logger.info( + `⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요` + ); + } else { + baseColumns = selectColumns + .map((col) => { + const dataType = columnTypes?.get(col); + const formattedCol = this.formatDateColumn("main", col, dataType); + return `${formattedCol} AS ${col}`; + }) + .join(", "); + } // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) // 별칭 매핑 생성 (JOIN 절과 동일한 로직) @@ -255,7 +300,9 @@ export class EntityJoinService { // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); - logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`); + logger.info( + `🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}` + ); }); const joinColumns = joinConfigs @@ -266,78 +313,82 @@ export class EntityJoinService { config.displayColumn, ]; const separator = config.separator || " - "; - + // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; if (displayColumns.length === 0 || !displayColumns[0]) { // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 // 조인 테이블의 referenceColumn을 기본값으로 사용 - resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`); + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` + ); } else if (displayColumns.length === 1) { // 단일 컬럼인 경우 const col = displayColumns[0]; - const isJoinTableColumn = [ - "dept_name", - "dept_code", - "master_user_id", - "location_name", - "parent_dept_code", - "master_sabun", - "location", - "data_type", - "company_name", - "sales_yn", - "status", - "value_label", // table_column_category_values - "user_name", // user_info - ].includes(col); + + // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 + // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; if (isJoinTableColumn) { - resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`); - + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` + ); + // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) // sourceColumn_label 형식으로 추가 - resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`); + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label` + ); + + // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) + // 예: customer_code, item_number 등 + // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` + ); } else { - resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`); + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` + ); } } else { - // 여러 컬럼인 경우 CONCAT으로 연결 - // 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리 - const concatParts = displayColumns - .map((col) => { - // 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용) - // 현재는 dept_info 테이블의 컬럼들을 확인 - const isJoinTableColumn = [ - "dept_name", - "dept_code", - "master_user_id", - "location_name", - "parent_dept_code", - "master_sabun", - "location", - "data_type", - "company_name", - "sales_yn", - "status", - "value_label", // table_column_category_values - "user_name", // user_info - ].includes(col); + // 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음) + // 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price) + displayColumns.forEach((col) => { + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; - if (isJoinTableColumn) { - // 조인 테이블 컬럼은 조인 별칭 사용 - return `COALESCE(${alias}.${col}::TEXT, '')`; - } else { - // 기본 테이블 컬럼은 main 별칭 사용 - return `COALESCE(main.${col}::TEXT, '')`; - } - }) - .join(` || '${separator}' || `); + const individualAlias = `${config.sourceColumn}_${col}`; - resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); + if (isJoinTableColumn) { + // 조인 테이블 컬럼은 조인 별칭 사용 + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + ); + } else { + // 기본 테이블 컬럼은 main 별칭 사용 + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + ); + } + }); + + // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; + if ( + isJoinTableColumn && + !displayColumns.includes(config.referenceColumn) + ) { + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` + ); + } } - + // 모든 resultColumns를 반환 return resultColumns.join(", "); }) @@ -356,13 +407,13 @@ export class EntityJoinService { .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); @@ -424,7 +475,7 @@ export class EntityJoinService { } // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { logger.info( `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` ); @@ -578,13 +629,13 @@ export class EntityJoinService { .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index e7b6e806..6c3a3430 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -98,7 +98,8 @@ export class ScreenManagementService { async getScreensByCompany( companyCode: string, page: number = 1, - size: number = 20 + size: number = 20, + searchTerm?: string // 검색어 추가 ): Promise> { const offset = (page - 1) * size; @@ -111,6 +112,16 @@ export class ScreenManagementService { params.push(companyCode); } + // 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색) + if (searchTerm && searchTerm.trim() !== "") { + whereConditions.push(`( + screen_name ILIKE $${params.length + 1} OR + screen_code ILIKE $${params.length + 1} OR + table_name ILIKE $${params.length + 1} + )`); + params.push(`%${searchTerm.trim()}%`); + } + const whereSQL = whereConditions.join(" AND "); // 페이징 쿼리 (Raw Query) @@ -1068,43 +1079,131 @@ export class ScreenManagementService { [tableName] ); - // column_labels 테이블에서 입력타입 정보 조회 (있는 경우) - const webTypeInfo = await query<{ + // 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음) + // 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리 + console.log(`🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`); + + const typeInfo = await query<{ column_name: string; input_type: string | null; - column_label: string | null; detail_settings: any; }>( - `SELECT column_name, input_type, column_label, detail_settings + `SELECT column_name, input_type, detail_settings + FROM table_type_columns + WHERE table_name = $1 + AND company_code = $2 + ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지) + [tableName, companyCode] + ); + + console.log(`📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}개`); + const currencyCodeType = typeInfo.find(t => t.column_name === 'currency_code'); + if (currencyCodeType) { + console.log(`💰 [getTableColumns] currency_code 발견:`, currencyCodeType); + } else { + console.log(`⚠️ [getTableColumns] currency_code 없음`); + } + + // column_labels 테이블에서 라벨 정보 조회 (우선순위 2) + const labelInfo = await query<{ + column_name: string; + column_label: string | null; + }>( + `SELECT column_name, column_label FROM column_labels WHERE table_name = $1`, [tableName] ); - // 컬럼 정보 매핑 - return columns.map((column: any) => { - const webTypeData = webTypeInfo.find( - (wt) => wt.column_name === column.column_name - ); + // 🆕 category_column_mapping에서 코드 카테고리 정보 조회 + const categoryInfo = await query<{ + physical_column_name: string; + logical_column_name: string; + }>( + `SELECT physical_column_name, logical_column_name + FROM category_column_mapping + WHERE table_name = $1 + AND company_code = $2`, + [tableName, companyCode] + ); - return { + // 컬럼 정보 매핑 + const columnMap = new Map(); + + // 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성 + columns.forEach((column: any) => { + columnMap.set(column.column_name, { tableName: tableName, columnName: column.column_name, - columnLabel: - webTypeData?.column_label || - this.getColumnLabel(column.column_name), dataType: column.data_type, - webType: - (webTypeData?.input_type as WebType) || - this.inferWebType(column.data_type), isNullable: column.is_nullable, columnDefault: column.column_default || undefined, characterMaximumLength: column.character_maximum_length || undefined, numericPrecision: column.numeric_precision || undefined, numericScale: column.numeric_scale || undefined, - detailSettings: webTypeData?.detail_settings || undefined, - }; + }); }); + + console.log(`🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}개`); + + // table_type_columns에서 input_type 추가 (중복 시 최신 것만) + const addedTypes = new Set(); + typeInfo.forEach((type) => { + const colName = type.column_name; + if (!addedTypes.has(colName) && columnMap.has(colName)) { + const col = columnMap.get(colName); + col.inputType = type.input_type; + col.webType = type.input_type; // webType도 동일하게 설정 + col.detailSettings = type.detail_settings; + addedTypes.add(colName); + + if (colName === 'currency_code') { + console.log(`✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`); + } + } + }); + + console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}개`); + + // column_labels에서 라벨 추가 + labelInfo.forEach((label) => { + const col = columnMap.get(label.column_name); + if (col) { + col.columnLabel = label.column_label || this.getColumnLabel(label.column_name); + } + }); + + // category_column_mapping에서 코드 카테고리 추가 + categoryInfo.forEach((cat) => { + const col = columnMap.get(cat.physical_column_name); + if (col) { + col.codeCategory = cat.logical_column_name; + } + }); + + // 최종 결과 생성 + const result = Array.from(columnMap.values()).map((col) => ({ + ...col, + // 기본값 설정 + columnLabel: col.columnLabel || this.getColumnLabel(col.columnName), + inputType: col.inputType || this.inferWebType(col.dataType), + webType: col.webType || this.inferWebType(col.dataType), + detailSettings: col.detailSettings || undefined, + codeCategory: col.codeCategory || undefined, + })); + + // 디버깅: currency_code의 최종 inputType 확인 + const currencyCodeResult = result.find(r => r.columnName === 'currency_code'); + if (currencyCodeResult) { + console.log(`🎯 [getTableColumns] 최종 currency_code:`, { + inputType: currencyCodeResult.inputType, + webType: currencyCodeResult.webType, + dataType: currencyCodeResult.dataType + }); + } + + console.log(`✅ [getTableColumns] 반환: ${result.length}개 컬럼`); + return result; } catch (error) { console.error("테이블 컬럼 조회 실패:", error); throw new Error("테이블 컬럼 정보를 조회할 수 없습니다."); @@ -2013,55 +2112,109 @@ export class ScreenManagementService { } /** - * 화면에 연결된 모달 화면들을 자동 감지 - * 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출 + * 화면에 연결된 모달/화면들을 재귀적으로 자동 감지 + * - 버튼 컴포넌트: popup/modal/edit/openModalWithData 액션의 targetScreenId + * - 조건부 컨테이너: sections[].screenId (조건별 화면 할당) + * - 중첩된 화면들도 모두 감지 (재귀) */ async detectLinkedModalScreens( screenId: number ): Promise<{ screenId: number; screenName: string; screenCode: string }[]> { - // 화면의 모든 레이아웃 조회 - const layouts = await query( - `SELECT layout_id, properties - FROM screen_layouts - WHERE screen_id = $1 - AND component_type = 'component' - AND properties IS NOT NULL`, - [screenId] - ); + console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`); + + const allLinkedScreenIds = new Set(); + const visited = new Set(); // 무한 루프 방지 + const queue: number[] = [screenId]; // BFS 큐 - const linkedScreenIds = new Set(); + // BFS로 연결된 모든 화면 탐색 + while (queue.length > 0) { + const currentScreenId = queue.shift()!; + + // 이미 방문한 화면은 스킵 (순환 참조 방지) + if (visited.has(currentScreenId)) { + console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`); + continue; + } + + visited.add(currentScreenId); + console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`); - // 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인 - for (const layout of layouts) { - try { - const properties = layout.properties; - - // 버튼 컴포넌트인지 확인 - if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) { - const action = properties?.componentConfig?.action; + // 현재 화면의 모든 레이아웃 조회 + const layouts = await query( + `SELECT layout_id, properties + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + AND properties IS NOT NULL`, + [currentScreenId] + ); + + console.log(` 📦 레이아웃 개수: ${layouts.length}`); + + // 각 레이아웃에서 연결된 화면 ID 확인 + for (const layout of layouts) { + try { + const properties = layout.properties; - // popup, modal, edit 액션이고 targetScreenId가 있는 경우 - // edit 액션도 수정 폼 모달을 열기 때문에 포함 - if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) { - const targetScreenId = parseInt(action.targetScreenId); - if (!isNaN(targetScreenId)) { - linkedScreenIds.add(targetScreenId); - console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`); + // 1. 버튼 컴포넌트의 액션 확인 + if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) { + const action = properties?.componentConfig?.action; + + const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; + if (modalActionTypes.includes(action?.type) && action?.targetScreenId) { + const targetScreenId = parseInt(action.targetScreenId); + if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) { + // 메인 화면이 아닌 경우에만 추가 + if (targetScreenId !== screenId) { + allLinkedScreenIds.add(targetScreenId); + } + // 아직 방문하지 않은 화면이면 큐에 추가 + if (!visited.has(targetScreenId)) { + queue.push(targetScreenId); + console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`); + } + } } } + + // 2. conditional-container 컴포넌트의 sections 확인 + if (properties?.componentType === "conditional-container") { + const sections = properties?.componentConfig?.sections || []; + + for (const section of sections) { + if (section?.screenId) { + const sectionScreenId = parseInt(section.screenId); + if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) { + // 메인 화면이 아닌 경우에만 추가 + if (sectionScreenId !== screenId) { + allLinkedScreenIds.add(sectionScreenId); + } + // 아직 방문하지 않은 화면이면 큐에 추가 + if (!visited.has(sectionScreenId)) { + queue.push(sectionScreenId); + console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`); + } + } + } + } + } + } catch (error) { + console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error); } - } catch (error) { - // JSON 파싱 오류 등은 무시하고 계속 진행 - console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error); } } + console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`); + console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`); + console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`); + // 감지된 화면 ID들의 정보 조회 - if (linkedScreenIds.size === 0) { + if (allLinkedScreenIds.size === 0) { + console.log(`ℹ️ 연결된 화면이 없습니다.`); return []; } - const screenIds = Array.from(linkedScreenIds); + const screenIds = Array.from(allLinkedScreenIds); const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", "); const linkedScreens = await query( @@ -2073,6 +2226,11 @@ export class ScreenManagementService { screenIds ); + console.log(`\n📋 최종 감지된 화면 목록:`); + linkedScreens.forEach((s: any) => { + console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`); + }); + return linkedScreens.map((s) => ({ screenId: s.screen_id, screenName: s.screen_name, @@ -2342,23 +2500,23 @@ export class ScreenManagementService { for (const layout of layouts) { try { const properties = layout.properties; + let needsUpdate = false; - // 버튼 컴포넌트인지 확인 + // 1. 버튼 컴포넌트의 targetScreenId 업데이트 if ( properties?.componentType === "button" || properties?.componentType?.startsWith("button-") ) { const action = properties?.componentConfig?.action; - // targetScreenId가 있는 액션 (popup, modal, edit) + // targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData) + const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; if ( - (action?.type === "popup" || - action?.type === "modal" || - action?.type === "edit") && + modalActionTypes.includes(action?.type) && action?.targetScreenId ) { const oldScreenId = parseInt(action.targetScreenId); - console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`); + console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`); // 매핑에 있으면 업데이트 if (screenIdMapping.has(oldScreenId)) { @@ -2368,31 +2526,63 @@ export class ScreenManagementService { // properties 업데이트 properties.componentConfig.action.targetScreenId = newScreenId.toString(); + needsUpdate = true; - // 데이터베이스 업데이트 - await query( - `UPDATE screen_layouts - SET properties = $1 - WHERE layout_id = $2`, - [JSON.stringify(properties), layout.layout_id] - ); - - updateCount++; console.log( - `🔗 버튼 targetScreenId 업데이트: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})` + `🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})` ); } else { console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); } } } + + // 2. conditional-container 컴포넌트의 sections[].screenId 업데이트 + if (properties?.componentType === "conditional-container") { + const sections = properties?.componentConfig?.sections || []; + + for (const section of sections) { + if (section?.screenId) { + const oldScreenId = parseInt(section.screenId); + console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`); + + // 매핑에 있으면 업데이트 + if (screenIdMapping.has(oldScreenId)) { + const newScreenId = screenIdMapping.get(oldScreenId)!; + console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`); + + // section.screenId 업데이트 + section.screenId = newScreenId; + needsUpdate = true; + + console.log( + `🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})` + ); + } else { + console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); + } + } + } + } + + // 3. 업데이트가 필요한 경우 DB 저장 + if (needsUpdate) { + await query( + `UPDATE screen_layouts + SET properties = $1 + WHERE layout_id = $2`, + [JSON.stringify(properties), layout.layout_id] + ); + updateCount++; + console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`); + } } catch (error) { console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error); // 개별 레이아웃 오류는 무시하고 계속 진행 } } - console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`); + console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`); return updateCount; } } diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index bffb0d05..2a379ae0 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -445,7 +445,129 @@ class TableCategoryValueService { } /** - * 카테고리 값 삭제 (비활성화) + * 카테고리 값 사용 여부 확인 + * 실제 데이터 테이블에서 해당 카테고리 값이 사용되고 있는지 확인 + */ + async checkCategoryValueUsage( + valueId: number, + companyCode: string + ): Promise<{ isUsed: boolean; usedInTables: any[]; totalCount: number }> { + const pool = getPool(); + + try { + logger.info("카테고리 값 사용 여부 확인", { valueId, companyCode }); + + // 1. 카테고리 값 정보 조회 + let valueQuery: string; + let valueParams: any[]; + + if (companyCode === "*") { + valueQuery = ` + SELECT table_name, column_name, value_code + FROM table_column_category_values + WHERE value_id = $1 + `; + valueParams = [valueId]; + } else { + valueQuery = ` + SELECT table_name, column_name, value_code + FROM table_column_category_values + WHERE value_id = $1 + AND company_code = $2 + `; + valueParams = [valueId, companyCode]; + } + + const valueResult = await pool.query(valueQuery, valueParams); + + if (valueResult.rowCount === 0) { + throw new Error("카테고리 값을 찾을 수 없습니다"); + } + + const { table_name, column_name, value_code } = valueResult.rows[0]; + + // 2. 실제 데이터 테이블에서 사용 여부 확인 + // 테이블이 존재하는지 먼저 확인 + const tableExistsQuery = ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ) as exists + `; + + const tableExistsResult = await pool.query(tableExistsQuery, [table_name]); + + if (!tableExistsResult.rows[0].exists) { + logger.info("테이블이 존재하지 않음", { table_name }); + return { isUsed: false, usedInTables: [], totalCount: 0 }; + } + + // 3. 해당 테이블에서 value_code를 사용하는 데이터 개수 확인 + let dataCountQuery: string; + let dataCountParams: any[]; + + if (companyCode === "*") { + dataCountQuery = ` + SELECT COUNT(*) as count + FROM ${table_name} + WHERE ${column_name} = $1 + `; + dataCountParams = [value_code]; + } else { + dataCountQuery = ` + SELECT COUNT(*) as count + FROM ${table_name} + WHERE ${column_name} = $1 + AND company_code = $2 + `; + dataCountParams = [value_code, companyCode]; + } + + const dataCountResult = await pool.query(dataCountQuery, dataCountParams); + const totalCount = parseInt(dataCountResult.rows[0].count); + const isUsed = totalCount > 0; + + // 4. 사용 중인 메뉴 목록 조회 (해당 테이블을 사용하는 화면/메뉴) + const menuQuery = ` + SELECT DISTINCT + mi.objid as menu_objid, + mi.menu_name_kor as menu_name, + mi.menu_url + FROM menu_info mi + INNER JOIN screen_menu_assignments sma ON sma.menu_objid = mi.objid + INNER JOIN screen_definitions sd ON sd.screen_id = sma.screen_id + WHERE sd.table_name = $1 + AND mi.company_code = $2 + ORDER BY mi.menu_name_kor + `; + + const menuResult = await pool.query(menuQuery, [table_name, companyCode]); + + const usedInTables = menuResult.rows.map((row) => ({ + menuObjid: row.menu_objid, + menuName: row.menu_name, + menuUrl: row.menu_url, + tableName: table_name, + columnName: column_name, + })); + + logger.info("카테고리 값 사용 여부 확인 완료", { + valueId, + isUsed, + totalCount, + usedInMenusCount: usedInTables.length, + }); + + return { isUsed, usedInTables, totalCount }; + } catch (error: any) { + logger.error(`카테고리 값 사용 여부 확인 실패: ${error.message}`); + throw error; + } + } + + /** + * 카테고리 값 삭제 (물리적 삭제) */ async deleteCategoryValue( valueId: number, @@ -455,7 +577,24 @@ class TableCategoryValueService { const pool = getPool(); try { - // 하위 값 체크 (멀티테넌시 적용) + // 1. 사용 여부 확인 + const usage = await this.checkCategoryValueUsage(valueId, companyCode); + + if (usage.isUsed) { + let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n"; + errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`; + + if (usage.usedInTables.length > 0) { + const menuNames = usage.usedInTables.map((t) => t.menuName).join(", "); + errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`; + } + + errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요."; + + throw new Error(errorMessage); + } + + // 2. 하위 값 체크 (멀티테넌시 적용) let checkQuery: string; let checkParams: any[]; @@ -465,7 +604,6 @@ class TableCategoryValueService { SELECT COUNT(*) as count FROM table_column_category_values WHERE parent_value_id = $1 - AND is_active = true `; checkParams = [valueId]; } else { @@ -475,7 +613,6 @@ class TableCategoryValueService { FROM table_column_category_values WHERE parent_value_id = $1 AND company_code = $2 - AND is_active = true `; checkParams = [valueId, companyCode]; } @@ -486,27 +623,25 @@ class TableCategoryValueService { throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); } - // 비활성화 (멀티테넌시 적용) + // 3. 물리적 삭제 (멀티테넌시 적용) let deleteQuery: string; let deleteParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values - SET is_active = false, updated_at = NOW(), updated_by = $2 + DELETE FROM table_column_category_values WHERE value_id = $1 `; - deleteParams = [valueId, userId]; + deleteParams = [valueId]; } else { // 일반 회사: 자신의 카테고리 값만 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values - SET is_active = false, updated_at = NOW(), updated_by = $3 + DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2 `; - deleteParams = [valueId, companyCode, userId]; + deleteParams = [valueId, companyCode]; } const result = await pool.query(deleteQuery, deleteParams); @@ -515,7 +650,7 @@ class TableCategoryValueService { throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다"); } - logger.info("카테고리 값 삭제(비활성화) 완료", { + logger.info("카테고리 값 삭제 완료", { valueId, companyCode, }); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 8ce3c9d4..38fc77b1 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -144,6 +144,19 @@ export class TableManagementService { logger.info( `컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개` ); + + // 디버깅: 캐시된 currency_code 확인 + const cachedCurrency = cachedResult.columns.find( + (col: any) => col.columnName === "currency_code" + ); + if (cachedCurrency) { + console.log(`💾 [캐시] currency_code:`, { + columnName: cachedCurrency.columnName, + inputType: cachedCurrency.inputType, + webType: cachedCurrency.webType, + }); + } + return cachedResult; } @@ -174,6 +187,8 @@ export class TableManagementService { c.data_type as "dbType", COALESCE(cl.input_type, 'text') as "webType", COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType", + ttc.input_type as "ttc_input_type", + cl.input_type as "cl_input_type", COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings", COALESCE(cl.description, '') as "description", c.is_nullable as "isNullable", @@ -782,8 +797,13 @@ export class TableManagementService { ] ); + // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 + const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; + cache.delete(cacheKeyPattern); + cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); + logger.info( - `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}` + `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} (캐시 무효화 완료)` ); } catch (error) { logger.error( diff --git a/backend-node/src/utils/dataFilterUtil.ts b/backend-node/src/utils/dataFilterUtil.ts index d00861fb..a4e81fd6 100644 --- a/backend-node/src/utils/dataFilterUtil.ts +++ b/backend-node/src/utils/dataFilterUtil.ts @@ -6,9 +6,28 @@ export interface ColumnFilter { id: string; columnName: string; - operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null"; + operator: + | "equals" + | "not_equals" + | "in" + | "not_in" + | "contains" + | "starts_with" + | "ends_with" + | "is_null" + | "is_not_null" + | "greater_than" + | "less_than" + | "greater_than_or_equal" + | "less_than_or_equal" + | "between" + | "date_range_contains"; value: string | string[]; - valueType: "static" | "category" | "code"; + valueType: "static" | "category" | "code" | "dynamic"; + rangeConfig?: { + startColumn: string; + endColumn: string; + }; } export interface DataFilterConfig { @@ -123,6 +142,71 @@ export function buildDataFilterWhereClause( conditions.push(`${columnRef} IS NOT NULL`); break; + case "greater_than": + conditions.push(`${columnRef} > $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "less_than": + conditions.push(`${columnRef} < $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "greater_than_or_equal": + conditions.push(`${columnRef} >= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "less_than_or_equal": + conditions.push(`${columnRef} <= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "between": + if (Array.isArray(value) && value.length === 2) { + conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`); + params.push(value[0], value[1]); + paramIndex += 2; + } + break; + + case "date_range_contains": + // 날짜 범위 포함: start_date <= value <= end_date + // filter.rangeConfig = { startColumn: "start_date", endColumn: "end_date" } + // NULL 처리: + // - start_date만 있고 end_date가 NULL이면: start_date <= value (이후 계속) + // - end_date만 있고 start_date가 NULL이면: value <= end_date (이전 계속) + // - 둘 다 있으면: start_date <= value <= end_date + if (filter.rangeConfig && filter.rangeConfig.startColumn && filter.rangeConfig.endColumn) { + const startCol = getColumnRef(filter.rangeConfig.startColumn); + const endCol = getColumnRef(filter.rangeConfig.endColumn); + + // value가 "TODAY"면 현재 날짜로 변환 + const actualValue = filter.valueType === "dynamic" && value === "TODAY" + ? "CURRENT_DATE" + : `$${paramIndex}`; + + if (actualValue === "CURRENT_DATE") { + // CURRENT_DATE는 파라미터가 아니므로 직접 SQL에 포함 + // NULL 처리: (start_date IS NULL OR start_date <= CURRENT_DATE) AND (end_date IS NULL OR end_date >= CURRENT_DATE) + conditions.push( + `((${startCol} IS NULL OR ${startCol} <= CURRENT_DATE) AND (${endCol} IS NULL OR ${endCol} >= CURRENT_DATE))` + ); + } else { + // NULL 처리: (start_date IS NULL OR start_date <= $param) AND (end_date IS NULL OR end_date >= $param) + conditions.push( + `((${startCol} IS NULL OR ${startCol} <= $${paramIndex}) AND (${endCol} IS NULL OR ${endCol} >= $${paramIndex}))` + ); + params.push(value); + paramIndex++; + } + } + break; + default: // 알 수 없는 연산자는 무시 break; diff --git a/docs/기간별_단가_설정_가이드.md b/docs/기간별_단가_설정_가이드.md new file mode 100644 index 00000000..67bed5f9 --- /dev/null +++ b/docs/기간별_단가_설정_가이드.md @@ -0,0 +1,382 @@ +# 기간별 단가 설정 시스템 구현 가이드 + +## 개요 + +**선택항목 상세입력(selected-items-detail-input)** 컴포넌트를 활용하여 기간별 단가를 설정하는 범용 시스템입니다. + +## 데이터베이스 설계 + +### 1. 마이그레이션 실행 + +```bash +# 마이그레이션 파일 위치 +db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql + +# 실행 (로컬) +npm run migrate:local + +# 또는 수동 실행 +psql -U your_user -d erp_db -f db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql +``` + +### 2. 추가된 컬럼들 + +| 컬럼명 | 타입 | 설명 | 사진 항목 | +|--------|------|------|-----------| +| `start_date` | DATE | 기간 시작일 | ✅ 시작일 DatePicker | +| `end_date` | DATE | 기간 종료일 | ✅ 종료일 DatePicker | +| `discount_type` | VARCHAR(50) | 할인 방식 | ✅ 할인율/할인금액 Select | +| `discount_value` | NUMERIC(15,2) | 할인율 또는 할인금액 | ✅ 숫자 입력 | +| `rounding_type` | VARCHAR(50) | 반올림 방식 | ✅ 반올림/절삭/올림 Select | +| `rounding_unit_value` | VARCHAR(50) | 반올림 단위 | ✅ 1원/10원/100원/1,000원 Select | +| `calculated_price` | NUMERIC(15,2) | 계산된 최종 단가 | ✅ 계산 결과 표시 | +| `is_base_price` | BOOLEAN | 기준단가 여부 | ✅ 기준단가 Checkbox | + +## 화면 편집기 설정 방법 + +### Step 1: 선택항목 상세입력 컴포넌트 추가 + +1. 화면 편집기에서 "선택항목 상세입력" 컴포넌트를 캔버스에 드래그앤드롭 +2. 컴포넌트 ID: `customer-item-price-periods` + +### Step 2: 데이터 소스 설정 + +- **원본 데이터 테이블**: `item_info` (품목 정보) +- **저장 대상 테이블**: `customer_item_mapping` +- **데이터 소스 ID**: URL 파라미터에서 자동 설정 (Button 컴포넌트가 전달) + +### Step 3: 표시할 원본 데이터 컬럼 설정 + +이전 화면(품목 선택 모달)에서 전달받은 품목 정보를 표시: + +``` +컬럼1: item_code (품목코드) +컬럼2: item_name (품목명) +컬럼3: spec (규격) +``` + +### Step 4: 필드 그룹 2개 생성 + +#### 그룹 1: 거래처 품목/품명 관리 (group_customer) + +| 필드명 | 라벨 | 타입 | 설명 | +|--------|------|------|------| +| `customer_item_code` | 거래처 품번 | text | 거래처에서 사용하는 품번 | +| `customer_item_name` | 거래처 품명 | text | 거래처에서 사용하는 품명 | + +#### 그룹 2: 기간별 단가 설정 (group_period_price) + +| 필드명 | 라벨 | 타입 | 자동 채우기 | 설명 | +|--------|------|------|-------------|------| +| `start_date` | 시작일 | date | - | 단가 적용 시작일 | +| `end_date` | 종료일 | date | - | 단가 적용 종료일 (NULL이면 무기한) | +| `current_unit_price` | 단가 | number | `item_info.standard_price` | 기본 단가 (품목에서 자동 채우기) | +| `currency_code` | 통화 | code/category | - | 통화 코드 (KRW, USD 등) | +| `discount_type` | 할인 방식 | code/category | - | 할인율없음/할인율(%)/할인금액 | +| `discount_value` | 할인값 | number | - | 할인율(5) 또는 할인금액 | +| `rounding_type` | 반올림 방식 | code/category | - | 반올림없음/반올림/절삭/올림 | +| `rounding_unit_value` | 반올림 단위 | code/category | - | 1원/10원/100원/1,000원 | +| `calculated_price` | 최종 단가 | number | - | 계산된 최종 단가 (읽기 전용) | +| `is_base_price` | 기준단가 | checkbox | - | 기준단가 여부 | + +### Step 5: 그룹별 표시 항목 설정 (DisplayItems) + +**그룹 2 (기간별 단가 설정)의 표시 설정:** + +``` +1. [필드] start_date | 라벨: "" | 형식: date | 빈 값: 기본값 (미설정) +2. [텍스트] " ~ " +3. [필드] end_date | 라벨: "" | 형식: date | 빈 값: 기본값 (무기한) +4. [텍스트] " | " +5. [필드] calculated_price | 라벨: "" | 형식: currency | 빈 값: 기본값 (계산 중) +6. [텍스트] " " +7. [필드] currency_code | 라벨: "" | 형식: text | 빈 값: 기본값 (KRW) +8. [조건] is_base_price가 true이면 → [배지] "기준단가" (variant: default) +``` + +**렌더링 예시:** +``` +2024-01-01 ~ 2024-06-30 | 50,000 KRW [기준단가] +2024-07-01 ~ 무기한 | 55,000 KRW +``` + +## 데이터 흐름 + +### 1. 품목 선택 모달 (이전 화면) + +```tsx +// TableList 컴포넌트에서 품목 선택 + +``` + +### 2. 기간별 단가 설정 화면 + +```tsx +// 선택항목 상세입력 컴포넌트가 자동으로 처리 +// 1. URL 파라미터에서 dataSourceId 읽기 +// 2. modalDataStore에서 item_info 데이터 가져오기 +// 3. 사용자가 그룹별로 여러 개의 기간별 단가 입력 +// 4. 저장 버튼 클릭 시 customer_item_mapping 테이블에 저장 +``` + +### 3. 저장 데이터 구조 + +**하나의 품목(item_id = "ITEM001")에 대해 3개의 기간별 단가를 입력한 경우:** + +```sql +-- customer_item_mapping 테이블에 3개의 행으로 저장 +INSERT INTO customer_item_mapping ( + customer_id, item_id, + customer_item_code, customer_item_name, + start_date, end_date, + current_unit_price, currency_code, + discount_type, discount_value, + rounding_type, rounding_unit_value, + calculated_price, is_base_price +) VALUES +-- 첫 번째 기간 (기준단가) +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2024-01-01', '2024-06-30', + 50000, 'KRW', + '할인율없음', 0, + '반올림', '100원', + 50000, true), + +-- 두 번째 기간 +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2024-07-01', '2024-12-31', + 50000, 'KRW', + '할인율(%)', 5, + '절삭', '1원', + 47500, false), + +-- 세 번째 기간 (무기한) +('CUST001', 'ITEM001', + 'CUST-A-001', '실리콘 고무 시트', + '2025-01-01', NULL, + 50000, 'KRW', + '할인금액', 3000, + '올림', '1000원', + 47000, false); +``` + +## 계산 로직 (선택사항) + +단가 계산을 자동화하려면 프론트엔드에서 `calculated_price`를 자동 계산: + +```typescript +const calculatePrice = ( + basePrice: number, + discountType: string, + discountValue: number, + roundingType: string, + roundingUnit: string +): number => { + let price = basePrice; + + // 1단계: 할인 적용 + if (discountType === "할인율(%)") { + price = price * (1 - discountValue / 100); + } else if (discountType === "할인금액") { + price = price - discountValue; + } + + // 2단계: 반올림 적용 + const unitMap: Record = { + "1원": 1, + "10원": 10, + "100원": 100, + "1,000원": 1000, + }; + + const unit = unitMap[roundingUnit] || 1; + + if (roundingType === "반올림") { + price = Math.round(price / unit) * unit; + } else if (roundingType === "절삭") { + price = Math.floor(price / unit) * unit; + } else if (roundingType === "올림") { + price = Math.ceil(price / unit) * unit; + } + + return price; +}; + +// 필드 변경 시 자동 계산 +useEffect(() => { + const calculatedPrice = calculatePrice( + basePrice, + discountType, + discountValue, + roundingType, + roundingUnit + ); + + // calculated_price 필드 업데이트 + handleFieldChange(itemId, groupId, entryId, "calculated_price", calculatedPrice); +}, [basePrice, discountType, discountValue, roundingType, roundingUnit]); +``` + +## 백엔드 API 구현 (필요시) + +### 기간별 단가 조회 + +```typescript +// GET /api/customer-item/price-periods?customer_id=CUST001&item_id=ITEM001 +router.get("/price-periods", async (req, res) => { + const { customer_id, item_id } = req.query; + const companyCode = req.user!.companyCode; + + const query = ` + SELECT * FROM customer_item_mapping + WHERE customer_id = $1 + AND item_id = $2 + AND company_code = $3 + ORDER BY start_date ASC + `; + + const result = await pool.query(query, [customer_id, item_id, companyCode]); + + return res.json({ success: true, data: result.rows }); +}); +``` + +### 기간별 단가 저장 + +```typescript +// POST /api/customer-item/price-periods +router.post("/price-periods", async (req, res) => { + const { items } = req.body; // 선택항목 상세입력 컴포넌트에서 전달 + const companyCode = req.user!.companyCode; + + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + for (const item of items) { + // item.fieldGroups.group_period_price 배열의 각 항목을 INSERT + const periodPrices = item.fieldGroups.group_period_price || []; + + for (const periodPrice of periodPrices) { + const query = ` + INSERT INTO customer_item_mapping ( + company_code, customer_id, item_id, + customer_item_code, customer_item_name, + start_date, end_date, + current_unit_price, currency_code, + discount_type, discount_value, + rounding_type, rounding_unit_value, + calculated_price, is_base_price + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + `; + + await client.query(query, [ + companyCode, + item.originalData.customer_id, + item.originalData.item_id, + periodPrice.customer_item_code, + periodPrice.customer_item_name, + periodPrice.start_date, + periodPrice.end_date || null, + periodPrice.current_unit_price, + periodPrice.currency_code, + periodPrice.discount_type, + periodPrice.discount_value, + periodPrice.rounding_type, + periodPrice.rounding_unit_value, + periodPrice.calculated_price, + periodPrice.is_base_price + ]); + } + } + + await client.query("COMMIT"); + + return res.json({ success: true, message: "기간별 단가가 저장되었습니다." }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("기간별 단가 저장 실패:", error); + return res.status(500).json({ success: false, error: "저장 실패" }); + } finally { + client.release(); + } +}); +``` + +## 사용 시나리오 예시 + +### 시나리오 1: 거래처별 단가 관리 + +1. 거래처 선택 모달 → 거래처 선택 → 다음 +2. 품목 선택 모달 → 품목 여러 개 선택 → 다음 +3. **기간별 단가 설정 화면** + - 품목1 (실리콘 고무 시트) + - **그룹1 추가**: 거래처 품번: CUST-A-001, 품명: 실리콘 시트 + - **그룹2 추가**: 2024-01-01 ~ 2024-06-30, 50,000원 (기준단가) + - **그룹2 추가**: 2024-07-01 ~ 무기한, 할인율 5% → 47,500원 + - 품목2 (스테인리스 판) + - **그룹1 추가**: 거래처 품번: CUST-A-002, 품명: SUS304 판 + - **그룹2 추가**: 2024-01-01 ~ 무기한, 150,000원 (기준단가) +4. 저장 버튼 클릭 → customer_item_mapping 테이블에 4개 행 저장 + +### 시나리오 2: 단순 단가 입력 + +필드 그룹을 사용하지 않고 단일 입력도 가능: + +``` +그룹 없이 필드 정의: +- customer_item_code +- customer_item_name +- current_unit_price +- currency_code + +→ 각 품목당 1개의 행만 저장 +``` + +## 장점 + +### 1. 범용성 +- 기간별 단가뿐만 아니라 **모든 숫자 계산 시나리오**에 적용 가능 +- 견적서, 발주서, 판매 단가, 구매 단가 등 + +### 2. 유연성 +- 필드 그룹으로 자유롭게 섹션 구성 +- 표시 항목 설정으로 UI 커스터마이징 + +### 3. 데이터 무결성 +- 1:N 관계로 여러 기간별 데이터 관리 +- 기간 중복 체크는 백엔드에서 처리 + +### 4. 사용자 경험 +- 품목별로 여러 개의 기간별 단가를 손쉽게 입력 +- 입력 완료 후 작은 카드로 요약 표시 + +## 다음 단계 + +1. **마이그레이션 실행** (999_add_period_price_columns_to_customer_item_mapping.sql) +2. **화면 편집기에서 설정** (위 Step 1~5 참고) +3. **백엔드 API 구현** (저장/조회 엔드포인트) +4. **계산 로직 추가** (선택사항: 자동 계산) +5. **테스트** (품목 선택 → 기간별 단가 입력 → 저장 → 조회) + +## 참고 자료 + +- 선택항목 상세입력 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/` +- 타입 정의: `frontend/lib/registry/components/selected-items-detail-input/types.ts` +- 설정 패널: `SelectedItemsDetailInputConfigPanel.tsx` + diff --git a/frontend/MODAL_REPEATER_TABLE_DEBUG.md b/frontend/MODAL_REPEATER_TABLE_DEBUG.md new file mode 100644 index 00000000..0f0f66ce --- /dev/null +++ b/frontend/MODAL_REPEATER_TABLE_DEBUG.md @@ -0,0 +1,185 @@ +# Modal Repeater Table 디버깅 가이드 + +## 📊 콘솔 로그 확인 순서 + +새로고침 후 수주 등록 모달을 열고, 아래 순서대로 콘솔 로그를 확인하세요: + +### 1️⃣ 컴포넌트 마운트 (초기 로드) + +``` +🎬 ModalRepeaterTableComponent 마운트: { + config: {...}, + propColumns: [...], + columns: [...], + columnsLength: N, // ⚠️ 0이면 문제! + value: [], + valueLength: 0, + sourceTable: "item_info", + sourceColumns: [...], + uniqueField: "item_number" +} +``` + +**✅ 정상:** +- `columnsLength: 8` (품번, 품명, 규격, 재질, 수량, 단가, 금액, 납기일) +- `columns` 배열에 각 컬럼의 `field`, `label`, `type` 정보가 있어야 함 + +**❌ 문제:** +- `columnsLength: 0` → **이것이 문제의 원인!** +- 빈 배열이면 테이블에 컬럼이 표시되지 않음 + +--- + +### 2️⃣ 항목 검색 모달 열림 + +``` +🚪 모달 열림 - uniqueField: "item_number", multiSelect: true +``` + +--- + +### 3️⃣ 품목 체크 (선택) + +``` +🖱️ 행 클릭: { + item: { item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... }, + uniqueField: "item_number", + itemValue: "SLI-2025-0003", + currentSelected: 0, + selectedValues: [] +} + +✅ 매칭 발견: { selectedValue: "SLI-2025-0003", itemValue: "SLI-2025-0003", uniqueField: "item_number" } +``` + +--- + +### 4️⃣ 추가 버튼 클릭 + +``` +✅ ItemSelectionModal 추가 버튼 클릭: { + selectedCount: 1, + selectedItems: [{ item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... }], + uniqueField: "item_number" +} +``` + +--- + +### 5️⃣ 데이터 추가 처리 + +``` +➕ handleAddItems 호출: { + selectedItems: [{ item_number: "SLI-2025-0003", ... }], + currentValue: [], + columns: [...], // ⚠️ 여기도 확인! + calculationRules: [...] +} + +📝 기본값 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, ... }] + +🔢 계산 필드 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }] + +✅ 최종 데이터 (onChange 호출): [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }] +``` + +--- + +### 6️⃣ Renderer 업데이트 + +``` +🔄 ModalRepeaterTableRenderer onChange 호출: { + previousValue: [], + newValue: [{ item_number: "SLI-2025-0003", ... }] +} +``` + +--- + +### 7️⃣ value 변경 감지 + +``` +📦 ModalRepeaterTableComponent value 변경: { + valueLength: 1, + value: [{ item_number: "SLI-2025-0003", ... }], + columns: [...] // ⚠️ 여기도 확인! +} +``` + +--- + +### 8️⃣ 테이블 리렌더링 + +``` +📊 RepeaterTable 데이터 업데이트: { + rowCount: 1, + data: [{ item_number: "SLI-2025-0003", ... }], + columns: ["item_number", "item_name", "specification", "material", "quantity", "selling_price", "amount", "delivery_date"] +} +``` + +--- + +## 🔍 문제 진단 + +### Case 1: columns가 비어있음 (columnsLength: 0) + +**원인:** +- 화면관리 시스템에서 modal-repeater-table 컴포넌트의 `columns` 설정을 하지 않음 +- DB에 컬럼 설정이 저장되지 않음 + +**해결:** +1. 화면 관리 페이지로 이동 +2. 해당 화면 편집 +3. modal-repeater-table 컴포넌트 선택 +4. 우측 설정 패널에서 "컬럼 설정" 탭 열기 +5. 다음 컬럼들을 추가: + - 품번 (item_number, text, 편집불가) + - 품명 (item_name, text, 편집불가) + - 규격 (specification, text, 편집불가) + - 재질 (material, text, 편집불가) + - 수량 (quantity, number, 편집가능, 기본값: 1) + - 단가 (selling_price, number, 편집가능) + - 금액 (amount, number, 편집불가, 계산필드) + - 납기일 (delivery_date, date, 편집가능) +6. 저장 + +--- + +### Case 2: 로그가 8번까지 나오는데 화면에 안 보임 + +**원인:** +- React 리렌더링 문제 +- 화면관리 시스템의 상태 동기화 문제 + +**해결:** +1. 브라우저 개발자 도구 → Elements 탭 +2. `#component-comp_5jdmuzai .border.rounded-md table tbody` 찾기 +3. 실제 DOM에 `` 요소가 추가되었는지 확인 +4. 추가되었다면 CSS 문제 (display: none 등) +5. 추가 안 되었다면 컴포넌트 렌더링 문제 + +--- + +### Case 3: 로그가 5번까지만 나오고 멈춤 + +**원인:** +- `onChange` 콜백이 제대로 전달되지 않음 +- Renderer의 `updateComponent`가 작동하지 않음 + +**해결:** +- 이미 수정한 `ModalRepeaterTableRenderer.tsx` 코드 확인 +- `handleChange` 함수가 호출되는지 확인 + +--- + +## 📝 다음 단계 + +위 로그를 **모두** 복사해서 공유해주세요. 특히: + +1. **🎬 마운트 로그의 `columnsLength` 값** +2. **로그가 어디까지 출력되는지** +3. **Elements 탭에서 `tbody` 내부 HTML 구조** + +이 정보로 정확한 문제를 진단할 수 있습니다! + diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index d8eeae61..e7680584 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -18,32 +18,26 @@ import { Pagination, PaginationInfo } from "@/components/common/Pagination"; import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; -interface DashboardListClientProps { - initialDashboards: Dashboard[]; - initialPagination: { - total: number; - page: number; - limit: number; - }; -} - /** * 대시보드 목록 클라이언트 컴포넌트 + * - CSR 방식으로 초기 데이터 로드 * - 대시보드 목록 조회 * - 대시보드 생성/수정/삭제/복사 */ -export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) { +export default function DashboardListClient() { const router = useRouter(); const { toast } = useToast(); - const [dashboards, setDashboards] = useState(initialDashboards); - const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료 + + // 상태 관리 + const [dashboards, setDashboards] = useState([]); + const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(""); // 페이지네이션 상태 - const [currentPage, setCurrentPage] = useState(initialPagination.page); - const [pageSize, setPageSize] = useState(initialPagination.limit); - const [totalCount, setTotalCount] = useState(initialPagination.total); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalCount, setTotalCount] = useState(0); // 모달 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -73,17 +67,8 @@ export default function DashboardListClient({ initialDashboards, initialPaginati } }; - // 초기 로드 여부 추적 - const [isInitialLoad, setIsInitialLoad] = useState(true); - + // 검색어/페이지 변경 시 fetch (초기 로딩 포함) useEffect(() => { - // 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음) - if (isInitialLoad) { - setIsInitialLoad(false); - return; - } - - // 이후 검색어/페이지 변경 시에만 fetch loadDashboards(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm, currentPage, pageSize]); @@ -91,7 +76,7 @@ export default function DashboardListClient({ initialDashboards, initialPaginati // 페이지네이션 정보 계산 const paginationInfo: PaginationInfo = { currentPage, - totalPages: Math.ceil(totalCount / pageSize), + totalPages: Math.ceil(totalCount / pageSize) || 1, totalItems: totalCount, itemsPerPage: pageSize, startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, @@ -300,7 +285,14 @@ export default function DashboardListClient({ initialDashboards, initialPaginati {dashboards.map((dashboard) => ( - {dashboard.title} + + + {dashboard.description || "-"} @@ -355,7 +347,12 @@ export default function DashboardListClient({ initialDashboards, initialPaginati {/* 헤더 */}
-

{dashboard.title}

+

{dashboard.id}

diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index 8d78600c..7d09bafc 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,73 +1,22 @@ import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient"; -import { cookies } from "next/headers"; /** - * 서버에서 초기 대시보드 목록 fetch + * 대시보드 관리 페이지 + * - 클라이언트 컴포넌트를 렌더링하는 래퍼 + * - 초기 로딩부터 CSR로 처리 */ -async function getInitialDashboards() { - try { - // 서버 사이드 전용: 백엔드 API 직접 호출 - // 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1 - const backendUrl = process.env.SERVER_API_URL || "http://backend:8080"; - - // 쿠키에서 authToken 추출 - const cookieStore = await cookies(); - const authToken = cookieStore.get("authToken")?.value; - - if (!authToken) { - // 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드) - return { - dashboards: [], - pagination: { total: 0, page: 1, limit: 10 }, - }; - } - - const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, { - cache: "no-store", // 항상 최신 데이터 - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달 - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch dashboards: ${response.status}`); - } - - const data = await response.json(); - return { - dashboards: data.data || [], - pagination: data.pagination || { total: 0, page: 1, limit: 10 }, - }; - } catch (error) { - console.error("Server-side fetch error:", error); - // 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능) - return { - dashboards: [], - pagination: { total: 0, page: 1, limit: 10 }, - }; - } -} - -/** - * 대시보드 관리 페이지 (서버 컴포넌트) - * - 페이지 헤더 + 초기 데이터를 서버에서 렌더링 - * - 클라이언트 컴포넌트로 초기 데이터 전달 - */ -export default async function DashboardListPage() { - const initialData = await getInitialDashboards(); - +export default function DashboardListPage() { return (
- {/* 페이지 헤더 (서버에서 렌더링) */} + {/* 페이지 헤더 */}

대시보드 관리

대시보드를 생성하고 관리할 수 있습니다

- {/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */} - + {/* 클라이언트 컴포넌트 */} +
); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index ebfbd3e7..3b75f262 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -65,6 +65,9 @@ function ScreenViewPage() { // 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨) const [flowRefreshKey, setFlowRefreshKey] = useState(0); + // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) + const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -402,19 +405,39 @@ function ScreenViewPage() { (c) => (c as any).componentId === "table-search-widget" ); - // TableSearchWidget 높이 차이를 계산하여 Y 위치 조정 + // 디버그: 모든 컴포넌트 타입 확인 + console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({ + id: c.id, + type: c.type, + componentType: (c as any).componentType, + componentId: (c as any).componentId, + }))); + + // 🆕 조건부 컨테이너들을 찾기 + const conditionalContainers = regularComponents.filter( + (c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container" + ); + + console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({ + id: c.id, + y: c.position.y, + size: c.size, + }))); + + // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 const adjustedComponents = regularComponents.map((component) => { const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + const isConditionalContainer = (component as any).componentId === "conditional-container"; - if (isTableSearchWidget) { - // TableSearchWidget 자체는 조정하지 않음 + if (isTableSearchWidget || isConditionalContainer) { + // 자기 자신은 조정하지 않음 return component; } let totalHeightAdjustment = 0; + // TableSearchWidget 높이 조정 for (const widget of tableSearchWidgets) { - // 현재 컴포넌트가 이 위젯 아래에 있는지 확인 const isBelow = component.position.y > widget.position.y; const heightDiff = getHeightDiff(screenId, widget.id); @@ -423,6 +446,31 @@ function ScreenViewPage() { } } + // 🆕 조건부 컨테이너 높이 조정 + for (const container of conditionalContainers) { + const isBelow = component.position.y > container.position.y; + const actualHeight = conditionalContainerHeights[container.id]; + const originalHeight = container.size?.height || 200; + const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0; + + console.log(`🔍 높이 조정 체크:`, { + componentId: component.id, + componentY: component.position.y, + containerY: container.position.y, + isBelow, + actualHeight, + originalHeight, + heightDiff, + containerId: container.id, + containerSize: container.size, + }); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`); + } + } + if (totalHeightAdjustment > 0) { return { ...component, @@ -491,6 +539,12 @@ function ScreenViewPage() { onFormDataChange={(fieldName, value) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); }} + onHeightChange={(componentId, newHeight) => { + setConditionalContainerHeights((prev) => ({ + ...prev, + [componentId]: newHeight, + })); + }} > {/* 자식 컴포넌트들 */} {(component.type === "group" || component.type === "container" || component.type === "area") && diff --git a/frontend/app/test-autocomplete-mapping/page.tsx b/frontend/app/test-autocomplete-mapping/page.tsx new file mode 100644 index 00000000..234c75f6 --- /dev/null +++ b/frontend/app/test-autocomplete-mapping/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import React, { useState } from "react"; +import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +export default function TestAutocompleteMapping() { + const [selectedValue, setSelectedValue] = useState(""); + const [customerName, setCustomerName] = useState(""); + const [address, setAddress] = useState(""); + const [phone, setPhone] = useState(""); + + return ( +
+ + + AutocompleteSearchInput 필드 자동 매핑 테스트 + + 거래처를 선택하면 아래 입력 필드들이 자동으로 채워집니다 + + + + {/* 검색 컴포넌트 */} +
+ + { + setSelectedValue(value); + console.log("선택된 항목:", fullData); + }} + /> +
+ + {/* 구분선 */} +
+

+ 자동으로 채워지는 필드들 +

+
+ {/* 거래처명 */} +
+ + setCustomerName(e.target.value)} + placeholder="자동으로 채워집니다" + /> +
+ + {/* 주소 */} +
+ + setAddress(e.target.value)} + placeholder="자동으로 채워집니다" + /> +
+ + {/* 전화번호 */} +
+ + setPhone(e.target.value)} + placeholder="자동으로 채워집니다" + /> +
+
+
+ + {/* 상태 표시 */} +
+

현재 상태

+
+
+                {JSON.stringify(
+                  {
+                    selectedValue,
+                    customerName,
+                    address,
+                    phone,
+                  },
+                  null,
+                  2
+                )}
+              
+
+
+
+
+ + {/* 사용 안내 */} + + + 사용 방법 + + +
    +
  1. 위의 검색 필드에 거래처명이나 코드를 입력하세요
  2. +
  3. 드롭다운에서 원하는 거래처를 선택하세요
  4. +
  5. 아래 입력 필드들이 자동으로 채워지는 것을 확인하세요
  6. +
  7. 필요한 경우 자동으로 채워진 값을 수정할 수 있습니다
  8. +
+
+
+
+ ); +} + diff --git a/frontend/app/test-entity-search/page.tsx b/frontend/app/test-entity-search/page.tsx new file mode 100644 index 00000000..802024d9 --- /dev/null +++ b/frontend/app/test-entity-search/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function TestEntitySearchPage() { + return ( +
+
+

EntitySearchInput 테스트

+

이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.

+
+ + + + 빌드 에러 수정 중 + 순환 참조 문제를 해결한 후 다시 활성화됩니다. + + +

+ 에러 메시지: ReferenceError: Cannot access 'h' before initialization +

+
+
+
+ ); +} diff --git a/frontend/app/test-order-registration/page.tsx b/frontend/app/test-order-registration/page.tsx new file mode 100644 index 00000000..fe6a005f --- /dev/null +++ b/frontend/app/test-order-registration/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function TestOrderRegistrationPage() { + return ( +
+
+

수주 등록 테스트

+

이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.

+
+ + + + 빌드 에러 수정 중 + ModalRepeaterTable 순환 참조 문제를 해결한 후 다시 활성화됩니다. + + +

+ 에러 메시지: ReferenceError: Cannot access 'h' before initialization +

+
+
+
+ ); +} diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index ce08c522..2bb85051 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -812,69 +812,70 @@ export function CanvasElement({ }} onMouseDown={handleMouseDown} > - {/* 헤더 */} -
-
- {/* 차트 타입 전환 드롭다운 (차트일 경우만) */} - {element.type === "chart" && ( - { + onUpdate(element.id, { subtype: newSubtype as ElementSubtype }); + }} > - - - e.stopPropagation()}> - {getChartCategory(element.subtype) === "axis-based" ? ( - - 축 기반 차트 - 바 차트 - 수평 바 차트 - 누적 바 차트 - 꺾은선 차트 - 영역 차트 - 콤보 차트 - - ) : ( - - 원형 차트 - 원형 차트 - 도넛 차트 - - )} - - - )} - {/* 제목 */} - {!element.type || element.type !== "chart" ? ( - element.subtype === "map-summary-v2" && !element.customTitle ? null : ( - {element.customTitle || element.title} - ) - ) : null} + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + e.stopPropagation()}> + {getChartCategory(element.subtype) === "axis-based" ? ( + + 축 기반 차트 + 바 차트 + 수평 바 차트 + 누적 바 차트 + 꺾은선 차트 + 영역 차트 + 콤보 차트 + + ) : ( + + 원형 차트 + 원형 차트 + 도넛 차트 + + )} + + + )} + {/* 제목 */} + {!element.type || element.type !== "chart" ? ( + element.subtype === "map-summary-v2" && !element.customTitle ? null : ( + {element.customTitle || element.title} + ) + ) : null} +
-
- {/* 삭제 버튼 */} - -
- + )} + + {/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */} + {/* 내용 */} -
+
{element.type === "chart" ? ( // 차트 렌더링
diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index 8f0b65e0..50e57689 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -1,12 +1,11 @@ "use client"; import React, { useState, useCallback } from "react"; -import { ChartDataSource, QueryResult, ChartConfig } from "./types"; +import { ChartDataSource, QueryResult } from "./types"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { dashboardApi } from "@/lib/api/dashboard"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -14,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react"; -import { applyQueryFilters } from "./utils/queryHelpers"; interface QueryEditorProps { dataSource?: ChartDataSource; @@ -106,7 +104,6 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que ...dataSource, type: "database", query: query.trim(), - refreshInterval: dataSource?.refreshInterval ?? 0, lastExecuted: new Date().toISOString(), }); } catch (err) { @@ -168,8 +165,8 @@ ORDER BY 하위부서수 DESC`, {/* 쿼리 에디터 헤더 */}
- -

SQL 쿼리 에디터

+ +

SQL 쿼리 에디터

@@ -247,46 +244,6 @@ ORDER BY 하위부서수 DESC`,
- {/* 새로고침 간격 설정 */} -
- - -
- {/* 오류 메시지 */} {error && ( @@ -300,15 +257,15 @@ ORDER BY 하위부서수 DESC`, {/* 쿼리 결과 미리보기 */} {queryResult && ( -
+
- 쿼리 결과 + 쿼리 결과 {queryResult.rows.length}행
- 실행 시간: {queryResult.executionTime}ms + 실행 시간: {queryResult.executionTime}ms
@@ -339,13 +296,13 @@ ORDER BY 하위부서수 DESC`, {queryResult.rows.length > 10 && ( -
+
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
)}
) : ( -
결과가 없습니다.
+
결과가 없습니다.
)}
@@ -353,169 +310,3 @@ ORDER BY 하위부서수 DESC`,
); } - -/** - * 샘플 쿼리 결과 생성 함수 - */ -function generateSampleQueryResult(query: string): QueryResult { - // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 - const queryLower = query.toLowerCase(); - - // 디버깅용 로그 - // console.log('generateSampleQueryResult called with query:', query.substring(0, 100)); - - // 가장 구체적인 조건부터 먼저 체크 (순서 중요!) - const isComparison = - queryLower.includes("galaxy") || - queryLower.includes("갤럭시") || - queryLower.includes("아이폰") || - queryLower.includes("iphone"); - const isRegional = queryLower.includes("region") || queryLower.includes("지역"); - const isMonthly = queryLower.includes("month"); - const isSales = queryLower.includes("sales") || queryLower.includes("매출"); - const isUsers = queryLower.includes("users") || queryLower.includes("사용자"); - const isProducts = queryLower.includes("product") || queryLower.includes("상품"); - const isWeekly = queryLower.includes("week"); - - // console.log('Sample data type detection:', { - // isComparison, - // isRegional, - // isWeekly, - // isProducts, - // isMonthly, - // isSales, - // isUsers, - // querySnippet: query.substring(0, 200) - // }); - - let columns: string[]; - let rows: Record[]; - - // 더 구체적인 조건부터 먼저 체크 (순서 중요!) - if (isComparison) { - // console.log('✅ Using COMPARISON data'); - // 제품 비교 데이터 (다중 시리즈) - columns = ["month", "galaxy_sales", "iphone_sales", "other_sales"]; - rows = [ - { month: "2024-01", galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 }, - { month: "2024-02", galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 }, - { month: "2024-03", galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 }, - { month: "2024-04", galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 }, - { month: "2024-05", galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 }, - { month: "2024-06", galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 }, - { month: "2024-07", galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 }, - { month: "2024-08", galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 }, - { month: "2024-09", galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 }, - { month: "2024-10", galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 }, - { month: "2024-11", galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 }, - { month: "2024-12", galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 }, - ]; - // COMPARISON 데이터를 반환하고 함수 종료 - // console.log('COMPARISON data generated:', { - // columns, - // rowCount: rows.length, - // sampleRow: rows[0], - // allRows: rows, - // fieldTypes: { - // month: typeof rows[0].month, - // galaxy_sales: typeof rows[0].galaxy_sales, - // iphone_sales: typeof rows[0].iphone_sales, - // other_sales: typeof rows[0].other_sales - // }, - // firstFewRows: rows.slice(0, 3), - // lastFewRows: rows.slice(-3) - // }); - return { - columns, - rows, - totalRows: rows.length, - executionTime: Math.floor(Math.random() * 200) + 100, - }; - } else if (isRegional) { - // console.log('✅ Using REGIONAL data'); - // 지역별 분기별 매출 - columns = ["지역", "Q1", "Q2", "Q3", "Q4"]; - rows = [ - { 지역: "서울", Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 }, - { 지역: "경기", Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 }, - { 지역: "부산", Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 }, - { 지역: "대구", Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 }, - { 지역: "인천", Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 }, - { 지역: "광주", Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 }, - { 지역: "대전", Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 }, - ]; - } else if (isWeekly && isUsers) { - // console.log('✅ Using USERS data'); - // 사용자 가입 추이 - columns = ["week", "new_users"]; - rows = [ - { week: "2024-W10", new_users: 23 }, - { week: "2024-W11", new_users: 31 }, - { week: "2024-W12", new_users: 28 }, - { week: "2024-W13", new_users: 35 }, - { week: "2024-W14", new_users: 42 }, - { week: "2024-W15", new_users: 38 }, - { week: "2024-W16", new_users: 45 }, - { week: "2024-W17", new_users: 52 }, - { week: "2024-W18", new_users: 48 }, - { week: "2024-W19", new_users: 55 }, - { week: "2024-W20", new_users: 61 }, - { week: "2024-W21", new_users: 58 }, - ]; - } else if (isProducts && !isComparison) { - // console.log('✅ Using PRODUCTS data'); - // 상품별 판매량 - columns = ["product_name", "total_sold", "revenue"]; - rows = [ - { product_name: "스마트폰", total_sold: 156, revenue: 234000000 }, - { product_name: "노트북", total_sold: 89, revenue: 178000000 }, - { product_name: "태블릿", total_sold: 134, revenue: 67000000 }, - { product_name: "이어폰", total_sold: 267, revenue: 26700000 }, - { product_name: "스마트워치", total_sold: 98, revenue: 49000000 }, - { product_name: "키보드", total_sold: 78, revenue: 15600000 }, - { product_name: "마우스", total_sold: 145, revenue: 8700000 }, - { product_name: "모니터", total_sold: 67, revenue: 134000000 }, - { product_name: "프린터", total_sold: 34, revenue: 17000000 }, - { product_name: "웹캠", total_sold: 89, revenue: 8900000 }, - ]; - } else if (isMonthly && isSales && !isComparison) { - // console.log('✅ Using MONTHLY SALES data'); - // 월별 매출 데이터 - columns = ["month", "sales", "order_count"]; - rows = [ - { month: "2024-01", sales: 1200000, order_count: 45 }, - { month: "2024-02", sales: 1350000, order_count: 52 }, - { month: "2024-03", sales: 1180000, order_count: 41 }, - { month: "2024-04", sales: 1420000, order_count: 58 }, - { month: "2024-05", sales: 1680000, order_count: 67 }, - { month: "2024-06", sales: 1540000, order_count: 61 }, - { month: "2024-07", sales: 1720000, order_count: 71 }, - { month: "2024-08", sales: 1580000, order_count: 63 }, - { month: "2024-09", sales: 1650000, order_count: 68 }, - { month: "2024-10", sales: 1780000, order_count: 75 }, - { month: "2024-11", sales: 1920000, order_count: 82 }, - { month: "2024-12", sales: 2100000, order_count: 89 }, - ]; - } else { - // console.log('⚠️ Using DEFAULT data'); - // 기본 샘플 데이터 - columns = ["category", "value", "count"]; - rows = [ - { category: "A", value: 100, count: 10 }, - { category: "B", value: 150, count: 15 }, - { category: "C", value: 120, count: 12 }, - { category: "D", value: 180, count: 18 }, - { category: "E", value: 90, count: 9 }, - { category: "F", value: 200, count: 20 }, - { category: "G", value: 110, count: 11 }, - { category: "H", value: 160, count: 16 }, - ]; - } - - return { - columns, - rows, - totalRows: rows.length, - executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms - }; -} diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index a18abf9a..db608645 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -46,7 +46,7 @@ const needsDataSource = (subtype: ElementSubtype): boolean => { "chart", "map-summary-v2", "risk-alert-v2", - "yard-management-3d", + // "yard-management-3d", // 데이터 탭 불필요 (레이아웃 선택만 사용) "todo", "document", "work-history", @@ -449,13 +449,30 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
+ {/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */} + {element.subtype === "yard-management-3d" && ( +
+ +

표시할 디지털 트윈 레이아웃을 선택하세요

+
+

위젯 내부에서 레이아웃을 선택할 수 있습니다.

+

편집 모드에서 레이아웃 목록을 확인하고 선택하세요.

+
+
+ )} + {/* 자동 새로고침 설정 (지도 위젯 전용) */} {element.subtype === "map-summary-v2" && (
- setRefreshInterval(parseInt(value))} + > @@ -579,30 +596,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge refreshInterval={element.chartConfig?.refreshInterval || 5} markerType={element.chartConfig?.markerType || "circle"} onRefreshIntervalChange={(interval) => { - setElement((prev) => - prev - ? { - ...prev, - chartConfig: { - ...prev.chartConfig, - refreshInterval: interval, - }, - } - : prev - ); + setChartConfig((prev) => ({ + ...prev, + refreshInterval: interval, + })); }} onMarkerTypeChange={(type) => { - setElement((prev) => - prev - ? { - ...prev, - chartConfig: { - ...prev.chartConfig, - markerType: type, - }, - } - : prev - ); + setChartConfig((prev) => ({ + ...prev, + markerType: type, + })); }} /> )} @@ -619,20 +622,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge -