diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9ffaaa8a..be51e70e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -69,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"; // 임시 주석 @@ -232,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/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts new file mode 100644 index 00000000..5046d8bb --- /dev/null +++ b/backend-node/src/controllers/entitySearchController.ts @@ -0,0 +1,118 @@ +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 엔티티 검색 API + * GET /api/entity-search/:tableName + */ +export async function searchEntity(req: Request, 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..0b76fd95 --- /dev/null +++ b/backend-node/src/controllers/orderController.ts @@ -0,0 +1,238 @@ +import { Request, Response } from "express"; +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: Request, 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: Request, 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..c696d5de 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -14,7 +14,7 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } = + const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } = req.query; // 입력값 검증 @@ -37,6 +37,9 @@ router.get( } } + // 🆕 enableEntityJoin 파싱 + const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; + // SQL 인젝션 방지를 위한 검증 const tables = [leftTable as string, rightTable as string]; const columns = [leftColumn as string, rightColumn as string]; @@ -64,6 +67,31 @@ 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 +99,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 +113,10 @@ router.get( rightColumn as string, leftValue as string, userCompany, - parsedDataFilter // 🆕 데이터 필터 전달 + parsedDataFilter, + enableEntityJoinFlag, + parsedDisplayColumns, // 🆕 표시 컬럼 전달 + parsedDeduplication // 🆕 중복 제거 설정 전달 ); if (!result.success) { @@ -305,10 +339,31 @@ router.get( }); } - console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`); + const { enableEntityJoin, groupByColumns } = req.query; + const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; + + // 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); + } + } - // 레코드 상세 조회 - const result = await dataService.getRecordDetail(tableName, id); + 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 +393,86 @@ 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.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + return res.json({ + success: true, + message: "데이터가 저장되었습니다.", + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + } 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} @@ -523,6 +658,46 @@ 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/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..d9b13475 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,284 @@ 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) { + // 날짜 필드 정규화 + const normalizedRecord: Record = {}; + for (const [key, value] of Object.entries(newRecord)) { + normalizedRecord[key] = normalizeDateValue(value); + } + + // 전체 레코드 데이터 (parentKeys + normalizedRecord) + const fullRecord = { ...parentKeys, ...normalizedRecord }; + + // 고유 키: parentKeys 제외한 나머지 필드들 + const uniqueFields = Object.keys(normalizedRecord); + + // 기존 레코드에서 일치하는 것 찾기 + 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..3283ea09 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, @@ -200,6 +200,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 +229,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 +291,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,64 +304,55 @@ 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); + // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; if (isJoinTableColumn) { // 조인 테이블 컬럼은 조인 별칭 사용 @@ -336,8 +365,20 @@ export class EntityJoinService { .join(` || '${separator}' || `); resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); + + // 🆕 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 +397,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 +465,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 +619,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)/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..af9317a3 --- /dev/null +++ b/frontend/app/test-entity-search/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import React, { useState } from "react"; +import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input"; +import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; + +export default function TestEntitySearchPage() { + const [customerCode, setCustomerCode] = useState(""); + const [customerData, setCustomerData] = useState(null); + + const [itemCode, setItemCode] = useState(""); + const [itemData, setItemData] = useState(null); + + return ( +
+
+

EntitySearchInput 테스트

+

+ 엔티티 검색 입력 컴포넌트 동작 테스트 +

+
+ + {/* 거래처 검색 테스트 - 자동완성 방식 */} + + + 거래처 검색 (자동완성 드롭다운 방식) ⭐ NEW + + 타이핑하면 바로 드롭다운이 나타나는 방식 - 수주 등록에서 사용 + + + +
+ + { + setCustomerCode(code || ""); + setCustomerData(fullData); + }} + /> +
+ + {customerData && ( +
+

선택된 거래처 정보:

+
+                {JSON.stringify(customerData, null, 2)}
+              
+
+ )} +
+
+ + {/* 거래처 검색 테스트 - 모달 방식 */} + + + 거래처 검색 (모달 방식) + + 버튼 클릭 → 모달 열기 → 검색 및 선택 방식 + + + +
+ + { + setCustomerCode(code || ""); + setCustomerData(fullData); + }} + /> +
+
+
+ + {/* 품목 검색 테스트 */} + + + 품목 검색 (Modal 모드) + + item_info 테이블에서 품목을 검색합니다 + + + +
+ + { + setItemCode(code || ""); + setItemData(fullData); + }} + /> +
+ + {itemData && ( +
+

선택된 품목 정보:

+
+                {JSON.stringify(itemData, null, 2)}
+              
+
+ )} +
+
+
+ ); +} + diff --git a/frontend/app/test-order-registration/page.tsx b/frontend/app/test-order-registration/page.tsx new file mode 100644 index 00000000..689c5434 --- /dev/null +++ b/frontend/app/test-order-registration/page.tsx @@ -0,0 +1,87 @@ +"use client"; + +import React, { useState } from "react"; +import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function TestOrderRegistrationPage() { + const [modalOpen, setModalOpen] = useState(false); + + const handleSuccess = () => { + console.log("수주 등록 성공!"); + }; + + return ( +
+
+

수주 등록 테스트

+

+ EntitySearchInput + ModalRepeaterTable을 활용한 수주 등록 화면 +

+
+ + + + 수주 등록 모달 + + 모달 버튼을 클릭하여 수주 등록 화면을 테스트하세요 + + + + + + + + + + 구현된 기능 + + +
+ + EntitySearchInput: 거래처 검색 및 선택 (콤보 모드) +
+
+ + ModalRepeaterTable: 품목 검색 및 동적 추가 +
+
+ + 자동 계산: 수량 × 단가 = 금액 +
+
+ + 인라인 편집: 수량, 단가, 납품일, 비고 수정 가능 +
+
+ + 중복 방지: 이미 추가된 품목은 선택 불가 +
+
+ + 행 삭제: 추가된 품목 개별 삭제 가능 +
+
+ + 전체 금액 표시: 모든 품목 금액의 합계 +
+
+ + 입력 방식 전환: 거래처 우선 / 견대 방식 / 단가 방식 +
+
+
+ + {/* 수주 등록 모달 */} + +
+ ); +} + diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 609c2b43..cf0a5edb 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -15,6 +15,8 @@ import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; +import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; +import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; interface ScreenModalState { isOpen: boolean; @@ -54,11 +56,11 @@ export const ScreenModal: React.FC = ({ className }) => { // 폼 데이터 상태 추가 const [formData, setFormData] = useState>({}); - + // 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록) const continuousModeRef = useRef(false); const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음) - + // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); @@ -119,7 +121,19 @@ export const ScreenModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { - const { screenId, title, description, size } = event.detail; + const { screenId, title, description, size, urlParams } = event.detail; + + // 🆕 URL 파라미터가 있으면 현재 URL에 추가 + if (urlParams && typeof window !== "undefined") { + const currentUrl = new URL(window.location.href); + Object.entries(urlParams).forEach(([key, value]) => { + currentUrl.searchParams.set(key, String(value)); + }); + // pushState로 URL 변경 (페이지 새로고침 없이) + window.history.pushState({}, "", currentUrl.toString()); + console.log("✅ URL 파라미터 추가:", urlParams); + } + setModalState({ isOpen: true, screenId, @@ -130,6 +144,15 @@ export const ScreenModal: React.FC = ({ className }) => { }; const handleCloseModal = () => { + // 🆕 URL 파라미터 제거 + if (typeof window !== "undefined") { + const currentUrl = new URL(window.location.href); + // dataSourceId 파라미터 제거 + currentUrl.searchParams.delete("dataSourceId"); + window.history.pushState({}, "", currentUrl.toString()); + console.log("🧹 URL 파라미터 제거"); + } + setModalState({ isOpen: false, screenId: null, @@ -150,14 +173,14 @@ export const ScreenModal: React.FC = ({ className }) => { // console.log("💾 저장 성공 이벤트 수신"); // console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode); // console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); - + if (isContinuousMode) { // 연속 모드: 폼만 초기화하고 모달은 유지 // console.log("✅ 연속 모드 활성화 - 폼만 초기화"); - + // 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨) setFormData({}); - + toast.success("저장되었습니다. 계속 입력하세요."); } else { // 일반 모드: 모달 닫기 @@ -198,13 +221,132 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("API 응답:", { screenInfo, layoutData }); + // 🆕 URL 파라미터 확인 (수정 모드) + if (typeof window !== "undefined") { + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get("mode"); + const editId = urlParams.get("editId"); + const tableName = urlParams.get("tableName") || screenInfo.tableName; + const groupByColumnsParam = urlParams.get("groupByColumns"); + + console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam }); + + // 수정 모드이고 editId가 있으면 해당 레코드 조회 + if (mode === "edit" && editId && tableName) { + try { + console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam }); + + const { dataApi } = await import("@/lib/api/data"); + + // groupByColumns 파싱 + let groupByColumns: string[] = []; + if (groupByColumnsParam) { + try { + groupByColumns = JSON.parse(groupByColumnsParam); + console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns); + } catch (e) { + console.warn("groupByColumns 파싱 실패:", e); + } + } else { + console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!"); + } + + console.log("🚀 [ScreenModal] API 호출 직전:", { + tableName, + editId, + enableEntityJoin: true, + groupByColumns, + groupByColumnsLength: groupByColumns.length, + }); + + // 🆕 apiClient를 named import로 가져오기 + const { apiClient } = await import("@/lib/api/client"); + const params: any = { + enableEntityJoin: true, + }; + if (groupByColumns.length > 0) { + params.groupByColumns = JSON.stringify(groupByColumns); + console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns); + } + + console.log("📡 [ScreenModal] 실제 API 요청:", { + url: `/data/${tableName}/${editId}`, + params, + }); + + const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params }); + const response = apiResponse.data; + + console.log("📩 [ScreenModal] API 응답 받음:", { + success: response.success, + hasData: !!response.data, + dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음", + dataLength: Array.isArray(response.data) ? response.data.length : 1, + }); + + if (response.success && response.data) { + // 배열인 경우 (그룹핑) vs 단일 객체 + const isArray = Array.isArray(response.data); + + if (isArray) { + console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`); + console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); + } else { + console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")"); + console.log("📊 모든 필드 키:", Object.keys(response.data)); + console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); + } + + // 🔧 날짜 필드 정규화 (타임존 제거) + const normalizeDates = (data: any): any => { + if (Array.isArray(data)) { + return data.map(normalizeDates); + } + + if (typeof data !== 'object' || data === null) { + return data; + } + + const normalized: any = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + // ISO 날짜 형식 감지: YYYY-MM-DD만 추출 + const before = value; + const after = value.split('T')[0]; + console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`); + normalized[key] = after; + } else { + normalized[key] = value; + } + } + return normalized; + }; + + console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2)); + const normalizedData = normalizeDates(response.data); + console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2)); + setFormData(normalizedData); + + // setFormData 직후 확인 + console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); + } else { + console.error("❌ 수정 데이터 로드 실패:", response.error); + toast.error("데이터를 불러올 수 없습니다."); + } + } catch (error) { + console.error("❌ 수정 데이터 조회 오류:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + } + } + // screenApi는 직접 데이터를 반환하므로 .success 체크 불필요 if (screenInfo && layoutData) { const components = layoutData.components || []; // 화면 관리에서 설정한 해상도 사용 (우선순위) const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution; - + let dimensions; if (screenResolution && screenResolution.width && screenResolution.height) { // 화면 관리에서 설정한 해상도 사용 @@ -220,7 +362,7 @@ export const ScreenModal: React.FC = ({ className }) => { dimensions = calculateScreenDimensions(components); console.log("⚠️ 자동 계산된 크기 사용:", dimensions); } - + setScreenDimensions(dimensions); setScreenData({ @@ -245,6 +387,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; const handleClose = () => { + // 🔧 URL 파라미터 제거 (mode, editId, tableName 등) + if (typeof window !== "undefined") { + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete("mode"); + currentUrl.searchParams.delete("editId"); + currentUrl.searchParams.delete("tableName"); + currentUrl.searchParams.delete("groupByColumns"); + window.history.pushState({}, "", currentUrl.toString()); + console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)"); + } + setModalState({ isOpen: false, screenId: null, @@ -280,17 +433,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; const modalStyle = getModalStyle(); - + // 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지) const [persistedModalId, setPersistedModalId] = useState(undefined); - + // modalId 생성 및 업데이트 useEffect(() => { // 모달이 열려있고 screenId가 있을 때만 업데이트 if (!modalState.isOpen) return; - + let newModalId: string | undefined; - + // 1순위: screenId (가장 안정적) if (modalState.screenId) { newModalId = `screen-modal-${modalState.screenId}`; @@ -328,11 +481,17 @@ export const ScreenModal: React.FC = ({ className }) => { // result: newModalId, // }); } - + if (newModalId) { setPersistedModalId(newModalId); } - }, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]); + }, [ + modalState.isOpen, + modalState.screenId, + modalState.title, + screenData?.screenInfo?.tableName, + screenData?.screenInfo?.screenName, + ]); return ( @@ -373,55 +532,59 @@ export const ScreenModal: React.FC = ({ className }) => { ) : screenData ? ( -
- {screenData.components.map((component) => { - // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 - const offsetX = screenDimensions?.offsetX || 0; - const offsetY = screenDimensions?.offsetY || 0; + +
+ {screenData.components.map((component) => { + // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; - // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) - const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : { - ...component, - position: { - ...component.position, - x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY, - }, - }; + // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) + const adjustedComponent = + offsetX === 0 && offsetY === 0 + ? component + : { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + }, + }; - return ( - { - // console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`); - // console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { + return ( + { + setFormData((prev) => ({ ...prev, [fieldName]: value, - }; - // console.log("📝 ScreenModal 업데이트된 formData:", newFormData); - return newFormData; - }); - }} - screenInfo={{ - id: modalState.screenId!, - tableName: screenData.screenInfo?.tableName, - }} - /> - ); - })} -
+ })); + }} + onRefresh={() => { + // 부모 화면의 테이블 새로고침 이벤트 발송 + console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송"); + window.dispatchEvent(new CustomEvent("refreshTable")); + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData.screenInfo?.tableName, + }} + /> + ); + })} +
+ ) : (

화면 데이터가 없습니다.

@@ -443,10 +606,7 @@ export const ScreenModal: React.FC = ({ className }) => { // console.log("🔄 연속 모드 변경:", isChecked); }} /> -
diff --git a/frontend/components/order/OrderCustomerSearch.tsx b/frontend/components/order/OrderCustomerSearch.tsx new file mode 100644 index 00000000..bcd351f9 --- /dev/null +++ b/frontend/components/order/OrderCustomerSearch.tsx @@ -0,0 +1,49 @@ +"use client"; + +import React from "react"; +import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input"; + +/** + * 수주 등록 전용 거래처 검색 컴포넌트 + * + * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다. + * 범용 AutocompleteSearchInput과 달리 customer_mng 테이블만 조회합니다. + */ + +interface OrderCustomerSearchProps { + /** 현재 선택된 거래처 코드 */ + value: string; + /** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */ + onChange: (customerCode: string | null, fullData?: any) => void; + /** 비활성화 여부 */ + disabled?: boolean; +} + +export function OrderCustomerSearch({ + value, + onChange, + disabled = false, +}: OrderCustomerSearchProps) { + return ( + + ); +} + diff --git a/frontend/components/order/OrderItemRepeaterTable.tsx b/frontend/components/order/OrderItemRepeaterTable.tsx new file mode 100644 index 00000000..dd38ee5a --- /dev/null +++ b/frontend/components/order/OrderItemRepeaterTable.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React from "react"; +import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table"; +import type { + RepeaterColumnConfig, + CalculationRule, +} from "@/lib/registry/components/modal-repeater-table"; + +/** + * 수주 등록 전용 품목 반복 테이블 컴포넌트 + * + * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다. + * 범용 ModalRepeaterTable과 달리 item_info 테이블만 조회하며, + * 수주 등록에 필요한 컬럼과 계산 공식이 미리 설정되어 있습니다. + */ + +interface OrderItemRepeaterTableProps { + /** 현재 선택된 품목 목록 */ + value: any[]; + /** 품목 목록 변경 시 콜백 */ + onChange: (items: any[]) => void; + /** 비활성화 여부 */ + disabled?: boolean; +} + +// 수주 등록 전용 컬럼 설정 (고정) +const ORDER_COLUMNS: RepeaterColumnConfig[] = [ + { + field: "item_number", + label: "품번", + editable: false, + width: "120px", + }, + { + field: "item_name", + label: "품명", + editable: false, + width: "180px", + }, + { + field: "specification", + label: "규격", + editable: false, + width: "150px", + }, + { + field: "material", + label: "재질", + editable: false, + width: "120px", + }, + { + field: "quantity", + label: "수량", + type: "number", + editable: true, + required: true, + defaultValue: 1, + width: "100px", + }, + { + field: "selling_price", + label: "단가", + type: "number", + editable: true, + required: true, + width: "120px", + }, + { + field: "amount", + label: "금액", + type: "number", + editable: false, + calculated: true, + width: "120px", + }, + { + field: "delivery_date", + label: "납기일", + type: "date", + editable: true, + width: "130px", + }, +]; + +// 수주 등록 전용 계산 공식 (고정) +const ORDER_CALCULATION_RULES: CalculationRule[] = [ + { + result: "amount", + formula: "quantity * selling_price", + dependencies: ["quantity", "selling_price"], + }, +]; + +export function OrderItemRepeaterTable({ + value, + onChange, + disabled = false, +}: OrderItemRepeaterTableProps) { + return ( + + ); +} + diff --git a/frontend/components/order/OrderRegistrationModal.tsx b/frontend/components/order/OrderRegistrationModal.tsx new file mode 100644 index 00000000..bd780038 --- /dev/null +++ b/frontend/components/order/OrderRegistrationModal.tsx @@ -0,0 +1,530 @@ +"use client"; + +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { OrderCustomerSearch } from "./OrderCustomerSearch"; +import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; + +interface OrderRegistrationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function OrderRegistrationModal({ + open, + onOpenChange, + onSuccess, +}: OrderRegistrationModalProps) { + // 입력 방식 + const [inputMode, setInputMode] = useState("customer_first"); + + // 판매 유형 (국내/해외) + const [salesType, setSalesType] = useState("domestic"); + + // 단가 기준 (기준단가/거래처별단가) + const [priceType, setPriceType] = useState("standard"); + + // 폼 데이터 + const [formData, setFormData] = useState({ + customerCode: "", + customerName: "", + contactPerson: "", + deliveryDestination: "", + deliveryAddress: "", + deliveryDate: "", + memo: "", + // 무역 정보 (해외 판매 시) + incoterms: "", + paymentTerms: "", + currency: "KRW", + portOfLoading: "", + portOfDischarge: "", + hsCode: "", + }); + + // 선택된 품목 목록 + const [selectedItems, setSelectedItems] = useState([]); + + // 저장 중 + const [isSaving, setIsSaving] = useState(false); + + // 저장 처리 + const handleSave = async () => { + try { + // 유효성 검사 + if (!formData.customerCode) { + toast.error("거래처를 선택해주세요"); + return; + } + + if (selectedItems.length === 0) { + toast.error("품목을 추가해주세요"); + return; + } + + setIsSaving(true); + + // 수주 등록 API 호출 + const orderData: any = { + inputMode, + salesType, + priceType, + customerCode: formData.customerCode, + contactPerson: formData.contactPerson, + deliveryDestination: formData.deliveryDestination, + deliveryAddress: formData.deliveryAddress, + deliveryDate: formData.deliveryDate, + items: selectedItems, + memo: formData.memo, + }; + + // 해외 판매 시 무역 정보 추가 + if (salesType === "export") { + orderData.tradeInfo = { + incoterms: formData.incoterms, + paymentTerms: formData.paymentTerms, + currency: formData.currency, + portOfLoading: formData.portOfLoading, + portOfDischarge: formData.portOfDischarge, + hsCode: formData.hsCode, + }; + } + + const response = await apiClient.post("/orders", orderData); + + if (response.data.success) { + toast.success("수주가 등록되었습니다"); + onOpenChange(false); + onSuccess?.(); + + // 폼 초기화 + resetForm(); + } else { + toast.error(response.data.message || "수주 등록에 실패했습니다"); + } + } catch (error: any) { + console.error("수주 등록 오류:", error); + toast.error( + error.response?.data?.message || "수주 등록 중 오류가 발생했습니다" + ); + } finally { + setIsSaving(false); + } + }; + + // 취소 처리 + const handleCancel = () => { + onOpenChange(false); + resetForm(); + }; + + // 폼 초기화 + const resetForm = () => { + setInputMode("customer_first"); + setSalesType("domestic"); + setPriceType("standard"); + setFormData({ + customerCode: "", + customerName: "", + contactPerson: "", + deliveryDestination: "", + deliveryAddress: "", + deliveryDate: "", + memo: "", + incoterms: "", + paymentTerms: "", + currency: "KRW", + portOfLoading: "", + portOfDischarge: "", + hsCode: "", + }); + setSelectedItems([]); + }; + + // 전체 금액 계산 + const totalAmount = selectedItems.reduce( + (sum, item) => sum + (item.amount || 0), + 0 + ); + + return ( + + + + 수주 등록 + + 새로운 수주를 등록합니다 + + + +
+ {/* 상단 셀렉트 박스 3개 */} +
+ {/* 입력 방식 */} +
+ + +
+ + {/* 판매 유형 */} +
+ + +
+ + {/* 단가 기준 */} +
+ + +
+
+ + {/* 거래처 정보 (항상 표시) */} + {inputMode === "customer_first" && ( +
+
+ 🏢 + 거래처 정보 +
+ +
+ {/* 거래처 */} +
+ + { + setFormData({ + ...formData, + customerCode: code || "", + customerName: fullData?.customer_name || "", + }); + }} + /> +
+ + {/* 담당자 */} +
+ + + setFormData({ ...formData, contactPerson: e.target.value }) + } + className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" + /> +
+ + {/* 납품처 */} +
+ + + setFormData({ ...formData, deliveryDestination: e.target.value }) + } + className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" + /> +
+ + {/* 납품장소 */} +
+ + + setFormData({ ...formData, deliveryAddress: e.target.value }) + } + className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" + /> +
+
+
+ )} + + {inputMode === "quotation" && ( +
+
+ + +
+
+ )} + + {inputMode === "unit_price" && ( +
+
+ + +
+
+ )} + + {/* 추가된 품목 */} +
+ + +
+ + {/* 전체 금액 표시 */} + {selectedItems.length > 0 && ( +
+
+ 전체 금액: {totalAmount.toLocaleString()}원 +
+
+ )} + + {/* 무역 정보 (해외 판매 시에만 표시) */} + {salesType === "export" && ( +
+
+ 🌏 + 무역 정보 +
+ +
+ {/* 인코텀즈 */} +
+ + +
+ + {/* 결제 조건 */} +
+ + +
+ + {/* 통화 */} +
+ + +
+
+ +
+ {/* 선적항 */} +
+ + + setFormData({ ...formData, portOfLoading: e.target.value }) + } + className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" + /> +
+ + {/* 도착항 */} +
+ + + setFormData({ ...formData, portOfDischarge: e.target.value }) + } + className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" + /> +
+ + {/* HS Code */} +
+ + + setFormData({ ...formData, hsCode: e.target.value }) + } + className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" + /> +
+
+
+ )} + + {/* 메모 */} +
+ +