/** * 채번 규칙 관리 서비스 */ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { getMenuAndChildObjids } from "./menuService"; interface NumberingRulePart { id?: number; order: number; partType: string; generationMethod: string; autoConfig?: any; manualConfig?: any; generatedValue?: string; separatorAfter?: string; } /** * 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출 */ function extractSeparatorAfterFromParts(parts: any[]): any[] { return parts.map((part) => { if (part.autoConfig?.separatorAfter !== undefined) { part.separatorAfter = part.autoConfig.separatorAfter; } return part; }); } /** * 파트별 개별 구분자를 사용하여 코드 결합 * 마지막 파트의 separatorAfter는 무시됨 */ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string { let result = ""; partValues.forEach((val, idx) => { result += val; if (idx < partValues.length - 1) { const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; result += sep; } }); return result; } interface NumberingRuleConfig { ruleId: string; ruleName: string; description?: string; parts: NumberingRulePart[]; separator?: string; resetPeriod?: string; currentSequence?: number; tableName?: string; columnName?: string; companyCode?: string; menuObjid?: number; scopeType?: string; // 카테고리 조건 categoryColumn?: string; categoryValueId?: number; categoryValueLabel?: string; // 조회 시 조인해서 가져옴 createdAt?: string; updatedAt?: string; createdBy?: string; } class NumberingRuleService { /** * 순번(sequence) 파트를 제외한 나머지 파트 값들을 조합해 prefix_key 생성 * 이 키가 같으면 같은 순번 계열, 다르면 001부터 재시작 */ private async buildPrefixKey( rule: NumberingRuleConfig, formData?: Record ): Promise { const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); const prefixParts: string[] = []; for (const part of sortedParts) { if (part.partType === "sequence") continue; if (part.generationMethod === "manual") { // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) continue; } const autoConfig = (part as any).autoConfig || {}; switch (part.partType) { case "date": { const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue); if (!isNaN(dateValue.getTime())) { prefixParts.push(this.formatDate(dateValue, dateFormat)); break; } } } prefixParts.push(this.formatDate(new Date(), dateFormat)); break; } case "text": { prefixParts.push(autoConfig.textValue || "TEXT"); break; } case "number": { const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; prefixParts.push(String(value).padStart(length, "0")); break; } case "category": { const categoryKey = autoConfig.categoryKey; const categoryMappings = autoConfig.categoryMappings || []; if (!categoryKey || !formData) { prefixParts.push(""); break; } const columnName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey; const selectedValue = formData[columnName]; if (!selectedValue) { prefixParts.push(""); break; } const selectedValueStr = String(selectedValue); let mapping = categoryMappings.find((m: any) => { if (m.categoryValueId?.toString() === selectedValueStr) return true; if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; if (m.categoryValueLabel === selectedValueStr) return true; return false; }); if (!mapping) { try { const pool = getPool(); const [catTableName, catColumnName] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; const cvResult = await pool.query( `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, [catTableName, catColumnName, selectedValueStr] ); if (cvResult.rows.length > 0) { const resolvedId = cvResult.rows[0].value_id; const resolvedLabel = cvResult.rows[0].value_label; mapping = categoryMappings.find((m: any) => { if (m.categoryValueId?.toString() === String(resolvedId)) return true; if (m.categoryValueLabel === resolvedLabel) return true; return false; }); } } catch { /* ignore */ } } prefixParts.push(mapping?.format || selectedValueStr); break; } case "reference": { const refColumn = autoConfig.referenceColumnName; if (refColumn && formData && formData[refColumn]) { prefixParts.push(String(formData[refColumn])); } else { prefixParts.push(""); } break; } default: break; } } return prefixParts.join("|"); } /** * prefix_key 기반으로 현재 순번 조회 (새 테이블 사용) */ private async getSequenceForPrefix( client: any, ruleId: string, companyCode: string, prefixKey: string ): Promise { const result = await client.query( `SELECT current_sequence FROM numbering_rule_sequences WHERE rule_id = $1 AND company_code = $2 AND prefix_key = $3`, [ruleId, companyCode, prefixKey] ); return result.rows.length > 0 ? result.rows[0].current_sequence : 0; } /** * prefix_key 기반으로 순번 증가 (UPSERT) */ private async incrementSequenceForPrefix( client: any, ruleId: string, companyCode: string, prefixKey: string ): Promise { const result = await client.query( `INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at) VALUES ($1, $2, $3, 1, NOW()) ON CONFLICT (rule_id, company_code, prefix_key) DO UPDATE SET current_sequence = numbering_rule_sequences.current_sequence + 1, last_allocated_at = NOW() RETURNING current_sequence`, [ruleId, companyCode, prefixKey] ); return result.rows[0].current_sequence; } /** * 규칙 목록 조회 (전체) */ async getRuleList(companyCode: string): Promise { try { logger.info("채번 규칙 목록 조회 시작", { companyCode }); const pool = getPool(); // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 회사 데이터 조회 가능 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules ORDER BY created_at DESC `; params = []; logger.info("최고 관리자 전체 채번 규칙 조회"); } else { // 일반 회사: 자신의 회사 데이터만 조회 (company_code="*" 제외) query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code = $1 ORDER BY created_at DESC `; params = [companyCode]; logger.info("회사별 채번 규칙 조회", { companyCode }); } const result = await pool.query(query, params); // 각 규칙의 파트 정보 조회 for (const rule of result.rows) { let partsQuery: string; let partsParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 파트 조회 partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order `; partsParams = [rule.ruleId]; } else { // 일반 회사: 자신의 파트만 조회 partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; partsParams = [rule.ruleId, companyCode]; } const partsResult = await pool.query(partsQuery, partsParams); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { companyCode, }); return result.rows; } catch (error: any) { logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`); throw error; } } /** * 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프) * * 메뉴 스코프 규칙: * - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함 * - 우선순위: menu (형제 메뉴) > table > global */ async getAvailableRulesForMenu( companyCode: string, menuObjid?: number ): Promise { let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { companyCode, menuObjid, }); const pool = getPool(); // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외) if (menuObjid) { menuAndChildObjids = await getMenuAndChildObjids(menuObjid); logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids, }); } // menuObjid가 없으면 global 규칙만 반환 if (!menuObjid || menuAndChildObjids.length === 0) { let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 global 규칙 조회 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE 1=1 ORDER BY created_at DESC `; params = []; } else { // 일반 회사: 자신의 global 규칙만 조회 (company_code="*" 제외) query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code = $1 ORDER BY created_at DESC `; params = [companyCode]; } const result = await pool.query(query, params); // 파트 정보 추가 for (const rule of result.rows) { let partsQuery: string; let partsParams: any[]; if (companyCode === "*") { partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order `; partsParams = [rule.ruleId]; } else { partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; partsParams = [rule.ruleId, companyCode]; } const partsResult = await pool.query(partsQuery, partsParams); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; } // 2. 메뉴 스코프: 형제 메뉴의 채번 규칙 조회 // 우선순위: menu (형제 메뉴) > table > global let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules ORDER BY created_at DESC `; params = []; logger.info("최고 관리자: 전체 채번 규칙 조회"); } else { // 일반 회사: 자신의 규칙만 조회 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code = $1 ORDER BY created_at DESC `; params = [companyCode]; logger.info("회사별 채번 규칙 조회", { companyCode }); } logger.info("🔍 채번 규칙 쿼리 실행", { queryPreview: query.substring(0, 200), paramsTypes: params.map((p) => typeof p), paramsValues: params, }); const result = await pool.query(query, params); logger.debug("채번 규칙 쿼리 성공", { ruleCount: result.rows.length }); // 파트 정보 추가 for (const rule of result.rows) { try { let partsQuery: string; let partsParams: any[]; if (companyCode === "*") { partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order `; partsParams = [rule.ruleId]; } else { partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; partsParams = [rule.ruleId, companyCode]; } const partsResult = await pool.query(partsQuery, partsParams); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, ruleName: rule.ruleName, partsCount: partsResult.rows.length, }); } catch (partError: any) { logger.error("❌ 규칙 파트 조회 실패", { ruleId: rule.ruleId, ruleName: rule.ruleName, error: partError.message, errorCode: partError.code, errorStack: partError.stack, }); throw partError; } } logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, menuAndChildCount: menuAndChildObjids.length, count: result.rowCount, }); return result.rows; } catch (error: any) { logger.error("메뉴별 채번 규칙 조회 실패", { error: error.message, errorCode: error.code, errorStack: error.stack, companyCode, menuObjid, menuAndChildObjids: menuAndChildObjids || [], }); throw error; } } /** * 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화) * @param companyCode 회사 코드 * @param tableName 화면의 테이블명 * @returns 해당 테이블의 채번 규칙 목록 */ async getAvailableRulesForScreen( companyCode: string, tableName: string ): Promise { try { logger.info("화면용 채번 규칙 조회", { companyCode, tableName, }); const pool = getPool(); // 멀티테넌시: 최고 관리자 vs 일반 회사 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 회사의 규칙 조회 가능 (최고 관리자 전용 규칙 제외) query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code != '*' AND table_name = $1 ORDER BY created_at DESC `; params = [tableName]; logger.info("최고 관리자: 일반 회사 채번 규칙 조회"); } else { // 일반 회사: 자신의 규칙만 조회 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code = $1 AND table_name = $2 ORDER BY created_at DESC `; params = [companyCode, tableName]; } const result = await pool.query(query, params); // 각 규칙의 파트 정보 로드 for (const rule of result.rows) { const partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [ rule.ruleId, companyCode === "*" ? rule.companyCode : companyCode, ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, { companyCode, tableName, }); return result.rows; } catch (error: any) { logger.error("화면용 채번 규칙 조회 실패", error); throw error; } } /** * 특정 규칙 조회 */ async getRuleById( ruleId: string, companyCode: string ): Promise { const pool = getPool(); // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 가능 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE rule_id = $1 `; params = [ruleId]; } else { // 일반 회사: 자신의 규칙만 조회 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE rule_id = $1 AND company_code = $2 `; params = [ruleId, companyCode]; } const result = await pool.query(query, params); if (result.rowCount === 0) { return null; } const rule = result.rows[0]; // 파트 정보 조회 let partsQuery: string; let partsParams: any[]; if (companyCode === "*") { partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order `; partsParams = [ruleId]; } else { partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; partsParams = [ruleId, companyCode]; } const partsResult = await pool.query(partsQuery, partsParams); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); return rule; } /** * 규칙 생성 */ async createRule( config: NumberingRuleConfig, companyCode: string, userId: string ): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); // 마스터 삽입 const insertRuleQuery = ` INSERT INTO numbering_rules ( rule_id, rule_name, description, separator, reset_period, current_sequence, table_name, column_name, company_code, category_column, category_value_id, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" `; const ruleResult = await client.query(insertRuleQuery, [ config.ruleId, config.ruleName, config.description || null, config.separator || "-", config.resetPeriod || "none", config.currentSequence || 1, config.tableName || null, config.columnName || null, companyCode, config.categoryColumn || null, config.categoryValueId || null, userId, ]); // 파트 삽입 const parts: NumberingRulePart[] = []; for (const part of config.parts) { const insertPartQuery = ` INSERT INTO numbering_rule_parts ( rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" `; // auto_config에 separatorAfter 포함 const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; const partResult = await client.query(insertPartQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); const savedPart = partResult.rows[0]; // autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동 if (savedPart.autoConfig?.separatorAfter !== undefined) { savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; } parts.push(savedPart); } await client.query("COMMIT"); logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode, }); return { ...ruleResult.rows[0], parts }; } catch (error: any) { await client.query("ROLLBACK"); logger.error("채번 규칙 생성 실패", { error: error.message }); throw error; } finally { client.release(); } } /** * 규칙 수정 */ async updateRule( ruleId: string, updates: Partial, companyCode: string ): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const updateRuleQuery = ` UPDATE numbering_rules SET rule_name = COALESCE($1, rule_name), description = COALESCE($2, description), separator = COALESCE($3, separator), reset_period = COALESCE($4, reset_period), table_name = COALESCE($5, table_name), column_name = COALESCE($6, column_name), category_column = COALESCE($7, category_column), category_value_id = COALESCE($8, category_value_id), updated_at = NOW() WHERE rule_id = $9 AND company_code = $10 RETURNING rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" `; const ruleResult = await client.query(updateRuleQuery, [ updates.ruleName, updates.description, updates.separator, updates.resetPeriod, updates.tableName, updates.columnName, updates.categoryColumn, updates.categoryValueId, ruleId, companyCode, ]); if (ruleResult.rowCount === 0) { throw new Error("규칙을 찾을 수 없거나 권한이 없습니다"); } // 파트 업데이트 let parts: NumberingRulePart[] = []; if (updates.parts) { await client.query( "DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); for (const part of updates.parts) { const insertPartQuery = ` INSERT INTO numbering_rule_parts ( rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" `; const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; const partResult = await client.query(insertPartQuery, [ ruleId, part.order, part.partType, part.generationMethod, JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); const savedPart = partResult.rows[0]; if (savedPart.autoConfig?.separatorAfter !== undefined) { savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; } parts.push(savedPart); } } await client.query("COMMIT"); logger.info("채번 규칙 수정 완료", { ruleId, companyCode }); return { ...ruleResult.rows[0], parts }; } catch (error: any) { await client.query("ROLLBACK"); logger.error("채번 규칙 수정 실패", { ruleId, companyCode, error: error.message, stack: error.stack, updates, }); throw error; } finally { client.release(); } } /** * 규칙 삭제 */ async deleteRule(ruleId: string, companyCode: string): Promise { const pool = getPool(); const query = ` DELETE FROM numbering_rules WHERE rule_id = $1 AND company_code = $2 `; const result = await pool.query(query, [ruleId, companyCode]); if (result.rowCount === 0) { throw new Error("규칙을 찾을 수 없거나 권한이 없습니다"); } logger.info("채번 규칙 삭제 완료", { ruleId, companyCode }); } /** * 코드 미리보기 (순번 증가 없음) * @param ruleId 채번 규칙 ID * @param companyCode 회사 코드 * @param formData 폼 데이터 (카테고리 기반 채번 시 사용) */ async previewCode( ruleId: string, companyCode: string, formData?: Record ): Promise { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); // prefix_key 기반 순번 조회 const prefixKey = await this.buildPrefixKey(rule, formData); const pool = getPool(); const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); logger.info("미리보기: prefix_key 기반 순번 조회", { ruleId, prefixKey, currentSeq, }); const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { if (part.generationMethod === "manual") { return "____"; } const autoConfig = part.autoConfig || {}; switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; const nextSequence = currentSeq + 1; return String(nextSequence).padStart(length, "0"); } case "number": { // 숫자 (고정 자릿수) const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } case "date": { // 날짜 (다양한 날짜 형식) const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; // 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출 if ( autoConfig.useColumnValue && autoConfig.sourceColumnName && formData ) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue); if (!isNaN(dateValue.getTime())) { return this.formatDate(dateValue, dateFormat); } } } return this.formatDate(new Date(), dateFormat); } case "text": { // 텍스트 (고정 문자열) return autoConfig.textValue || "TEXT"; } case "category": { // 카테고리 기반 코드 생성 const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" const categoryMappings = autoConfig.categoryMappings || []; if (!categoryKey || !formData) { logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData, }); return ""; } // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") const columnName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey; // 폼 데이터에서 해당 컬럼의 값 가져오기 const selectedValue = formData[columnName]; logger.info("카테고리 파트 처리", { categoryKey, columnName, selectedValue, formDataKeys: Object.keys(formData), mappingsCount: categoryMappings.length, }); if (!selectedValue) { logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData), }); return ""; } // 카테고리 매핑에서 해당 값에 대한 형식 찾기 // selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용) const selectedValueStr = String(selectedValue); let mapping = categoryMappings.find((m: any) => { // ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우) if (m.categoryValueId?.toString() === selectedValueStr) return true; // valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우) if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; // 라벨로 매칭 (폴백) if (m.categoryValueLabel === selectedValueStr) return true; return false; }); // 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도 if (!mapping) { try { const pool = getPool(); const [catTableName, catColumnName] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; const cvResult = await pool.query( `SELECT value_id, value_code, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, [catTableName, catColumnName, selectedValueStr] ); if (cvResult.rows.length > 0) { const resolvedId = cvResult.rows[0].value_id; const resolvedLabel = cvResult.rows[0].value_label; mapping = categoryMappings.find((m: any) => { if (m.categoryValueId?.toString() === String(resolvedId)) return true; if (m.categoryValueLabel === resolvedLabel) return true; return false; }); if (mapping) { logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", { valueCode: selectedValueStr, resolvedId, resolvedLabel, format: mapping.format, }); } } } catch (lookupError: any) { logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message }); } } if (mapping) { logger.info("카테고리 매핑 적용", { selectedValue, format: mapping.format, categoryValueLabel: mapping.categoryValueLabel, }); return mapping.format || ""; } logger.warn("카테고리 매핑을 찾을 수 없음", { selectedValue, availableMappings: categoryMappings.map((m: any) => ({ id: m.categoryValueId, label: m.categoryValueLabel, })), }); return ""; } case "reference": { const refColumn = autoConfig.referenceColumnName; if (refColumn && formData && formData[refColumn]) { return String(formData[refColumn]); } return "REF"; } default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } })); const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order); const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || ""); logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData, }); return previewCode; } /** * 코드 할당 (저장 시점에 실제 순번 증가) * @param ruleId 채번 규칙 ID * @param companyCode 회사 코드 * @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용) * @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용) */ async allocateCode( ruleId: string, companyCode: string, formData?: Record, userInputCode?: string ): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); // prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성 const prefixKey = await this.buildPrefixKey(rule, formData); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); // 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 let allocatedSequence = 0; if (hasSequence) { allocatedSequence = await this.incrementSequenceForPrefix( client, ruleId, companyCode, prefixKey ); // 호환성을 위해 기존 current_sequence도 업데이트 await client.query( "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); } logger.info("allocateCode: prefix_key 기반 순번 할당", { ruleId, prefixKey, allocatedSequence, }); // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 const manualParts = rule.parts.filter( (p: any) => p.generationMethod === "manual" ); let extractedManualValues: string[] = []; if (manualParts.length > 0 && userInputCode) { const previewParts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { if (part.generationMethod === "manual") { return "____"; } const autoConfig = part.autoConfig || {}; switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; return "X".repeat(length); } case "text": return autoConfig.textValue || ""; case "date": return "DATEPART"; case "category": { const catKey2 = autoConfig.categoryKey; const catMappings2 = autoConfig.categoryMappings || []; if (!catKey2 || !formData) { return "CATEGORY"; } const colName2 = catKey2.includes(".") ? catKey2.split(".")[1] : catKey2; const selVal2 = formData[colName2]; if (!selVal2) { return "CATEGORY"; } const selValStr2 = String(selVal2); let catMapping2 = catMappings2.find((m: any) => { if (m.categoryValueId?.toString() === selValStr2) return true; if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true; if (m.categoryValueLabel === selValStr2) return true; return false; }); if (!catMapping2) { try { const pool2 = getPool(); const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2]; const cvr2 = await pool2.query( `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, [ct2, cc2, selValStr2] ); if (cvr2.rows.length > 0) { const rid2 = cvr2.rows[0].value_id; const rlabel2 = cvr2.rows[0].value_label; catMapping2 = catMappings2.find((m: any) => { if (m.categoryValueId?.toString() === String(rid2)) return true; if (m.categoryValueLabel === rlabel2) return true; return false; }); } } catch { /* ignore */ } } return catMapping2?.format || "CATEGORY"; } case "reference": { const refCol2 = autoConfig.referenceColumnName; if (refCol2 && formData && formData[refCol2]) { return String(formData[refCol2]); } return "REF"; } default: return ""; } })); const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); const templateParts = previewTemplate.split("____"); if (templateParts.length > 1) { let remainingCode = userInputCode; for (let i = 0; i < templateParts.length - 1; i++) { const prefix = templateParts[i]; const suffix = templateParts[i + 1]; if (prefix && remainingCode.startsWith(prefix)) { remainingCode = remainingCode.slice(prefix.length); } if (suffix) { const suffixStart = suffix.replace(/X+|DATEPART/g, ""); const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length; if (manualEndIndex > 0) { extractedManualValues.push( remainingCode.slice(0, manualEndIndex) ); remainingCode = remainingCode.slice(manualEndIndex); } } else { extractedManualValues.push(remainingCode); } } } logger.info( `수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}` ); } let manualPartIndex = 0; const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) .map(async (part: any) => { if (part.generationMethod === "manual") { const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || ""; manualPartIndex++; return manualValue; } const autoConfig = part.autoConfig || {}; switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; return String(allocatedSequence).padStart(length, "0"); } case "number": { const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } case "date": { const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; if ( autoConfig.useColumnValue && autoConfig.sourceColumnName && formData ) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue); if (!isNaN(dateValue.getTime())) { return this.formatDate(dateValue, dateFormat); } } } return this.formatDate(new Date(), dateFormat); } case "text": { return autoConfig.textValue || "TEXT"; } case "category": { const categoryKey = autoConfig.categoryKey; const categoryMappings = autoConfig.categoryMappings || []; if (!categoryKey || !formData) { return ""; } const columnName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey; const selectedValue = formData[columnName]; if (!selectedValue) { return ""; } const selectedValueStr = String(selectedValue); let allocMapping = categoryMappings.find((m: any) => { if (m.categoryValueId?.toString() === selectedValueStr) return true; if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; if (m.categoryValueLabel === selectedValueStr) return true; return false; }); if (!allocMapping) { try { const pool3 = getPool(); const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; const cvr3 = await pool3.query( `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, [ct3, cc3, selectedValueStr] ); if (cvr3.rows.length > 0) { const rid3 = cvr3.rows[0].value_id; const rlabel3 = cvr3.rows[0].value_label; allocMapping = categoryMappings.find((m: any) => { if (m.categoryValueId?.toString() === String(rid3)) return true; if (m.categoryValueLabel === rlabel3) return true; return false; }); } } catch { /* ignore */ } } if (allocMapping) { return allocMapping.format || ""; } return ""; } case "reference": { const refColumn = autoConfig.referenceColumnName; if (refColumn && formData && formData[refColumn]) { return String(formData[refColumn]); } logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] }); return ""; } default: return ""; } })); const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order); const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || ""); await client.query("COMMIT"); logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode }); return allocatedCode; } catch (error: any) { await client.query("ROLLBACK"); logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message, stack: error.stack, }); throw error; } finally { client.release(); } } /** * @deprecated 기존 generateCode는 allocateCode를 사용하세요 */ async generateCode(ruleId: string, companyCode: string): Promise { logger.warn( "generateCode는 deprecated 되었습니다. previewCode 또는 allocateCode를 사용하세요" ); return this.allocateCode(ruleId, companyCode); } private formatDate(date: Date, format: string): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); switch (format) { case "YYYY": return String(year); case "YY": return String(year).slice(-2); case "YYYYMM": return `${year}${month}`; case "YYMM": return `${String(year).slice(-2)}${month}`; case "YYYYMMDD": return `${year}${month}${day}`; case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; default: return `${year}${month}${day}`; } } async resetSequence(ruleId: string, companyCode: string): Promise { const pool = getPool(); // 새 테이블의 모든 prefix 순번 초기화 await pool.query( "DELETE FROM numbering_rule_sequences WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); // 기존 테이블도 초기화 (호환성) await pool.query( "UPDATE numbering_rules SET current_sequence = 0, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); logger.info("시퀀스 초기화 완료 (prefix별 순번 포함)", { ruleId, companyCode }); } /** * [테스트] 테스트 테이블에서 채번 규칙 목록 조회 * numbering_rules 테이블 사용 */ async getRulesFromTest( companyCode: string, menuObjid?: number ): Promise { try { logger.info("[테스트] 채번 규칙 목록 조회 시작", { companyCode, menuObjid, }); const pool = getPool(); // 멀티테넌시: 최고 관리자 vs 일반 회사 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules ORDER BY created_at DESC `; params = []; } else { // 일반 회사: 자신의 규칙만 조회 query = ` SELECT rule_id AS "ruleId", rule_name AS "ruleName", description, separator, reset_period AS "resetPeriod", current_sequence AS "currentSequence", table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", category_column AS "categoryColumn", category_value_id AS "categoryValueId", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code = $1 ORDER BY created_at DESC `; params = [companyCode]; } const result = await pool.query(query, params); // 각 규칙의 파트 정보 조회 for (const rule of result.rows) { const partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [ rule.ruleId, companyCode === "*" ? rule.companyCode : companyCode, ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info("[테스트] 채번 규칙 목록 조회 완료", { companyCode, menuObjid, count: result.rows.length, }); return result.rows; } catch (error: any) { logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message, stack: error.stack, }); throw error; } } /** * 테이블명 + 컬럼명 기반으로 채번규칙 조회 * numbering_rules 테이블 사용 */ async getNumberingRuleByColumn( companyCode: string, tableName: string, columnName: string ): Promise { try { logger.info("테이블+컬럼 기반 채번 규칙 조회 시작 (테스트)", { companyCode, tableName, columnName, }); const pool = getPool(); const query = ` SELECT r.rule_id AS "ruleId", r.rule_name AS "ruleName", r.description, r.separator, r.reset_period AS "resetPeriod", r.current_sequence AS "currentSequence", r.table_name AS "tableName", r.column_name AS "columnName", r.company_code AS "companyCode", r.category_column AS "categoryColumn", r.category_value_id AS "categoryValueId", cv.value_label AS "categoryValueLabel", r.created_at AS "createdAt", r.updated_at AS "updatedAt", r.created_by AS "createdBy" FROM numbering_rules r LEFT JOIN category_values cv ON r.category_value_id = cv.value_id WHERE r.company_code = $1 AND r.table_name = $2 AND r.column_name = $3 AND r.category_value_id IS NULL LIMIT 1 `; const params = [companyCode, tableName, columnName]; let result = await pool.query(query, params); // fallback: column_name이 비어있는 레거시 규칙 검색 if (result.rows.length === 0) { const fallbackQuery = ` SELECT r.rule_id AS "ruleId", r.rule_name AS "ruleName", r.description, r.separator, r.reset_period AS "resetPeriod", r.current_sequence AS "currentSequence", r.table_name AS "tableName", r.column_name AS "columnName", r.company_code AS "companyCode", r.category_column AS "categoryColumn", r.category_value_id AS "categoryValueId", cv.value_label AS "categoryValueLabel", r.created_at AS "createdAt", r.updated_at AS "updatedAt", r.created_by AS "createdBy" FROM numbering_rules r LEFT JOIN category_values cv ON r.category_value_id = cv.value_id WHERE r.company_code = $1 AND r.table_name = $2 AND (r.column_name IS NULL OR r.column_name = '') AND r.category_value_id IS NULL ORDER BY r.updated_at DESC LIMIT 1 `; result = await pool.query(fallbackQuery, [companyCode, tableName]); // 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션) if (result.rows.length > 0) { const foundRule = result.rows[0]; await pool.query( `UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`, [columnName, foundRule.ruleId, companyCode] ); result.rows[0].columnName = columnName; logger.info("레거시 채번 규칙 자동 매핑 완료", { ruleId: foundRule.ruleId, tableName, columnName, }); } } if (result.rows.length === 0) { logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", { companyCode, tableName, columnName, }); return null; } const rule = result.rows[0]; const partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [ rule.ruleId, companyCode, ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", { ruleId: rule.ruleId, ruleName: rule.ruleName, }); return rule; } catch (error: any) { logger.error("테이블+컬럼 기반 채번 규칙 조회 실패 (테스트)", { error: error.message, stack: error.stack, companyCode, tableName, columnName, }); throw error; } } /** * [테스트] 테스트 테이블에 채번규칙 저장 * numbering_rules 테이블 사용 */ async saveRuleToTest( config: NumberingRuleConfig, companyCode: string, createdBy: string ): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); logger.info("테스트 테이블에 채번 규칙 저장 시작", { ruleId: config.ruleId, ruleName: config.ruleName, tableName: config.tableName, columnName: config.columnName, companyCode, }); // 기존 규칙 확인 const existingQuery = ` SELECT rule_id FROM numbering_rules WHERE rule_id = $1 AND company_code = $2 `; const existingResult = await client.query(existingQuery, [ config.ruleId, companyCode, ]); if (existingResult.rows.length > 0) { // 업데이트 const updateQuery = ` UPDATE numbering_rules SET rule_name = $1, description = $2, separator = $3, reset_period = $4, table_name = $5, column_name = $6, category_column = $7, category_value_id = $8, updated_at = NOW() WHERE rule_id = $9 AND company_code = $10 `; await client.query(updateQuery, [ config.ruleName, config.description || "", config.separator || "-", config.resetPeriod || "none", config.tableName || "", config.columnName || "", config.categoryColumn || null, config.categoryValueId || null, config.ruleId, companyCode, ]); // 기존 파트 삭제 await client.query( "DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2", [config.ruleId, companyCode] ); } else { // 신규 등록 const insertQuery = ` INSERT INTO numbering_rules ( rule_id, rule_name, description, separator, reset_period, current_sequence, table_name, column_name, company_code, category_column, category_value_id, created_at, updated_at, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), $12) `; await client.query(insertQuery, [ config.ruleId, config.ruleName, config.description || "", config.separator || "-", config.resetPeriod || "none", config.currentSequence || 1, config.tableName || "", config.columnName || "", companyCode, config.categoryColumn || null, config.categoryValueId || null, createdBy, ]); } // 파트 저장 if (config.parts && config.parts.length > 0) { for (const part of config.parts) { const partInsertQuery = ` INSERT INTO numbering_rule_parts ( rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) `; const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; await client.query(partInsertQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); } } await client.query("COMMIT"); logger.info("테스트 테이블에 채번 규칙 저장 완료", { ruleId: config.ruleId, companyCode, }); return config; } catch (error: any) { await client.query("ROLLBACK"); logger.error("테스트 테이블에 채번 규칙 저장 실패", { error: error.message, stack: error.stack, ruleId: config.ruleId, companyCode, }); throw error; } finally { client.release(); } } /** * [테스트] 테스트 테이블에서 채번규칙 삭제 * numbering_rules 테이블 사용 */ async deleteRuleFromTest(ruleId: string, companyCode: string): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode, }); // 파트 먼저 삭제 await client.query( "DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); // 규칙 삭제 const result = await client.query( "DELETE FROM numbering_rules WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); await client.query("COMMIT"); logger.info("테스트 테이블에서 채번 규칙 삭제 완료", { ruleId, companyCode, deletedCount: result.rowCount, }); } catch (error: any) { await client.query("ROLLBACK"); logger.error("테스트 테이블에서 채번 규칙 삭제 실패", { error: error.message, ruleId, companyCode, }); throw error; } finally { client.release(); } } /** * [테스트] 카테고리 값에 따라 적절한 채번규칙 조회 * 1. 해당 카테고리 값에 매칭되는 규칙 찾기 * 2. 없으면 기본 규칙(category_value_id가 NULL인) 찾기 */ async getNumberingRuleByColumnWithCategory( companyCode: string, tableName: string, columnName: string, categoryColumn?: string, categoryValueId?: number ): Promise { try { logger.info("카테고리 조건 포함 채번 규칙 조회 시작", { companyCode, tableName, columnName, categoryColumn, categoryValueId, }); const pool = getPool(); // 1. 카테고리 값에 매칭되는 규칙 찾기 if (categoryColumn && categoryValueId) { const categoryQuery = ` SELECT r.rule_id AS "ruleId", r.rule_name AS "ruleName", r.description, r.separator, r.reset_period AS "resetPeriod", r.current_sequence AS "currentSequence", r.table_name AS "tableName", r.column_name AS "columnName", r.company_code AS "companyCode", r.category_column AS "categoryColumn", r.category_value_id AS "categoryValueId", cv.value_label AS "categoryValueLabel", r.created_at AS "createdAt", r.updated_at AS "updatedAt", r.created_by AS "createdBy" FROM numbering_rules r LEFT JOIN category_values cv ON r.category_value_id = cv.value_id WHERE r.company_code = $1 AND r.table_name = $2 AND r.column_name = $3 AND r.category_column = $4 AND r.category_value_id = $5 LIMIT 1 `; const categoryResult = await pool.query(categoryQuery, [ companyCode, tableName, columnName, categoryColumn, categoryValueId, ]); if (categoryResult.rows.length > 0) { const rule = categoryResult.rows[0]; // 파트 정보 조회 const partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [ rule.ruleId, companyCode, ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("카테고리 조건 매칭 채번 규칙 찾음", { ruleId: rule.ruleId, categoryValueLabel: rule.categoryValueLabel, }); return rule; } } // 2. 기본 규칙 찾기 (category_value_id가 NULL인) const defaultQuery = ` SELECT r.rule_id AS "ruleId", r.rule_name AS "ruleName", r.description, r.separator, r.reset_period AS "resetPeriod", r.current_sequence AS "currentSequence", r.table_name AS "tableName", r.column_name AS "columnName", r.company_code AS "companyCode", r.category_column AS "categoryColumn", r.category_value_id AS "categoryValueId", r.created_at AS "createdAt", r.updated_at AS "updatedAt", r.created_by AS "createdBy" FROM numbering_rules r WHERE r.company_code = $1 AND r.table_name = $2 AND r.column_name = $3 AND r.category_value_id IS NULL LIMIT 1 `; const defaultResult = await pool.query(defaultQuery, [ companyCode, tableName, columnName, ]); if (defaultResult.rows.length > 0) { const rule = defaultResult.rows[0]; // 파트 정보 조회 const partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [ rule.ruleId, companyCode, ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { ruleId: rule.ruleId, }); return rule; } logger.info("채번 규칙을 찾을 수 없음", { companyCode, tableName, columnName, categoryColumn, categoryValueId, }); return null; } catch (error: any) { logger.error("카테고리 조건 포함 채번 규칙 조회 실패", { error: error.message, stack: error.stack, }); throw error; } } /** * [테스트] 특정 테이블.컬럼의 모든 채번 규칙 조회 (카테고리 조건별) */ async getRulesByTableColumn( companyCode: string, tableName: string, columnName: string ): Promise { try { const pool = getPool(); const query = ` SELECT r.rule_id AS "ruleId", r.rule_name AS "ruleName", r.description, r.separator, r.reset_period AS "resetPeriod", r.current_sequence AS "currentSequence", r.table_name AS "tableName", r.column_name AS "columnName", r.company_code AS "companyCode", r.category_column AS "categoryColumn", r.category_value_id AS "categoryValueId", cv.value_label AS "categoryValueLabel", r.created_at AS "createdAt", r.updated_at AS "updatedAt", r.created_by AS "createdBy" FROM numbering_rules r LEFT JOIN category_values cv ON r.category_value_id = cv.value_id WHERE r.company_code = $1 AND r.table_name = $2 AND r.column_name = $3 ORDER BY r.category_value_id NULLS FIRST, r.created_at `; const result = await pool.query(query, [ companyCode, tableName, columnName, ]); // 각 규칙의 파트 정보 조회 for (const rule of result.rows) { const partsQuery = ` SELECT id, part_order AS "order", part_type AS "partType", generation_method AS "generationMethod", auto_config AS "autoConfig", manual_config AS "manualConfig" FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [ rule.ruleId, companyCode, ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; } catch (error: any) { logger.error("테이블.컬럼별 채번 규칙 목록 조회 실패", { error: error.message, }); throw error; } } /** * 회사별 채번규칙 복제 (테이블 기반) * numbering_rules, numbering_rule_parts 테이블 사용 * 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트 */ async copyRulesForCompany( sourceCompanyCode: string, targetCompanyCode: string ): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record; }> { const pool = getPool(); const client = await pool.connect(); const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record, }; try { await client.query("BEGIN"); // 0. 대상 회사의 기존 채번규칙 삭제 (깨끗하게 복제하기 위해) // 먼저 파트 삭제 await client.query( `DELETE FROM numbering_rule_parts WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`, [targetCompanyCode] ); // 규칙 삭제 const deleteResult = await client.query( `DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`, [targetCompanyCode] ); if (deleteResult.rowCount && deleteResult.rowCount > 0) { logger.info("기존 채번규칙 삭제", { targetCompanyCode, deletedCount: deleteResult.rowCount, }); } // 1. 원본 회사의 채번규칙 조회 - numbering_rules 사용 const sourceRulesResult = await client.query( `SELECT * FROM numbering_rules WHERE company_code = $1`, [sourceCompanyCode] ); logger.info("원본 채번규칙 조회", { sourceCompanyCode, count: sourceRulesResult.rowCount, }); // 2. 각 채번규칙 복제 for (const rule of sourceRulesResult.rows) { // 새 rule_id 생성 const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // 이미 존재하는지 확인 (이름 기반) - numbering_rules 사용 const existsCheck = await client.query( `SELECT rule_id FROM numbering_rules WHERE company_code = $1 AND rule_name = $2`, [targetCompanyCode, rule.rule_name] ); if (existsCheck.rows.length > 0) { // 이미 존재하면 매핑만 추가 result.ruleIdMap[rule.rule_id] = existsCheck.rows[0].rule_id; result.skippedCount++; result.details.push(`건너뜀 (이미 존재): ${rule.rule_name}`); continue; } // 채번규칙 복제 - numbering_rules 사용 await client.query( `INSERT INTO numbering_rules ( rule_id, rule_name, description, separator, reset_period, current_sequence, table_name, column_name, company_code, created_at, updated_at, created_by, category_column, category_value_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`, [ newRuleId, rule.rule_name, rule.description, rule.separator, rule.reset_period, 0, // 시퀀스 초기화 rule.table_name, rule.column_name, targetCompanyCode, rule.created_by, rule.category_column, rule.category_value_id, ] ); // 채번규칙 파트 복제 - numbering_rule_parts 사용 const partsResult = await client.query( `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, [rule.rule_id] ); for (const part of partsResult.rows) { await client.query( `INSERT INTO numbering_rule_parts ( rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, [ newRuleId, part.part_order, part.part_type, part.generation_method, part.auto_config ? JSON.stringify(part.auto_config) : null, part.manual_config ? JSON.stringify(part.manual_config) : null, targetCompanyCode, ] ); } // 매핑 추가 result.ruleIdMap[rule.rule_id] = newRuleId; result.copiedCount++; result.details.push(`복제 완료: ${rule.rule_name}`); logger.info("채번규칙 복제 완료", { ruleName: rule.rule_name, oldRuleId: rule.rule_id, newRuleId, }); } // 3. 화면 레이아웃의 numberingRuleId 참조 업데이트 if (Object.keys(result.ruleIdMap).length > 0) { logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", { targetCompanyCode, mappingCount: Object.keys(result.ruleIdMap).length, }); // 대상 회사의 모든 화면 레이아웃 조회 const layoutsResult = await client.query( `SELECT sl.layout_id, sl.properties FROM screen_layouts sl JOIN screen_definitions sd ON sl.screen_id = sd.screen_id WHERE sd.company_code = $1 AND sl.properties::text LIKE '%numberingRuleId%'`, [targetCompanyCode] ); let updatedLayouts = 0; for (const layout of layoutsResult.rows) { let propsStr = JSON.stringify(layout.properties); let updated = false; // 각 매핑에 대해 치환 for (const [oldRuleId, newRuleId] of Object.entries( result.ruleIdMap )) { if (propsStr.includes(`"${oldRuleId}"`)) { propsStr = propsStr .split(`"${oldRuleId}"`) .join(`"${newRuleId}"`); updated = true; } } if (updated) { await client.query( `UPDATE screen_layouts SET properties = $1::jsonb WHERE layout_id = $2`, [propsStr, layout.layout_id] ); updatedLayouts++; } } logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", { targetCompanyCode, updatedLayouts, }); result.details.push( `화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트` ); } await client.query("COMMIT"); logger.info("회사별 채번규칙 복제 완료", { sourceCompanyCode, targetCompanyCode, copiedCount: result.copiedCount, skippedCount: result.skippedCount, ruleIdMapCount: Object.keys(result.ruleIdMap).length, }); return result; } catch (error) { await client.query("ROLLBACK"); logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode, }); throw error; } finally { client.release(); } } } export const numberingRuleService = new NumberingRuleService();