From 08575c296e7fa3b59d03104ab2cb7b05134f3ce4 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 10 Dec 2025 15:59:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=B0=EC=87=84=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 8 + .../src/controllers/adminController.ts | 13 +- .../cascadingAutoFillController.ts | 568 +++++++++++ .../cascadingConditionController.ts | 525 ++++++++++ .../cascadingHierarchyController.ts | 752 +++++++++++++++ .../cascadingMutualExclusionController.ts | 505 ++++++++++ .../src/routes/cascadingAutoFillRoutes.ts | 52 + .../src/routes/cascadingConditionRoutes.ts | 48 + .../src/routes/cascadingHierarchyRoutes.ts | 64 ++ .../routes/cascadingMutualExclusionRoutes.ts | 52 + docs/레벨기반_연쇄드롭다운_설계.md | 699 ++++++++++++++ frontend/app/(main)/admin/auto-fill/page.tsx | 21 + .../admin/cascading-management/page.tsx | 104 ++ .../cascading-management/tabs/AutoFillTab.tsx | 686 +++++++++++++ .../tabs/CascadingRelationsTab.tsx | 898 ++++++++++++++++++ .../tabs/ConditionTab.tsx | 501 ++++++++++ .../tabs/HierarchyTab.tsx | 847 +++++++++++++++++ .../tabs/MutualExclusionTab.tsx | 582 ++++++++++++ .../(main)/admin/cascading-relations/page.tsx | 798 +--------------- frontend/components/ui/command.tsx | 122 +-- frontend/hooks/useAutoFill.ts | 194 ++++ frontend/lib/api/cascadingAutoFill.ts | 231 +++++ frontend/lib/api/cascadingCondition.ts | 206 ++++ frontend/lib/api/cascadingHierarchy.ts | 317 +++++++ frontend/lib/api/cascadingMutualExclusion.ts | 215 +++++ 25 files changed, 8136 insertions(+), 872 deletions(-) create mode 100644 backend-node/src/controllers/cascadingAutoFillController.ts create mode 100644 backend-node/src/controllers/cascadingConditionController.ts create mode 100644 backend-node/src/controllers/cascadingHierarchyController.ts create mode 100644 backend-node/src/controllers/cascadingMutualExclusionController.ts create mode 100644 backend-node/src/routes/cascadingAutoFillRoutes.ts create mode 100644 backend-node/src/routes/cascadingConditionRoutes.ts create mode 100644 backend-node/src/routes/cascadingHierarchyRoutes.ts create mode 100644 backend-node/src/routes/cascadingMutualExclusionRoutes.ts create mode 100644 docs/레벨기반_연쇄드롭다운_설계.md create mode 100644 frontend/app/(main)/admin/auto-fill/page.tsx create mode 100644 frontend/app/(main)/admin/cascading-management/page.tsx create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/CascadingRelationsTab.tsx create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/ConditionTab.tsx create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/HierarchyTab.tsx create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/MutualExclusionTab.tsx create mode 100644 frontend/hooks/useAutoFill.ts create mode 100644 frontend/lib/api/cascadingAutoFill.ts create mode 100644 frontend/lib/api/cascadingCondition.ts create mode 100644 frontend/lib/api/cascadingHierarchy.ts create mode 100644 frontend/lib/api/cascadingMutualExclusion.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index c8494591..5c2415ea 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -77,6 +77,10 @@ import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 +import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리 +import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 +import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 +import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -249,6 +253,10 @@ app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 +app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리 +app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 +app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 +app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 5bcda820..a28712c1 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1256,8 +1256,17 @@ export async function updateMenu( } } - const requestCompanyCode = - menuData.companyCode || menuData.company_code || currentMenu.company_code; + let requestCompanyCode = + menuData.companyCode || menuData.company_code; + + // "none"이나 빈 값은 기존 메뉴의 회사 코드 유지 + if ( + requestCompanyCode === "none" || + requestCompanyCode === "" || + !requestCompanyCode + ) { + requestCompanyCode = currentMenu.company_code; + } // company_code 변경 시도하는 경우 권한 체크 if (requestCompanyCode !== currentMenu.company_code) { diff --git a/backend-node/src/controllers/cascadingAutoFillController.ts b/backend-node/src/controllers/cascadingAutoFillController.ts new file mode 100644 index 00000000..bf033880 --- /dev/null +++ b/backend-node/src/controllers/cascadingAutoFillController.ts @@ -0,0 +1,568 @@ +/** + * 자동 입력 (Auto-Fill) 컨트롤러 + * 마스터 선택 시 여러 필드 자동 입력 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 자동 입력 그룹 CRUD +// ===================================================== + +/** + * 자동 입력 그룹 목록 조회 + */ +export const getAutoFillGroups = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let sql = ` + SELECT + g.*, + COUNT(m.mapping_id) as mapping_count + FROM cascading_auto_fill_group g + LEFT JOIN cascading_auto_fill_mapping m + ON g.group_code = m.group_code AND g.company_code = m.company_code + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + // 회사 필터 + if (companyCode !== "*") { + sql += ` AND g.company_code = $${paramIndex++}`; + params.push(companyCode); + } + + // 활성 상태 필터 + if (isActive) { + sql += ` AND g.is_active = $${paramIndex++}`; + params.push(isActive); + } + + sql += ` GROUP BY g.group_id ORDER BY g.group_name`; + + const result = await query(sql, params); + + logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 그룹 상세 조회 (매핑 포함) + */ +export const getAutoFillGroupDetail = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupSql = ` + SELECT * FROM cascading_auto_fill_group + WHERE group_code = $1 + `; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const groupResult = await queryOne(groupSql, groupParams); + + if (!groupResult) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingSql = ` + SELECT * FROM cascading_auto_fill_mapping + WHERE group_code = $1 AND company_code = $2 + ORDER BY sort_order, mapping_id + `; + const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]); + + logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode }); + + res.json({ + success: true, + data: { + ...groupResult, + mappings: mappingResult, + }, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 그룹 코드 자동 생성 함수 + */ +const generateAutoFillGroupCode = async (companyCode: string): Promise => { + const prefix = "AF"; + const result = await queryOne( + `SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`, + [companyCode] + ); + const count = parseInt(result?.cnt || "0", 10) + 1; + const timestamp = Date.now().toString(36).toUpperCase().slice(-4); + return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`; +}; + +/** + * 자동 입력 그룹 생성 + */ +export const createAutoFillGroup = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + masterTable, + masterValueColumn, + masterLabelColumn, + mappings = [], + } = req.body; + + // 필수 필드 검증 + if (!groupName || !masterTable || !masterValueColumn) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)", + }); + } + + // 그룹 코드 자동 생성 + const groupCode = await generateAutoFillGroupCode(companyCode); + + // 그룹 생성 + const insertGroupSql = ` + INSERT INTO cascading_auto_fill_group ( + group_code, group_name, description, + master_table, master_value_column, master_label_column, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const groupResult = await queryOne(insertGroupSql, [ + groupCode, + groupName, + description || null, + masterTable, + masterValueColumn, + masterLabelColumn || null, + companyCode, + ]); + + // 매핑 생성 + if (mappings.length > 0) { + for (let i = 0; i < mappings.length; i++) { + const m = mappings[i]; + await query( + `INSERT INTO cascading_auto_fill_mapping ( + group_code, company_code, source_column, target_field, target_label, + is_editable, is_required, default_value, sort_order + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + groupCode, + companyCode, + m.sourceColumn, + m.targetField, + m.targetLabel || null, + m.isEditable || "Y", + m.isRequired || "N", + m.defaultValue || null, + m.sortOrder || i + 1, + ] + ); + } + } + + logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId }); + + res.status(201).json({ + success: true, + message: "자동 입력 그룹이 생성되었습니다.", + data: groupResult, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 그룹 수정 + */ +export const updateAutoFillGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + masterTable, + masterValueColumn, + masterLabelColumn, + isActive, + mappings, + } = req.body; + + // 기존 그룹 확인 + let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`; + const checkParams: any[] = [groupCode]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 그룹 업데이트 + const updateSql = ` + UPDATE cascading_auto_fill_group SET + group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + master_table = COALESCE($3, master_table), + master_value_column = COALESCE($4, master_value_column), + master_label_column = COALESCE($5, master_label_column), + is_active = COALESCE($6, is_active), + updated_date = CURRENT_TIMESTAMP + WHERE group_code = $7 AND company_code = $8 + RETURNING * + `; + + const updateResult = await queryOne(updateSql, [ + groupName, + description, + masterTable, + masterValueColumn, + masterLabelColumn, + isActive, + groupCode, + existing.company_code, + ]); + + // 매핑 업데이트 (전체 교체 방식) + if (mappings !== undefined) { + // 기존 매핑 삭제 + await query( + `DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`, + [groupCode, existing.company_code] + ); + + // 새 매핑 추가 + for (let i = 0; i < mappings.length; i++) { + const m = mappings[i]; + await query( + `INSERT INTO cascading_auto_fill_mapping ( + group_code, company_code, source_column, target_field, target_label, + is_editable, is_required, default_value, sort_order + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + groupCode, + existing.company_code, + m.sourceColumn, + m.targetField, + m.targetLabel || null, + m.isEditable || "Y", + m.isRequired || "N", + m.defaultValue || null, + m.sortOrder || i + 1, + ] + ); + } + } + + logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId }); + + res.json({ + success: true, + message: "자동 입력 그룹이 수정되었습니다.", + data: updateResult, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 그룹 삭제 + */ +export const deleteAutoFillGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`; + const deleteParams: any[] = [groupCode]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING group_code`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId }); + + res.json({ + success: true, + message: "자동 입력 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("자동 입력 그룹 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 자동 입력 데이터 조회 (실제 사용) +// ===================================================== + +/** + * 마스터 옵션 목록 조회 + * 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록 + */ +export const getAutoFillMasterOptions = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const group = await queryOne(groupSql, groupParams); + + if (!group) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 마스터 테이블에서 옵션 조회 + const labelColumn = group.master_label_column || group.master_value_column; + let optionsSql = ` + SELECT + ${group.master_value_column} as value, + ${labelColumn} as label + FROM ${group.master_table} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터 (테이블에 company_code가 있는 경우) + if (companyCode !== "*") { + // company_code 컬럼 존재 여부 확인 + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [group.master_table] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${paramIndex++}`; + optionsParams.push(companyCode); + } + } + + optionsSql += ` ORDER BY ${labelColumn}`; + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length }); + + res.json({ + success: true, + data: optionsResult, + }); + } catch (error: any) { + logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 마스터 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 데이터 조회 + * 마스터 값 선택 시 자동으로 입력할 데이터 조회 + */ +export const getAutoFillData = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const { masterValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + if (!masterValue) { + return res.status(400).json({ + success: false, + message: "masterValue 파라미터가 필요합니다.", + }); + } + + // 그룹 정보 조회 + let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const group = await queryOne(groupSql, groupParams); + + if (!group) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingSql = ` + SELECT * FROM cascading_auto_fill_mapping + WHERE group_code = $1 AND company_code = $2 + ORDER BY sort_order + `; + const mappings = await query(mappingSql, [groupCode, group.company_code]); + + if (mappings.length === 0) { + return res.json({ + success: true, + data: {}, + mappings: [], + }); + } + + // 마스터 테이블에서 데이터 조회 + const sourceColumns = mappings.map((m: any) => m.source_column).join(", "); + let dataSql = ` + SELECT ${sourceColumns} + FROM ${group.master_table} + WHERE ${group.master_value_column} = $1 + `; + const dataParams: any[] = [masterValue]; + let paramIndex = 2; + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [group.master_table] + ); + + if (columnCheck) { + dataSql += ` AND company_code = $${paramIndex++}`; + dataParams.push(companyCode); + } + } + + const dataResult = await queryOne(dataSql, dataParams); + + // 결과를 target_field 기준으로 변환 + const autoFillData: Record = {}; + const mappingInfo: any[] = []; + + for (const mapping of mappings) { + const sourceValue = dataResult?.[mapping.source_column]; + const finalValue = sourceValue !== null && sourceValue !== undefined + ? sourceValue + : mapping.default_value; + + autoFillData[mapping.target_field] = finalValue; + mappingInfo.push({ + targetField: mapping.target_field, + targetLabel: mapping.target_label, + value: finalValue, + isEditable: mapping.is_editable === "Y", + isRequired: mapping.is_required === "Y", + }); + } + + logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length }); + + res.json({ + success: true, + data: autoFillData, + mappings: mappingInfo, + }); + } catch (error: any) { + logger.error("자동 입력 데이터 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 데이터 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/cascadingConditionController.ts b/backend-node/src/controllers/cascadingConditionController.ts new file mode 100644 index 00000000..cf30a725 --- /dev/null +++ b/backend-node/src/controllers/cascadingConditionController.ts @@ -0,0 +1,525 @@ +/** + * 조건부 연쇄 (Conditional Cascading) 컨트롤러 + * 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 조건부 연쇄 규칙 CRUD +// ===================================================== + +/** + * 조건부 연쇄 규칙 목록 조회 + */ +export const getConditions = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive, relationCode, relationType } = req.query; + + let sql = ` + SELECT * FROM cascading_condition + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + // 회사 필터 + if (companyCode !== "*") { + sql += ` AND company_code = $${paramIndex++}`; + params.push(companyCode); + } + + // 활성 상태 필터 + if (isActive) { + sql += ` AND is_active = $${paramIndex++}`; + params.push(isActive); + } + + // 관계 코드 필터 + if (relationCode) { + sql += ` AND relation_code = $${paramIndex++}`; + params.push(relationCode); + } + + // 관계 유형 필터 (RELATION / HIERARCHY) + if (relationType) { + sql += ` AND relation_type = $${paramIndex++}`; + params.push(relationType); + } + + sql += ` ORDER BY relation_code, priority, condition_name`; + + const result = await query(sql, params); + + logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("조건부 연쇄 규칙 목록 조회 실패:", error); + logger.error("조건부 연쇄 규칙 목록 조회 실패", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 상세 조회 + */ +export const getConditionDetail = async (req: Request, res: Response) => { + try { + const { conditionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`; + const params: any[] = [Number(conditionId)]; + + if (companyCode !== "*") { + sql += ` AND company_code = $2`; + params.push(companyCode); + } + + const result = await queryOne(sql, params); + + if (!result) { + return res.status(404).json({ + success: false, + message: "조건부 연쇄 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 생성 + */ +export const createCondition = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { + relationType = "RELATION", + relationCode, + conditionName, + conditionField, + conditionOperator = "EQ", + conditionValue, + filterColumn, + filterValues, + priority = 0, + } = req.body; + + // 필수 필드 검증 + if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)", + }); + } + + const insertSql = ` + INSERT INTO cascading_condition ( + relation_type, relation_code, condition_name, + condition_field, condition_operator, condition_value, + filter_column, filter_values, priority, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await queryOne(insertSql, [ + relationType, + relationCode, + conditionName, + conditionField, + conditionOperator, + conditionValue, + filterColumn, + filterValues, + priority, + companyCode, + ]); + + logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode }); + + res.status(201).json({ + success: true, + message: "조건부 연쇄 규칙이 생성되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 수정 + */ +export const updateCondition = async (req: Request, res: Response) => { + try { + const { conditionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + conditionName, + conditionField, + conditionOperator, + conditionValue, + filterColumn, + filterValues, + priority, + isActive, + } = req.body; + + // 기존 규칙 확인 + let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`; + const checkParams: any[] = [Number(conditionId)]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "조건부 연쇄 규칙을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_condition SET + condition_name = COALESCE($1, condition_name), + condition_field = COALESCE($2, condition_field), + condition_operator = COALESCE($3, condition_operator), + condition_value = COALESCE($4, condition_value), + filter_column = COALESCE($5, filter_column), + filter_values = COALESCE($6, filter_values), + priority = COALESCE($7, priority), + is_active = COALESCE($8, is_active), + updated_date = CURRENT_TIMESTAMP + WHERE condition_id = $9 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + conditionName, + conditionField, + conditionOperator, + conditionValue, + filterColumn, + filterValues, + priority, + isActive, + Number(conditionId), + ]); + + logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode }); + + res.json({ + success: true, + message: "조건부 연쇄 규칙이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 삭제 + */ +export const deleteCondition = async (req: Request, res: Response) => { + try { + const { conditionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`; + const deleteParams: any[] = [Number(conditionId)]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING condition_id`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "조건부 연쇄 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode }); + + res.json({ + success: true, + message: "조건부 연쇄 규칙이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 조건부 필터링 적용 API (실제 사용) +// ===================================================== + +/** + * 조건에 따른 필터링된 옵션 조회 + * 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환 + */ +export const getFilteredOptions = async (req: Request, res: Response) => { + try { + const { relationCode } = req.params; + const { conditionFieldValue, parentValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 1. 기본 연쇄 관계 정보 조회 + let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`; + const relationParams: any[] = [relationCode]; + + if (companyCode !== "*") { + relationSql += ` AND company_code = $2`; + relationParams.push(companyCode); + } + + const relation = await queryOne(relationSql, relationParams); + + if (!relation) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + // 2. 해당 관계에 적용되는 조건 규칙 조회 + let conditionSql = ` + SELECT * FROM cascading_condition + WHERE relation_code = $1 AND is_active = 'Y' + `; + const conditionParams: any[] = [relationCode]; + let conditionParamIndex = 2; + + if (companyCode !== "*") { + conditionSql += ` AND company_code = $${conditionParamIndex++}`; + conditionParams.push(companyCode); + } + + conditionSql += ` ORDER BY priority DESC`; + + const conditions = await query(conditionSql, conditionParams); + + // 3. 조건에 맞는 규칙 찾기 + let matchedCondition: any = null; + + if (conditionFieldValue) { + for (const cond of conditions) { + const isMatch = evaluateCondition( + conditionFieldValue as string, + cond.condition_operator, + cond.condition_value + ); + + if (isMatch) { + matchedCondition = cond; + break; // 우선순위가 높은 첫 번째 매칭 규칙 사용 + } + } + } + + // 4. 옵션 조회 쿼리 생성 + let optionsSql = ` + SELECT + ${relation.child_value_column} as value, + ${relation.child_label_column} as label + FROM ${relation.child_table} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let optionsParamIndex = 1; + + // 부모 값 필터 (기본 연쇄) + if (parentValue) { + optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`; + optionsParams.push(parentValue); + } + + // 조건부 필터 적용 + if (matchedCondition) { + const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim()); + const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(","); + optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`; + optionsParams.push(...filterValues); + optionsParamIndex += filterValues.length; + } + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [relation.child_table] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${optionsParamIndex++}`; + optionsParams.push(companyCode); + } + } + + // 정렬 + if (relation.child_order_column) { + optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`; + } else { + optionsSql += ` ORDER BY ${relation.child_label_column}`; + } + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("조건부 필터링 옵션 조회", { + relationCode, + conditionFieldValue, + parentValue, + matchedCondition: matchedCondition?.condition_name, + optionCount: optionsResult.length, + }); + + res.json({ + success: true, + data: optionsResult, + appliedCondition: matchedCondition + ? { + conditionId: matchedCondition.condition_id, + conditionName: matchedCondition.condition_name, + } + : null, + }); + } catch (error: any) { + logger.error("조건부 필터링 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 필터링 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건 평가 함수 + */ +function evaluateCondition( + actualValue: string, + operator: string, + expectedValue: string +): boolean { + const actual = actualValue.toLowerCase().trim(); + const expected = expectedValue.toLowerCase().trim(); + + switch (operator.toUpperCase()) { + case "EQ": + case "=": + case "EQUALS": + return actual === expected; + + case "NEQ": + case "!=": + case "<>": + case "NOT_EQUALS": + return actual !== expected; + + case "CONTAINS": + case "LIKE": + return actual.includes(expected); + + case "NOT_CONTAINS": + case "NOT_LIKE": + return !actual.includes(expected); + + case "STARTS_WITH": + return actual.startsWith(expected); + + case "ENDS_WITH": + return actual.endsWith(expected); + + case "IN": + const inValues = expected.split(",").map((v) => v.trim()); + return inValues.includes(actual); + + case "NOT_IN": + const notInValues = expected.split(",").map((v) => v.trim()); + return !notInValues.includes(actual); + + case "GT": + case ">": + return parseFloat(actual) > parseFloat(expected); + + case "GTE": + case ">=": + return parseFloat(actual) >= parseFloat(expected); + + case "LT": + case "<": + return parseFloat(actual) < parseFloat(expected); + + case "LTE": + case "<=": + return parseFloat(actual) <= parseFloat(expected); + + case "IS_NULL": + case "NULL": + return actual === "" || actual === "null" || actual === "undefined"; + + case "IS_NOT_NULL": + case "NOT_NULL": + return actual !== "" && actual !== "null" && actual !== "undefined"; + + default: + logger.warn(`알 수 없는 연산자: ${operator}`); + return false; + } +} + diff --git a/backend-node/src/controllers/cascadingHierarchyController.ts b/backend-node/src/controllers/cascadingHierarchyController.ts new file mode 100644 index 00000000..59d243e2 --- /dev/null +++ b/backend-node/src/controllers/cascadingHierarchyController.ts @@ -0,0 +1,752 @@ +/** + * 다단계 계층 (Hierarchy) 컨트롤러 + * 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 계층 그룹 CRUD +// ===================================================== + +/** + * 계층 그룹 목록 조회 + */ +export const getHierarchyGroups = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive, hierarchyType } = req.query; + + let sql = ` + SELECT g.*, + (SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count + FROM cascading_hierarchy_group g + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + sql += ` AND g.company_code = $${paramIndex++}`; + params.push(companyCode); + } + + if (isActive) { + sql += ` AND g.is_active = $${paramIndex++}`; + params.push(isActive); + } + + if (hierarchyType) { + sql += ` AND g.hierarchy_type = $${paramIndex++}`; + params.push(hierarchyType); + } + + sql += ` ORDER BY g.group_name`; + + const result = await query(sql, params); + + logger.info("계층 그룹 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("계층 그룹 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 상세 조회 (레벨 포함) + */ +export const getHierarchyGroupDetail = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 조회 + let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const group = await queryOne(groupSql, groupParams); + + if (!group) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + // 레벨 조회 + let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`; + const levelParams: any[] = [groupCode]; + + if (companyCode !== "*") { + levelSql += ` AND company_code = $2`; + levelParams.push(companyCode); + } + + levelSql += ` ORDER BY level_order`; + + const levels = await query(levelSql, levelParams); + + logger.info("계층 그룹 상세 조회", { groupCode, companyCode }); + + res.json({ + success: true, + data: { + ...group, + levels: levels, + }, + }); + } catch (error: any) { + logger.error("계층 그룹 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 코드 자동 생성 함수 + */ +const generateHierarchyGroupCode = async (companyCode: string): Promise => { + const prefix = "HG"; + const result = await queryOne( + `SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`, + [companyCode] + ); + const count = parseInt(result?.cnt || "0", 10) + 1; + const timestamp = Date.now().toString(36).toUpperCase().slice(-4); + return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`; +}; + +/** + * 계층 그룹 생성 + */ +export const createHierarchyGroup = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + hierarchyType = "MULTI_TABLE", + maxLevels, + isFixedLevels = "Y", + // Self-reference 설정 + selfRefTable, + selfRefIdColumn, + selfRefParentColumn, + selfRefValueColumn, + selfRefLabelColumn, + selfRefLevelColumn, + selfRefOrderColumn, + // BOM 설정 + bomTable, + bomParentColumn, + bomChildColumn, + bomItemTable, + bomItemIdColumn, + bomItemLabelColumn, + bomQtyColumn, + bomLevelColumn, + // 메시지 + emptyMessage, + noOptionsMessage, + loadingMessage, + // 레벨 (MULTI_TABLE 타입인 경우) + levels = [], + } = req.body; + + // 필수 필드 검증 + if (!groupName || !hierarchyType) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)", + }); + } + + // 그룹 코드 자동 생성 + const groupCode = await generateHierarchyGroupCode(companyCode); + + // 그룹 생성 + const insertGroupSql = ` + INSERT INTO cascading_hierarchy_group ( + group_code, group_name, description, hierarchy_type, + max_levels, is_fixed_levels, + self_ref_table, self_ref_id_column, self_ref_parent_column, + self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column, + bom_table, bom_parent_column, bom_child_column, + bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column, + empty_message, no_options_message, loading_message, + company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP) + RETURNING * + `; + + const group = await queryOne(insertGroupSql, [ + groupCode, + groupName, + description || null, + hierarchyType, + maxLevels || null, + isFixedLevels, + selfRefTable || null, + selfRefIdColumn || null, + selfRefParentColumn || null, + selfRefValueColumn || null, + selfRefLabelColumn || null, + selfRefLevelColumn || null, + selfRefOrderColumn || null, + bomTable || null, + bomParentColumn || null, + bomChildColumn || null, + bomItemTable || null, + bomItemIdColumn || null, + bomItemLabelColumn || null, + bomQtyColumn || null, + bomLevelColumn || null, + emptyMessage || "선택해주세요", + noOptionsMessage || "옵션이 없습니다", + loadingMessage || "로딩 중...", + companyCode, + userId, + ]); + + // 레벨 생성 (MULTI_TABLE 타입인 경우) + if (hierarchyType === "MULTI_TABLE" && levels.length > 0) { + for (const level of levels) { + await query( + `INSERT INTO cascading_hierarchy_level ( + group_code, company_code, level_order, level_name, level_code, + table_name, value_column, label_column, parent_key_column, + filter_column, filter_value, order_column, order_direction, + placeholder, is_required, is_searchable, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`, + [ + groupCode, + companyCode, + level.levelOrder, + level.levelName, + level.levelCode || null, + level.tableName, + level.valueColumn, + level.labelColumn, + level.parentKeyColumn || null, + level.filterColumn || null, + level.filterValue || null, + level.orderColumn || null, + level.orderDirection || "ASC", + level.placeholder || `${level.levelName} 선택`, + level.isRequired || "Y", + level.isSearchable || "N", + ] + ); + } + } + + logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode }); + + res.status(201).json({ + success: true, + message: "계층 그룹이 생성되었습니다.", + data: group, + }); + } catch (error: any) { + logger.error("계층 그룹 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 수정 + */ +export const updateHierarchyGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + maxLevels, + isFixedLevels, + emptyMessage, + noOptionsMessage, + loadingMessage, + isActive, + } = req.body; + + // 기존 그룹 확인 + let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`; + const checkParams: any[] = [groupCode]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_hierarchy_group SET + group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + max_levels = COALESCE($3, max_levels), + is_fixed_levels = COALESCE($4, is_fixed_levels), + empty_message = COALESCE($5, empty_message), + no_options_message = COALESCE($6, no_options_message), + loading_message = COALESCE($7, loading_message), + is_active = COALESCE($8, is_active), + updated_by = $9, + updated_date = CURRENT_TIMESTAMP + WHERE group_code = $10 AND company_code = $11 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + groupName, + description, + maxLevels, + isFixedLevels, + emptyMessage, + noOptionsMessage, + loadingMessage, + isActive, + userId, + groupCode, + existing.company_code, + ]); + + logger.info("계층 그룹 수정", { groupCode, companyCode }); + + res.json({ + success: true, + message: "계층 그룹이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("계층 그룹 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 삭제 + */ +export const deleteHierarchyGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 레벨 먼저 삭제 + let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`; + const levelParams: any[] = [groupCode]; + + if (companyCode !== "*") { + deleteLevelsSql += ` AND company_code = $2`; + levelParams.push(companyCode); + } + + await query(deleteLevelsSql, levelParams); + + // 그룹 삭제 + let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + deleteGroupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + deleteGroupSql += ` RETURNING group_code`; + + const result = await queryOne(deleteGroupSql, groupParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + logger.info("계층 그룹 삭제", { groupCode, companyCode }); + + res.json({ + success: true, + message: "계층 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("계층 그룹 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 계층 레벨 관리 +// ===================================================== + +/** + * 레벨 추가 + */ +export const addLevel = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + levelOrder, + levelName, + levelCode, + tableName, + valueColumn, + labelColumn, + parentKeyColumn, + filterColumn, + filterValue, + orderColumn, + orderDirection = "ASC", + placeholder, + isRequired = "Y", + isSearchable = "N", + } = req.body; + + // 그룹 존재 확인 + const groupCheck = await queryOne( + `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`, + [groupCode, companyCode] + ); + + if (!groupCheck) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + const insertSql = ` + INSERT INTO cascading_hierarchy_level ( + group_code, company_code, level_order, level_name, level_code, + table_name, value_column, label_column, parent_key_column, + filter_column, filter_value, order_column, order_direction, + placeholder, is_required, is_searchable, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await queryOne(insertSql, [ + groupCode, + groupCheck.company_code, + levelOrder, + levelName, + levelCode || null, + tableName, + valueColumn, + labelColumn, + parentKeyColumn || null, + filterColumn || null, + filterValue || null, + orderColumn || null, + orderDirection, + placeholder || `${levelName} 선택`, + isRequired, + isSearchable, + ]); + + logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName }); + + res.status(201).json({ + success: true, + message: "레벨이 추가되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("계층 레벨 추가 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "레벨 추가에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 레벨 수정 + */ +export const updateLevel = async (req: Request, res: Response) => { + try { + const { levelId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + levelName, + tableName, + valueColumn, + labelColumn, + parentKeyColumn, + filterColumn, + filterValue, + orderColumn, + orderDirection, + placeholder, + isRequired, + isSearchable, + isActive, + } = req.body; + + let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`; + const checkParams: any[] = [Number(levelId)]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "레벨을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_hierarchy_level SET + level_name = COALESCE($1, level_name), + table_name = COALESCE($2, table_name), + value_column = COALESCE($3, value_column), + label_column = COALESCE($4, label_column), + parent_key_column = COALESCE($5, parent_key_column), + filter_column = COALESCE($6, filter_column), + filter_value = COALESCE($7, filter_value), + order_column = COALESCE($8, order_column), + order_direction = COALESCE($9, order_direction), + placeholder = COALESCE($10, placeholder), + is_required = COALESCE($11, is_required), + is_searchable = COALESCE($12, is_searchable), + is_active = COALESCE($13, is_active), + updated_date = CURRENT_TIMESTAMP + WHERE level_id = $14 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + levelName, + tableName, + valueColumn, + labelColumn, + parentKeyColumn, + filterColumn, + filterValue, + orderColumn, + orderDirection, + placeholder, + isRequired, + isSearchable, + isActive, + Number(levelId), + ]); + + logger.info("계층 레벨 수정", { levelId }); + + res.json({ + success: true, + message: "레벨이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("계층 레벨 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "레벨 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 레벨 삭제 + */ +export const deleteLevel = async (req: Request, res: Response) => { + try { + const { levelId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`; + const deleteParams: any[] = [Number(levelId)]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING level_id`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "레벨을 찾을 수 없습니다.", + }); + } + + logger.info("계층 레벨 삭제", { levelId }); + + res.json({ + success: true, + message: "레벨이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("계층 레벨 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "레벨 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 계층 옵션 조회 API (실제 사용) +// ===================================================== + +/** + * 특정 레벨의 옵션 조회 + */ +export const getLevelOptions = async (req: Request, res: Response) => { + try { + const { groupCode, levelOrder } = req.params; + const { parentValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 레벨 정보 조회 + let levelSql = ` + SELECT l.*, g.hierarchy_type + FROM cascading_hierarchy_level l + JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code + WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y' + `; + const levelParams: any[] = [groupCode, Number(levelOrder)]; + + if (companyCode !== "*") { + levelSql += ` AND l.company_code = $3`; + levelParams.push(companyCode); + } + + const level = await queryOne(levelSql, levelParams); + + if (!level) { + return res.status(404).json({ + success: false, + message: "레벨을 찾을 수 없습니다.", + }); + } + + // 옵션 조회 + let optionsSql = ` + SELECT + ${level.value_column} as value, + ${level.label_column} as label + FROM ${level.table_name} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let optionsParamIndex = 1; + + // 부모 값 필터 (레벨 2 이상) + if (level.parent_key_column && parentValue) { + optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`; + optionsParams.push(parentValue); + } + + // 고정 필터 + if (level.filter_column && level.filter_value) { + optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`; + optionsParams.push(level.filter_value); + } + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [level.table_name] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${optionsParamIndex++}`; + optionsParams.push(companyCode); + } + } + + // 정렬 + if (level.order_column) { + optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`; + } else { + optionsSql += ` ORDER BY ${level.label_column}`; + } + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("계층 레벨 옵션 조회", { + groupCode, + levelOrder, + parentValue, + optionCount: optionsResult.length, + }); + + res.json({ + success: true, + data: optionsResult, + levelInfo: { + levelId: level.level_id, + levelName: level.level_name, + placeholder: level.placeholder, + isRequired: level.is_required, + isSearchable: level.is_searchable, + }, + }); + } catch (error: any) { + logger.error("계층 레벨 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/cascadingMutualExclusionController.ts b/backend-node/src/controllers/cascadingMutualExclusionController.ts new file mode 100644 index 00000000..8714c73b --- /dev/null +++ b/backend-node/src/controllers/cascadingMutualExclusionController.ts @@ -0,0 +1,505 @@ +/** + * 상호 배제 (Mutual Exclusion) 컨트롤러 + * 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 상호 배제 규칙 CRUD +// ===================================================== + +/** + * 상호 배제 규칙 목록 조회 + */ +export const getExclusions = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let sql = ` + SELECT * FROM cascading_mutual_exclusion + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + // 회사 필터 + if (companyCode !== "*") { + sql += ` AND company_code = $${paramIndex++}`; + params.push(companyCode); + } + + // 활성 상태 필터 + if (isActive) { + sql += ` AND is_active = $${paramIndex++}`; + params.push(isActive); + } + + sql += ` ORDER BY exclusion_name`; + + const result = await query(sql, params); + + logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 상호 배제 규칙 상세 조회 + */ +export const getExclusionDetail = async (req: Request, res: Response) => { + try { + const { exclusionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`; + const params: any[] = [Number(exclusionId)]; + + if (companyCode !== "*") { + sql += ` AND company_code = $2`; + params.push(companyCode); + } + + const result = await queryOne(sql, params); + + if (!result) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 배제 코드 자동 생성 함수 + */ +const generateExclusionCode = async (companyCode: string): Promise => { + const prefix = "EX"; + const result = await queryOne( + `SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`, + [companyCode] + ); + const count = parseInt(result?.cnt || "0", 10) + 1; + const timestamp = Date.now().toString(36).toUpperCase().slice(-4); + return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`; +}; + +/** + * 상호 배제 규칙 생성 + */ +export const createExclusion = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { + exclusionName, + fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse") + sourceTable, + valueColumn, + labelColumn, + exclusionType = "SAME_VALUE", + errorMessage = "동일한 값을 선택할 수 없습니다", + } = req.body; + + // 필수 필드 검증 + if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)", + }); + } + + // 배제 코드 자동 생성 + const exclusionCode = await generateExclusionCode(companyCode); + + // 중복 체크 (생략 - 자동 생성이므로 중복 불가) + const existingCheck = await queryOne( + `SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`, + [exclusionCode, companyCode] + ); + + if (existingCheck) { + return res.status(409).json({ + success: false, + message: "이미 존재하는 배제 코드입니다.", + }); + } + + const insertSql = ` + INSERT INTO cascading_mutual_exclusion ( + exclusion_code, exclusion_name, field_names, + source_table, value_column, label_column, + exclusion_type, error_message, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await queryOne(insertSql, [ + exclusionCode, + exclusionName, + fieldNames, + sourceTable, + valueColumn, + labelColumn || null, + exclusionType, + errorMessage, + companyCode, + ]); + + logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode }); + + res.status(201).json({ + success: true, + message: "상호 배제 규칙이 생성되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 상호 배제 규칙 수정 + */ +export const updateExclusion = async (req: Request, res: Response) => { + try { + const { exclusionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + exclusionName, + fieldNames, + sourceTable, + valueColumn, + labelColumn, + exclusionType, + errorMessage, + isActive, + } = req.body; + + // 기존 규칙 확인 + let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`; + const checkParams: any[] = [Number(exclusionId)]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_mutual_exclusion SET + exclusion_name = COALESCE($1, exclusion_name), + field_names = COALESCE($2, field_names), + source_table = COALESCE($3, source_table), + value_column = COALESCE($4, value_column), + label_column = COALESCE($5, label_column), + exclusion_type = COALESCE($6, exclusion_type), + error_message = COALESCE($7, error_message), + is_active = COALESCE($8, is_active) + WHERE exclusion_id = $9 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + exclusionName, + fieldNames, + sourceTable, + valueColumn, + labelColumn, + exclusionType, + errorMessage, + isActive, + Number(exclusionId), + ]); + + logger.info("상호 배제 규칙 수정", { exclusionId, companyCode }); + + res.json({ + success: true, + message: "상호 배제 규칙이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 상호 배제 규칙 삭제 + */ +export const deleteExclusion = async (req: Request, res: Response) => { + try { + const { exclusionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`; + const deleteParams: any[] = [Number(exclusionId)]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING exclusion_id`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode }); + + res.json({ + success: true, + message: "상호 배제 규칙이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("상호 배제 규칙 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 상호 배제 검증 API (실제 사용) +// ===================================================== + +/** + * 상호 배제 검증 + * 선택하려는 값이 다른 필드와 충돌하는지 확인 + */ +export const validateExclusion = async (req: Request, res: Response) => { + try { + const { exclusionCode } = req.params; + const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" } + const companyCode = req.user?.companyCode || "*"; + + // 배제 규칙 조회 + let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`; + const exclusionParams: any[] = [exclusionCode]; + + if (companyCode !== "*") { + exclusionSql += ` AND company_code = $2`; + exclusionParams.push(companyCode); + } + + const exclusion = await queryOne(exclusionSql, exclusionParams); + + if (!exclusion) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + // 필드명 파싱 + const fields = exclusion.field_names.split(",").map((f: string) => f.trim()); + + // 필드 값 수집 + const values: string[] = []; + for (const field of fields) { + if (fieldValues[field]) { + values.push(fieldValues[field]); + } + } + + // 상호 배제 검증 + let isValid = true; + let errorMessage = null; + let conflictingFields: string[] = []; + + if (exclusion.exclusion_type === "SAME_VALUE") { + // 같은 값이 있는지 확인 + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + isValid = false; + errorMessage = exclusion.error_message; + + // 충돌하는 필드 찾기 + const valueCounts: Record = {}; + for (const field of fields) { + const val = fieldValues[field]; + if (val) { + if (!valueCounts[val]) { + valueCounts[val] = []; + } + valueCounts[val].push(field); + } + } + + for (const [, fieldList] of Object.entries(valueCounts)) { + if (fieldList.length > 1) { + conflictingFields = fieldList; + break; + } + } + } + } + + logger.info("상호 배제 검증", { + exclusionCode, + isValid, + fieldValues, + }); + + res.json({ + success: true, + data: { + isValid, + errorMessage: isValid ? null : errorMessage, + conflictingFields, + }, + }); + } catch (error: any) { + logger.error("상호 배제 검증 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 검증에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 필드에 대한 배제 옵션 조회 + * 다른 필드에서 이미 선택한 값을 제외한 옵션 반환 + */ +export const getExcludedOptions = async (req: Request, res: Response) => { + try { + const { exclusionCode } = req.params; + const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분) + const companyCode = req.user?.companyCode || "*"; + + // 배제 규칙 조회 + let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`; + const exclusionParams: any[] = [exclusionCode]; + + if (companyCode !== "*") { + exclusionSql += ` AND company_code = $2`; + exclusionParams.push(companyCode); + } + + const exclusion = await queryOne(exclusionSql, exclusionParams); + + if (!exclusion) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + // 옵션 조회 + const labelColumn = exclusion.label_column || exclusion.value_column; + let optionsSql = ` + SELECT + ${exclusion.value_column} as value, + ${labelColumn} as label + FROM ${exclusion.source_table} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let optionsParamIndex = 1; + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [exclusion.source_table] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${optionsParamIndex++}`; + optionsParams.push(companyCode); + } + } + + // 이미 선택된 값 제외 + if (selectedValues) { + const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v); + if (excludeValues.length > 0) { + const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(","); + optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`; + optionsParams.push(...excludeValues); + } + } + + optionsSql += ` ORDER BY ${labelColumn}`; + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("상호 배제 옵션 조회", { + exclusionCode, + currentField, + excludedCount: (selectedValues as string)?.split(",").length || 0, + optionCount: optionsResult.length, + }); + + res.json({ + success: true, + data: optionsResult, + }); + } catch (error: any) { + logger.error("상호 배제 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts new file mode 100644 index 00000000..5d922dd6 --- /dev/null +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -0,0 +1,52 @@ +/** + * 자동 입력 (Auto-Fill) 라우트 + */ + +import express from "express"; +import { + getAutoFillGroups, + getAutoFillGroupDetail, + createAutoFillGroup, + updateAutoFillGroup, + deleteAutoFillGroup, + getAutoFillMasterOptions, + getAutoFillData, +} from "../controllers/cascadingAutoFillController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 자동 입력 그룹 관리 API +// ===================================================== + +// 그룹 목록 조회 +router.get("/groups", getAutoFillGroups); + +// 그룹 상세 조회 (매핑 포함) +router.get("/groups/:groupCode", getAutoFillGroupDetail); + +// 그룹 생성 +router.post("/groups", createAutoFillGroup); + +// 그룹 수정 +router.put("/groups/:groupCode", updateAutoFillGroup); + +// 그룹 삭제 +router.delete("/groups/:groupCode", deleteAutoFillGroup); + +// ===================================================== +// 자동 입력 데이터 조회 API (실제 사용) +// ===================================================== + +// 마스터 옵션 목록 조회 +router.get("/options/:groupCode", getAutoFillMasterOptions); + +// 자동 입력 데이터 조회 +router.get("/data/:groupCode", getAutoFillData); + +export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts new file mode 100644 index 00000000..813dbff1 --- /dev/null +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -0,0 +1,48 @@ +/** + * 조건부 연쇄 (Conditional Cascading) 라우트 + */ + +import express from "express"; +import { + getConditions, + getConditionDetail, + createCondition, + updateCondition, + deleteCondition, + getFilteredOptions, +} from "../controllers/cascadingConditionController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 조건부 연쇄 규칙 관리 API +// ===================================================== + +// 규칙 목록 조회 +router.get("/", getConditions); + +// 규칙 상세 조회 +router.get("/:conditionId", getConditionDetail); + +// 규칙 생성 +router.post("/", createCondition); + +// 규칙 수정 +router.put("/:conditionId", updateCondition); + +// 규칙 삭제 +router.delete("/:conditionId", deleteCondition); + +// ===================================================== +// 조건부 필터링 적용 API (실제 사용) +// ===================================================== + +// 조건에 따른 필터링된 옵션 조회 +router.get("/filtered-options/:relationCode", getFilteredOptions); + +export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts new file mode 100644 index 00000000..be37da49 --- /dev/null +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -0,0 +1,64 @@ +/** + * 다단계 계층 (Hierarchy) 라우트 + */ + +import express from "express"; +import { + getHierarchyGroups, + getHierarchyGroupDetail, + createHierarchyGroup, + updateHierarchyGroup, + deleteHierarchyGroup, + addLevel, + updateLevel, + deleteLevel, + getLevelOptions, +} from "../controllers/cascadingHierarchyController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 계층 그룹 관리 API +// ===================================================== + +// 그룹 목록 조회 +router.get("/", getHierarchyGroups); + +// 그룹 상세 조회 (레벨 포함) +router.get("/:groupCode", getHierarchyGroupDetail); + +// 그룹 생성 +router.post("/", createHierarchyGroup); + +// 그룹 수정 +router.put("/:groupCode", updateHierarchyGroup); + +// 그룹 삭제 +router.delete("/:groupCode", deleteHierarchyGroup); + +// ===================================================== +// 계층 레벨 관리 API +// ===================================================== + +// 레벨 추가 +router.post("/:groupCode/levels", addLevel); + +// 레벨 수정 +router.put("/levels/:levelId", updateLevel); + +// 레벨 삭제 +router.delete("/levels/:levelId", deleteLevel); + +// ===================================================== +// 계층 옵션 조회 API (실제 사용) +// ===================================================== + +// 특정 레벨의 옵션 조회 +router.get("/:groupCode/options/:levelOrder", getLevelOptions); + +export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts new file mode 100644 index 00000000..46bbf427 --- /dev/null +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -0,0 +1,52 @@ +/** + * 상호 배제 (Mutual Exclusion) 라우트 + */ + +import express from "express"; +import { + getExclusions, + getExclusionDetail, + createExclusion, + updateExclusion, + deleteExclusion, + validateExclusion, + getExcludedOptions, +} from "../controllers/cascadingMutualExclusionController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 상호 배제 규칙 관리 API +// ===================================================== + +// 규칙 목록 조회 +router.get("/", getExclusions); + +// 규칙 상세 조회 +router.get("/:exclusionId", getExclusionDetail); + +// 규칙 생성 +router.post("/", createExclusion); + +// 규칙 수정 +router.put("/:exclusionId", updateExclusion); + +// 규칙 삭제 +router.delete("/:exclusionId", deleteExclusion); + +// ===================================================== +// 상호 배제 검증 및 옵션 API (실제 사용) +// ===================================================== + +// 상호 배제 검증 +router.post("/validate/:exclusionCode", validateExclusion); + +// 배제된 옵션 조회 +router.get("/options/:exclusionCode", getExcludedOptions); + +export default router; + diff --git a/docs/레벨기반_연쇄드롭다운_설계.md b/docs/레벨기반_연쇄드롭다운_설계.md new file mode 100644 index 00000000..f19a0d60 --- /dev/null +++ b/docs/레벨기반_연쇄드롭다운_설계.md @@ -0,0 +1,699 @@ +# 레벨 기반 연쇄 드롭다운 시스템 설계 + +## 1. 개요 + +### 1.1 목적 +다양한 계층 구조를 지원하는 범용 연쇄 드롭다운 시스템 구축 + +### 1.2 지원하는 계층 유형 + +| 유형 | 설명 | 예시 | +|------|------|------| +| **MULTI_TABLE** | 각 레벨이 다른 테이블 | 국가 → 시/도 → 구/군 → 동 | +| **SELF_REFERENCE** | 같은 테이블 내 자기참조 | 대분류 → 중분류 → 소분류 | +| **BOM** | BOM 구조 (수량 등 속성 포함) | 제품 → 어셈블리 → 부품 | +| **TREE** | 무한 깊이 트리 | 조직도, 메뉴 구조 | + +--- + +## 2. 데이터베이스 설계 + +### 2.1 테이블 구조 + +``` +┌─────────────────────────────────────┐ +│ cascading_hierarchy_group │ ← 계층 그룹 정의 +├─────────────────────────────────────┤ +│ group_code (PK) │ +│ group_name │ +│ hierarchy_type │ ← MULTI_TABLE/SELF_REFERENCE/BOM/TREE +│ max_levels │ +│ is_fixed_levels │ +│ self_ref_* (자기참조 설정) │ +│ bom_* (BOM 설정) │ +│ company_code │ +└─────────────────────────────────────┘ + │ + │ 1:N (MULTI_TABLE 유형만) + ▼ +┌─────────────────────────────────────┐ +│ cascading_hierarchy_level │ ← 레벨별 테이블/컬럼 정의 +├─────────────────────────────────────┤ +│ group_code (FK) │ +│ level_order │ ← 1, 2, 3... +│ level_name │ +│ table_name │ +│ value_column │ +│ label_column │ +│ parent_key_column │ ← 부모 테이블 참조 컬럼 +│ company_code │ +└─────────────────────────────────────┘ +``` + +### 2.2 기존 시스템과의 관계 + +``` +┌─────────────────────────────────────┐ +│ cascading_relation │ ← 기존 2단계 관계 (유지) +│ (2단계 전용) │ +└─────────────────────────────────────┘ + │ + │ 호환성 뷰 + ▼ +┌─────────────────────────────────────┐ +│ v_cascading_as_hierarchy │ ← 기존 관계를 계층 형태로 변환 +└─────────────────────────────────────┘ +``` + +--- + +## 3. 계층 유형별 상세 설계 + +### 3.1 MULTI_TABLE (다중 테이블 계층) + +**사용 사례**: 국가 → 시/도 → 구/군 → 동 + +**테이블 구조**: +``` +country_info province_info city_info district_info +├─ country_code (PK) ├─ province_code (PK) ├─ city_code (PK) ├─ district_code (PK) +├─ country_name ├─ province_name ├─ city_name ├─ district_name + ├─ country_code (FK) ├─ province_code (FK) ├─ city_code (FK) +``` + +**설정 예시**: +```sql +-- 그룹 정의 +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, company_code +) VALUES ( + 'REGION_HIERARCHY', '지역 계층', 'MULTI_TABLE', 4, 'EMAX' +); + +-- 레벨 정의 +INSERT INTO cascading_hierarchy_level VALUES +(1, 'REGION_HIERARCHY', 'EMAX', 1, '국가', 'country_info', 'country_code', 'country_name', NULL), +(2, 'REGION_HIERARCHY', 'EMAX', 2, '시/도', 'province_info', 'province_code', 'province_name', 'country_code'), +(3, 'REGION_HIERARCHY', 'EMAX', 3, '구/군', 'city_info', 'city_code', 'city_name', 'province_code'), +(4, 'REGION_HIERARCHY', 'EMAX', 4, '동', 'district_info', 'district_code', 'district_name', 'city_code'); +``` + +**API 호출 흐름**: +``` +1. 레벨 1 (국가): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/1 + → [{ value: 'KR', label: '대한민국' }, { value: 'US', label: '미국' }] + +2. 레벨 2 (시/도): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/2?parentValue=KR + → [{ value: 'SEOUL', label: '서울특별시' }, { value: 'BUSAN', label: '부산광역시' }] + +3. 레벨 3 (구/군): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/3?parentValue=SEOUL + → [{ value: 'GANGNAM', label: '강남구' }, { value: 'SEOCHO', label: '서초구' }] +``` + +--- + +### 3.2 SELF_REFERENCE (자기참조 계층) + +**사용 사례**: 제품 카테고리 (대분류 → 중분류 → 소분류) + +**테이블 구조** (code_info 활용): +``` +code_info +├─ code_category = 'PRODUCT_CATEGORY' +├─ code_value (PK) = 'ELEC', 'ELEC_TV', 'ELEC_TV_LED' +├─ code_name = '전자제품', 'TV', 'LED TV' +├─ parent_code = NULL, 'ELEC', 'ELEC_TV' ← 자기참조 +├─ level = 1, 2, 3 +├─ sort_order +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, + self_ref_table, self_ref_id_column, self_ref_parent_column, + self_ref_value_column, self_ref_label_column, self_ref_level_column, + self_ref_filter_column, self_ref_filter_value, + company_code +) VALUES ( + 'PRODUCT_CATEGORY', '제품 카테고리', 'SELF_REFERENCE', 3, + 'code_info', 'code_value', 'parent_code', + 'code_value', 'code_name', 'level', + 'code_category', 'PRODUCT_CATEGORY', + 'EMAX' +); +``` + +**API 호출 흐름**: +``` +1. 레벨 1 (대분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/1 + → WHERE parent_code IS NULL AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC', label: '전자제품' }, { value: 'FURN', label: '가구' }] + +2. 레벨 2 (중분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/2?parentValue=ELEC + → WHERE parent_code = 'ELEC' AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC_TV', label: 'TV' }, { value: 'ELEC_REF', label: '냉장고' }] + +3. 레벨 3 (소분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/3?parentValue=ELEC_TV + → WHERE parent_code = 'ELEC_TV' AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC_TV_LED', label: 'LED TV' }, { value: 'ELEC_TV_OLED', label: 'OLED TV' }] +``` + +--- + +### 3.3 BOM (Bill of Materials) + +**사용 사례**: 제품 BOM 구조 + +**테이블 구조**: +``` +klbom_tbl (BOM 관계) item_info (품목 마스터) +├─ id (자식 품목) ├─ item_code (PK) +├─ pid (부모 품목) ├─ item_name +├─ qty (수량) ├─ item_spec +├─ aylevel (레벨) ├─ unit +├─ bom_report_objid +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, is_fixed_levels, + bom_table, bom_parent_column, bom_child_column, + bom_item_table, bom_item_id_column, bom_item_label_column, + bom_qty_column, bom_level_column, + company_code +) VALUES ( + 'PRODUCT_BOM', '제품 BOM', 'BOM', NULL, 'N', + 'klbom_tbl', 'pid', 'id', + 'item_info', 'item_code', 'item_name', + 'qty', 'aylevel', + 'EMAX' +); +``` + +**API 호출 흐름**: +``` +1. 루트 품목 (레벨 1): GET /api/cascading-hierarchy/bom/PRODUCT_BOM/roots + → WHERE pid IS NULL OR pid = '' + → [{ value: 'PROD001', label: '완제품 A', level: 1 }] + +2. 하위 품목: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=PROD001 + → WHERE pid = 'PROD001' + → [ + { value: 'ASSY001', label: '어셈블리 A', qty: 1, level: 2 }, + { value: 'ASSY002', label: '어셈블리 B', qty: 2, level: 2 } + ] + +3. 더 하위: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=ASSY001 + → WHERE pid = 'ASSY001' + → [ + { value: 'PART001', label: '부품 A', qty: 4, level: 3 }, + { value: 'PART002', label: '부품 B', qty: 2, level: 3 } + ] +``` + +**BOM 전용 응답 형식**: +```typescript +interface BomOption { + value: string; // 품목 코드 + label: string; // 품목명 + qty: number; // 수량 + level: number; // BOM 레벨 + hasChildren: boolean; // 하위 품목 존재 여부 + spec?: string; // 규격 (선택) + unit?: string; // 단위 (선택) +} +``` + +--- + +### 3.4 TREE (무한 깊이 트리) + +**사용 사례**: 조직도, 메뉴 구조 + +**테이블 구조**: +``` +dept_info +├─ dept_code (PK) +├─ dept_name +├─ parent_dept_code ← 자기참조 (무한 깊이) +├─ sort_order +├─ is_active +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, is_fixed_levels, + self_ref_table, self_ref_id_column, self_ref_parent_column, + self_ref_value_column, self_ref_label_column, self_ref_order_column, + company_code +) VALUES ( + 'ORG_CHART', '조직도', 'TREE', NULL, 'N', + 'dept_info', 'dept_code', 'parent_dept_code', + 'dept_code', 'dept_name', 'sort_order', + 'EMAX' +); +``` + +**API 호출 흐름** (BOM과 유사): +``` +1. 루트 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/roots + → WHERE parent_dept_code IS NULL + → [{ value: 'HQ', label: '본사', hasChildren: true }] + +2. 하위 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/children?parentValue=HQ + → WHERE parent_dept_code = 'HQ' + → [ + { value: 'DIV1', label: '사업부1', hasChildren: true }, + { value: 'DIV2', label: '사업부2', hasChildren: true } + ] +``` + +--- + +## 4. API 설계 + +### 4.1 계층 그룹 관리 API + +``` +GET /api/cascading-hierarchy/groups # 그룹 목록 +POST /api/cascading-hierarchy/groups # 그룹 생성 +GET /api/cascading-hierarchy/groups/:code # 그룹 상세 +PUT /api/cascading-hierarchy/groups/:code # 그룹 수정 +DELETE /api/cascading-hierarchy/groups/:code # 그룹 삭제 +``` + +### 4.2 레벨 관리 API (MULTI_TABLE용) + +``` +GET /api/cascading-hierarchy/groups/:code/levels # 레벨 목록 +POST /api/cascading-hierarchy/groups/:code/levels # 레벨 추가 +PUT /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 수정 +DELETE /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 삭제 +``` + +### 4.3 옵션 조회 API + +``` +# MULTI_TABLE / SELF_REFERENCE +GET /api/cascading-hierarchy/options/:groupCode/:level + ?parentValue=xxx # 부모 값 (레벨 2 이상) + &companyCode=xxx # 회사 코드 (선택) + +# BOM / TREE +GET /api/cascading-hierarchy/tree/:groupCode/roots # 루트 노드 +GET /api/cascading-hierarchy/tree/:groupCode/children # 자식 노드 + ?parentValue=xxx +GET /api/cascading-hierarchy/tree/:groupCode/path # 경로 조회 + ?value=xxx +GET /api/cascading-hierarchy/tree/:groupCode/search # 검색 + ?keyword=xxx +``` + +--- + +## 5. 프론트엔드 컴포넌트 설계 + +### 5.1 CascadingHierarchyDropdown + +```typescript +interface CascadingHierarchyDropdownProps { + groupCode: string; // 계층 그룹 코드 + level: number; // 현재 레벨 (1, 2, 3...) + parentValue?: string; // 부모 값 (레벨 2 이상) + value?: string; // 선택된 값 + onChange: (value: string, option: HierarchyOption) => void; + placeholder?: string; + disabled?: boolean; + required?: boolean; +} + +// 사용 예시 (지역 계층) + + + +``` + +### 5.2 CascadingHierarchyGroup (자동 연결) + +```typescript +interface CascadingHierarchyGroupProps { + groupCode: string; + values: Record; // { 1: 'KR', 2: 'SEOUL', 3: 'GANGNAM' } + onChange: (level: number, value: string) => void; + layout?: 'horizontal' | 'vertical'; +} + +// 사용 예시 + { + setRegionValues(prev => ({ ...prev, [level]: value })); + }} +/> +``` + +### 5.3 BomTreeSelect (BOM 전용) + +```typescript +interface BomTreeSelectProps { + groupCode: string; + value?: string; + onChange: (value: string, path: BomOption[]) => void; + showQty?: boolean; // 수량 표시 + showLevel?: boolean; // 레벨 표시 + maxDepth?: number; // 최대 깊이 제한 +} + +// 사용 예시 + { + setSelectedPart(value); + console.log('선택 경로:', path); // [완제품 → 어셈블리 → 부품] + }} + showQty +/> +``` + +--- + +## 6. 화면관리 시스템 통합 + +### 6.1 컴포넌트 설정 확장 + +```typescript +interface SelectBasicConfig { + // 기존 설정 + cascadingEnabled?: boolean; + cascadingRelationCode?: string; // 기존 2단계 관계 + cascadingRole?: 'parent' | 'child'; + cascadingParentField?: string; + + // 🆕 레벨 기반 계층 설정 + hierarchyEnabled?: boolean; + hierarchyGroupCode?: string; // 계층 그룹 코드 + hierarchyLevel?: number; // 이 컴포넌트의 레벨 + hierarchyParentField?: string; // 부모 레벨 필드명 +} +``` + +### 6.2 설정 UI 확장 + +``` +┌─────────────────────────────────────────┐ +│ 연쇄 드롭다운 설정 │ +├─────────────────────────────────────────┤ +│ ○ 2단계 관계 (기존) │ +│ └─ 관계 선택: [창고-위치 ▼] │ +│ └─ 역할: [부모] [자식] │ +│ │ +│ ● 다단계 계층 (신규) │ +│ └─ 계층 그룹: [지역 계층 ▼] │ +│ └─ 레벨: [2 - 시/도 ▼] │ +│ └─ 부모 필드: [country_code] (자동감지) │ +└─────────────────────────────────────────┘ +``` + +--- + +## 7. 구현 우선순위 + +### Phase 1: 기반 구축 +1. ✅ 기존 2단계 연쇄 드롭다운 완성 +2. 📋 데이터베이스 마이그레이션 (066_create_cascading_hierarchy.sql) +3. 📋 백엔드 API 구현 (계층 그룹 CRUD) + +### Phase 2: MULTI_TABLE 지원 +1. 📋 레벨 관리 API +2. 📋 옵션 조회 API +3. 📋 프론트엔드 컴포넌트 + +### Phase 3: SELF_REFERENCE 지원 +1. 📋 자기참조 쿼리 로직 +2. 📋 code_info 기반 카테고리 계층 + +### Phase 4: BOM/TREE 지원 +1. 📋 BOM 전용 API +2. 📋 트리 컴포넌트 +3. 📋 무한 깊이 지원 + +### Phase 5: 화면관리 통합 +1. 📋 설정 UI 확장 +2. 📋 자동 연결 기능 + +--- + +## 8. 성능 고려사항 + +### 8.1 쿼리 최적화 +- 인덱스: `(group_code, company_code, level_order)` +- 캐싱: 자주 조회되는 옵션 목록 Redis 캐싱 +- Lazy Loading: 하위 레벨은 필요 시에만 로드 + +### 8.2 BOM 재귀 쿼리 +```sql +-- PostgreSQL WITH RECURSIVE 활용 +WITH RECURSIVE bom_tree AS ( + -- 루트 노드 + SELECT id, pid, qty, 1 AS level + FROM klbom_tbl + WHERE pid IS NULL + + UNION ALL + + -- 하위 노드 + SELECT b.id, b.pid, b.qty, t.level + 1 + FROM klbom_tbl b + JOIN bom_tree t ON b.pid = t.id + WHERE t.level < 10 -- 최대 깊이 제한 +) +SELECT * FROM bom_tree; +``` + +### 8.3 트리 최적화 전략 +- Materialized Path: `/HQ/DIV1/DEPT1/TEAM1` +- Nested Set: left/right 값으로 범위 쿼리 +- Closure Table: 별도 관계 테이블 + +--- + +## 9. 추가 연쇄 패턴 + +### 9.1 조건부 연쇄 (Conditional Cascading) + +**사용 사례**: 특정 조건에 따라 다른 옵션 목록 표시 + +``` +입고유형: [구매입고] → 창고: [원자재창고, 부품창고] 만 표시 +입고유형: [생산입고] → 창고: [완제품창고, 반제품창고] 만 표시 +``` + +**테이블**: `cascading_condition` + +```sql +INSERT INTO cascading_condition ( + relation_code, condition_name, + condition_field, condition_operator, condition_value, + filter_column, filter_values, company_code +) VALUES +('WAREHOUSE_LOCATION', '구매입고 창고', + 'inbound_type', 'EQ', 'PURCHASE', + 'warehouse_type', 'RAW_MATERIAL,PARTS', 'EMAX'); +``` + +--- + +### 9.2 다중 부모 연쇄 (Multi-Parent Cascading) + +**사용 사례**: 여러 부모 필드의 조합으로 자식 필터링 + +``` +회사: [A사] + 사업부: [영업부문] → 부서: [영업1팀, 영업2팀] +``` + +**테이블**: `cascading_multi_parent`, `cascading_multi_parent_source` + +```sql +-- 관계 정의 +INSERT INTO cascading_multi_parent ( + relation_code, relation_name, + child_table, child_value_column, child_label_column, company_code +) VALUES ( + 'COMPANY_DIVISION_DEPT', '회사-사업부-부서', + 'dept_info', 'dept_code', 'dept_name', 'EMAX' +); + +-- 부모 소스 정의 +INSERT INTO cascading_multi_parent_source ( + relation_code, company_code, parent_order, parent_name, + parent_table, parent_value_column, child_filter_column +) VALUES +('COMPANY_DIVISION_DEPT', 'EMAX', 1, '회사', 'company_info', 'company_code', 'company_code'), +('COMPANY_DIVISION_DEPT', 'EMAX', 2, '사업부', 'division_info', 'division_code', 'division_code'); +``` + +--- + +### 9.3 자동 입력 그룹 (Auto-Fill Group) + +**사용 사례**: 마스터 선택 시 여러 필드 자동 입력 + +``` +고객사 선택 → 담당자, 연락처, 주소, 결제조건 자동 입력 +``` + +**테이블**: `cascading_auto_fill_group`, `cascading_auto_fill_mapping` + +```sql +-- 그룹 정의 +INSERT INTO cascading_auto_fill_group ( + group_code, group_name, + master_table, master_value_column, master_label_column, company_code +) VALUES ( + 'CUSTOMER_AUTO_FILL', '고객사 정보 자동입력', + 'customer_info', 'customer_code', 'customer_name', 'EMAX' +); + +-- 필드 매핑 +INSERT INTO cascading_auto_fill_mapping ( + group_code, company_code, source_column, target_field, target_label +) VALUES +('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_person', 'contact_name', '담당자'), +('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_phone', 'contact_phone', '연락처'), +('CUSTOMER_AUTO_FILL', 'EMAX', 'address', 'delivery_address', '배송주소'); +``` + +--- + +### 9.4 상호 배제 (Mutual Exclusion) + +**사용 사례**: 같은 값 선택 불가 + +``` +출발 창고: [창고A] → 도착 창고: [창고B, 창고C] (창고A 제외) +``` + +**테이블**: `cascading_mutual_exclusion` + +```sql +INSERT INTO cascading_mutual_exclusion ( + exclusion_code, exclusion_name, field_names, + source_table, value_column, label_column, + error_message, company_code +) VALUES ( + 'WAREHOUSE_TRANSFER', '창고간 이동', + 'from_warehouse_code,to_warehouse_code', + 'warehouse_info', 'warehouse_code', 'warehouse_name', + '출발 창고와 도착 창고는 같을 수 없습니다', + 'EMAX' +); +``` + +--- + +### 9.5 역방향 조회 (Reverse Lookup) + +**사용 사례**: 자식에서 부모 방향으로 조회 + +``` +품목: [부품A] 선택 → 사용처 BOM: [제품X, 제품Y, 제품Z] +``` + +**테이블**: `cascading_reverse_lookup` + +```sql +INSERT INTO cascading_reverse_lookup ( + lookup_code, lookup_name, + source_table, source_value_column, source_label_column, + target_table, target_value_column, target_label_column, target_link_column, + company_code +) VALUES ( + 'ITEM_USED_IN_BOM', '품목 사용처 BOM', + 'item_info', 'item_code', 'item_name', + 'klbom_tbl', 'pid', 'ayupgname', 'id', + 'EMAX' +); +``` + +--- + +## 10. 전체 테이블 구조 요약 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 연쇄 드롭다운 시스템 구조 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [기존 - 2단계] │ +│ cascading_relation ─────────────────────────────────────────── │ +│ │ +│ [신규 - 다단계 계층] │ +│ cascading_hierarchy_group ──┬── cascading_hierarchy_level │ +│ │ (MULTI_TABLE용) │ +│ │ │ +│ [신규 - 조건부] │ +│ cascading_condition ────────┴── 조건에 따른 필터링 │ +│ │ +│ [신규 - 다중 부모] │ +│ cascading_multi_parent ─────┬── cascading_multi_parent_source │ +│ │ (여러 부모 조합) │ +│ │ +│ [신규 - 자동 입력] │ +│ cascading_auto_fill_group ──┬── cascading_auto_fill_mapping │ +│ │ (마스터→다중 필드) │ +│ │ +│ [신규 - 상호 배제] │ +│ cascading_mutual_exclusion ─┴── 같은 값 선택 불가 │ +│ │ +│ [신규 - 역방향] │ +│ cascading_reverse_lookup ───┴── 자식→부모 조회 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 11. 마이그레이션 가이드 + +### 11.1 기존 데이터 마이그레이션 +```sql +-- 기존 cascading_relation → cascading_hierarchy_group 변환 +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, company_code +) +SELECT + 'LEGACY_' || relation_code, + relation_name, + 'MULTI_TABLE', + 2, + company_code +FROM cascading_relation +WHERE is_active = 'Y'; +``` + +### 11.2 호환성 유지 +- 기존 `cascading_relation` 테이블 유지 +- 기존 API 엔드포인트 유지 +- 점진적 마이그레이션 지원 + +--- + +## 12. 구현 우선순위 (업데이트) + +| Phase | 기능 | 복잡도 | 우선순위 | +|-------|------|--------|----------| +| 1 | 기존 2단계 연쇄 (cascading_relation) | 완료 | 완료 | +| 2 | 다단계 계층 - MULTI_TABLE | 중 | 높음 | +| 3 | 다단계 계층 - SELF_REFERENCE | 중 | 높음 | +| 4 | 자동 입력 그룹 (Auto-Fill) | 낮음 | 높음 | +| 5 | 조건부 연쇄 | 중 | 중간 | +| 6 | 상호 배제 | 낮음 | 중간 | +| 7 | 다중 부모 연쇄 | 높음 | 낮음 | +| 8 | BOM/TREE 구조 | 높음 | 낮음 | +| 9 | 역방향 조회 | 중 | 낮음 | + diff --git a/frontend/app/(main)/admin/auto-fill/page.tsx b/frontend/app/(main)/admin/auto-fill/page.tsx new file mode 100644 index 00000000..64e5e789 --- /dev/null +++ b/frontend/app/(main)/admin/auto-fill/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +/** + * 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트 + */ +export default function AutoFillRedirect() { + const router = useRouter(); + + useEffect(() => { + router.replace("/admin/cascading-management?tab=autofill"); + }, [router]); + + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/frontend/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx new file mode 100644 index 00000000..70382dd9 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react"; + +// 탭별 컴포넌트 +import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; +import AutoFillTab from "./tabs/AutoFillTab"; +import HierarchyTab from "./tabs/HierarchyTab"; +import ConditionTab from "./tabs/ConditionTab"; +import MutualExclusionTab from "./tabs/MutualExclusionTab"; + +export default function CascadingManagementPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState("relations"); + + // URL 쿼리 파라미터에서 탭 설정 + useEffect(() => { + const tab = searchParams.get("tab"); + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) { + setActiveTab(tab); + } + }, [searchParams]); + + // 탭 변경 시 URL 업데이트 + const handleTabChange = (value: string) => { + setActiveTab(value); + const url = new URL(window.location.href); + url.searchParams.set("tab", value); + router.replace(url.pathname + url.search); + }; + + return ( +
+
+ {/* 페이지 헤더 */} +
+

연쇄 드롭다운 통합 관리

+

+ 연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다. +

+
+ + {/* 탭 네비게이션 */} + + + + + 2단계 연쇄관계 + 연쇄 + + + + 다단계 계층 + 계층 + + + + 조건부 필터 + 조건 + + + + 자동 입력 + 자동 + + + + 상호 배제 + 배제 + + + + {/* 탭 컨텐츠 */} +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx new file mode 100644 index 00000000..79208186 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx @@ -0,0 +1,686 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { + Check, + ChevronsUpDown, + Plus, + Pencil, + Trash2, + Search, + RefreshCw, + ArrowRight, + X, + GripVertical, +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +interface TableColumn { + columnName: string; + columnLabel?: string; + dataType?: string; +} + +export default function AutoFillTab() { + // 목록 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [searchText, setSearchText] = useState(""); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [deletingGroupCode, setDeletingGroupCode] = useState(null); + + // 테이블/컬럼 목록 + const [tableList, setTableList] = useState>([]); + const [masterColumns, setMasterColumns] = useState([]); + + // 폼 데이터 + const [formData, setFormData] = useState({ + groupName: "", + description: "", + masterTable: "", + masterValueColumn: "", + masterLabelColumn: "", + isActive: "Y", + }); + + // 매핑 데이터 + const [mappings, setMappings] = useState([]); + + // 테이블 Combobox 상태 + const [tableComboOpen, setTableComboOpen] = useState(false); + + // 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + try { + const response = await cascadingAutoFillApi.getGroups(); + if (response.success && response.data) { + setGroups(response.data); + } + } catch (error) { + console.error("그룹 목록 로드 실패:", error); + toast.error("그룹 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTableList = useCallback(async () => { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTableList(response.data); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }, []); + + // 테이블 컬럼 로드 + const loadColumns = useCallback(async (tableName: string) => { + if (!tableName) { + setMasterColumns([]); + return; + } + try { + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data?.columns) { + setMasterColumns( + response.data.columns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.columnName, + dataType: col.dataType || col.data_type, + })), + ); + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + setMasterColumns([]); + } + }, []); + + useEffect(() => { + loadGroups(); + loadTableList(); + }, [loadGroups, loadTableList]); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (formData.masterTable) { + loadColumns(formData.masterTable); + } + }, [formData.masterTable, loadColumns]); + + // 필터된 목록 + const filteredGroups = groups.filter( + (g) => + g.groupCode.toLowerCase().includes(searchText.toLowerCase()) || + g.groupName.toLowerCase().includes(searchText.toLowerCase()) || + g.masterTable?.toLowerCase().includes(searchText.toLowerCase()), + ); + + // 모달 열기 (생성) + const handleOpenCreate = () => { + setEditingGroup(null); + setFormData({ + groupName: "", + description: "", + masterTable: "", + masterValueColumn: "", + masterLabelColumn: "", + isActive: "Y", + }); + setMappings([]); + setMasterColumns([]); + setIsModalOpen(true); + }; + + // 모달 열기 (수정) + const handleOpenEdit = async (group: AutoFillGroup) => { + setEditingGroup(group); + + // 상세 정보 로드 + const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode); + if (detailResponse.success && detailResponse.data) { + const detail = detailResponse.data; + + // 컬럼 먼저 로드 + if (detail.masterTable) { + await loadColumns(detail.masterTable); + } + + setFormData({ + groupCode: detail.groupCode, + groupName: detail.groupName, + description: detail.description || "", + masterTable: detail.masterTable, + masterValueColumn: detail.masterValueColumn, + masterLabelColumn: detail.masterLabelColumn || "", + isActive: detail.isActive || "Y", + }); + + // 매핑 데이터 변환 (snake_case → camelCase) + const convertedMappings = (detail.mappings || []).map((m: any) => ({ + sourceColumn: m.source_column || m.sourceColumn, + targetField: m.target_field || m.targetField, + targetLabel: m.target_label || m.targetLabel || "", + isEditable: m.is_editable || m.isEditable || "Y", + isRequired: m.is_required || m.isRequired || "N", + defaultValue: m.default_value || m.defaultValue || "", + sortOrder: m.sort_order || m.sortOrder || 0, + })); + setMappings(convertedMappings); + } + + setIsModalOpen(true); + }; + + // 삭제 확인 + const handleDeleteConfirm = (groupCode: string) => { + setDeletingGroupCode(groupCode); + setIsDeleteDialogOpen(true); + }; + + // 삭제 실행 + const handleDelete = async () => { + if (!deletingGroupCode) return; + + try { + const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode); + if (response.success) { + toast.success("자동 입력 그룹이 삭제되었습니다."); + loadGroups(); + } else { + toast.error(response.error || "삭제에 실패했습니다."); + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleteDialogOpen(false); + setDeletingGroupCode(null); + } + }; + + // 저장 + const handleSave = async () => { + // 유효성 검사 + if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) { + toast.error("필수 항목을 모두 입력해주세요."); + return; + } + + try { + const saveData = { + ...formData, + mappings, + }; + + let response; + if (editingGroup) { + response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData); + } else { + response = await cascadingAutoFillApi.createGroup(saveData); + } + + if (response.success) { + toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다."); + setIsModalOpen(false); + loadGroups(); + } else { + toast.error(response.error || "저장에 실패했습니다."); + } + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } + }; + + // 매핑 추가 + const handleAddMapping = () => { + setMappings([ + ...mappings, + { + sourceColumn: "", + targetField: "", + targetLabel: "", + isEditable: "Y", + isRequired: "N", + defaultValue: "", + sortOrder: mappings.length + 1, + }, + ]); + }; + + // 매핑 삭제 + const handleRemoveMapping = (index: number) => { + setMappings(mappings.filter((_, i) => i !== index)); + }; + + // 매핑 수정 + const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => { + const updated = [...mappings]; + updated[index] = { ...updated[index], [field]: value }; + setMappings(updated); + }; + + return ( +
+ {/* 검색 및 액션 */} + + +
+
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
+ +
+
+
+ + {/* 목록 */} + + +
+
+ 자동 입력 그룹 + + 마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개) + +
+ +
+
+ + {loading ? ( +
+ + 로딩 중... +
+ ) : filteredGroups.length === 0 ? ( +
+ {searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."} +
+ ) : ( + + + + 그룹 코드 + 그룹명 + 마스터 테이블 + 매핑 수 + 상태 + 작업 + + + + {filteredGroups.map((group) => ( + + {group.groupCode} + {group.groupName} + {group.masterTable} + + {group.mappingCount || 0}개 + + + + {group.isActive === "Y" ? "활성" : "비활성"} + + + + + + + + ))} + +
+ )} +
+
+ + {/* 생성/수정 모달 */} + + + + {editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"} + 마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다. + + +
+ {/* 기본 정보 */} +
+

기본 정보

+ +
+ + setFormData({ ...formData, groupName: e.target.value })} + placeholder="예: 고객사 정보 자동입력" + /> +
+ +
+ +