/** * 채번 규칙 관리 서비스 */ 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; } 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 { /** * 규칙 목록 조회 (전체) */ 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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 = 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", menu_objid AS "menuObjid", scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE scope_type = 'global' 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", menu_objid AS "menuObjid", scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code = $1 AND scope_type = 'global' 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 = 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", menu_objid AS "menuObjid", scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($1)) OR (scope_type = 'table' AND menu_objid = ANY($1)) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 WHEN scope_type = 'table' THEN 2 WHEN scope_type = 'global' THEN 3 END, created_at DESC `; params = [menuAndChildObjids]; logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids }); } 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", menu_objid AS "menuObjid", scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM numbering_rules WHERE company_code = $1 AND ( scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($2)) OR (scope_type = 'table' AND menu_objid = ANY($2)) ) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($2)) THEN 1 WHEN scope_type = 'table' THEN 2 WHEN scope_type = 'global' THEN 3 END, created_at DESC `; params = [companyCode, menuAndChildObjids]; logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids }); } 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 = 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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 = 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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 = 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, menu_objid, scope_type, 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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.menuObjid || null, config.scopeType || "global", 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" `; const partResult = await client.query(insertPartQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, JSON.stringify(part.autoConfig || {}), JSON.stringify(part.manualConfig || {}), companyCode, ]); parts.push(partResult.rows[0]); } 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), menu_objid = COALESCE($7, menu_objid), scope_type = COALESCE($8, scope_type), 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", menu_objid AS "menuObjid", scope_type AS "scopeType", 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.menuObjid, updates.scopeType, 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 partResult = await client.query(insertPartQuery, [ ruleId, part.order, part.partType, part.generationMethod, JSON.stringify(part.autoConfig || {}), JSON.stringify(part.manualConfig || {}), companyCode, ]); parts.push(partResult.rows[0]); } } 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("규칙을 찾을 수 없습니다"); const parts = rule.parts .sort((a: any, b: any) => a.order - b.order) .map((part: any) => { if (part.generationMethod === "manual") { // 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력) return part.manualConfig?.placeholder || "____"; } const autoConfig = part.autoConfig || {}; switch (part.partType) { case "sequence": { // 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시) const length = autoConfig.sequenceLength || 3; const nextSequence = (rule.currentSequence || 0) + 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일 수 있음 (UnifiedSelect에서 valueCode를 value로 사용) const selectedValueStr = String(selectedValue); const mapping = categoryMappings.find( (m: any) => { // ID로 매칭 if (m.categoryValueId?.toString() === selectedValueStr) return true; // 라벨로 매칭 if (m.categoryValueLabel === selectedValueStr) return true; // valueCode로 매칭 (라벨과 동일할 수 있음) if (m.categoryValueLabel === selectedValueStr) return true; return false; } ); 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 ""; } default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } }); const previewCode = parts.join(rule.separator || ""); logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData }); return previewCode; } /** * 코드 할당 (저장 시점에 실제 순번 증가) * @param ruleId 채번 규칙 ID * @param companyCode 회사 코드 * @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용) */ async allocateCode( ruleId: string, companyCode: string, formData?: Record ): 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("규칙을 찾을 수 없습니다"); const parts = rule.parts .sort((a: any, b: any) => a.order - b.order) .map((part: any) => { if (part.generationMethod === "manual") { return part.manualConfig?.value || ""; } const autoConfig = part.autoConfig || {}; switch (part.partType) { case "sequence": { // 순번 (자동 증가 숫자 - 다음 번호 사용) const length = autoConfig.sequenceLength || 3; const nextSequence = (rule.currentSequence || 0) + 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) { // 날짜 문자열 또는 Date 객체를 Date로 변환 const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue); if (!isNaN(dateValue.getTime())) { logger.info("컬럼 기준 날짜 생성", { sourceColumn: autoConfig.sourceColumnName, columnValue, parsedDate: dateValue.toISOString(), }); return this.formatDate(dateValue, dateFormat); } else { logger.warn("날짜 변환 실패, 현재 날짜 사용", { sourceColumn: autoConfig.sourceColumnName, columnValue, }); } } else { logger.warn("소스 컬럼 값이 없음, 현재 날짜 사용", { sourceColumn: autoConfig.sourceColumnName, formDataKeys: Object.keys(formData), }); } } // 기본: 현재 날짜 사용 return this.formatDate(new Date(), dateFormat); } case "text": { // 텍스트 (고정 문자열) return autoConfig.textValue || "TEXT"; } default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } }); const allocatedCode = parts.join(rule.separator || ""); // 순번이 있는 경우에만 증가 const hasSequence = rule.parts.some( (p: any) => p.partType === "sequence" ); if (hasSequence) { await client.query( "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); } 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(); await pool.query( "UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); logger.info("시퀀스 초기화 완료", { ruleId, companyCode }); } /** * [테스트] 테스트 테이블에서 채번 규칙 목록 조회 * numbering_rules_test 테이블 사용 */ 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_test 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_test 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_test 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 = 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; } } /** * [테스트] 테이블명 + 컬럼명 기반으로 채번규칙 조회 (menu_objid 없이) * numbering_rules_test 테이블 사용 */ 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_test r LEFT JOIN category_values_test 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]; const result = await pool.query(query, params); 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_test WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); rule.parts = 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_test 테이블 사용 */ 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_test 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_test 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_test WHERE rule_id = $1 AND company_code = $2", [config.ruleId, companyCode] ); } else { // 신규 등록 const insertQuery = ` INSERT INTO numbering_rules_test ( 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_test ( 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()) `; await client.query(partInsertQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, JSON.stringify(part.autoConfig || {}), 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_test 테이블 사용 */ 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_test WHERE rule_id = $1 AND company_code = $2", [ruleId, companyCode] ); // 규칙 삭제 const result = await client.query( "DELETE FROM numbering_rules_test 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_test r LEFT JOIN category_values_test 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_test WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); rule.parts = 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_test 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_test WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); rule.parts = 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_test r LEFT JOIN category_values_test 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_test WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); rule.parts = partsResult.rows; } return result.rows; } catch (error: any) { logger.error("테이블.컬럼별 채번 규칙 목록 조회 실패", { error: error.message, }); throw error; } } /** * 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출) * 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결 * 복제 후 화면 레이아웃의 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"); // 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두) const sourceRulesResult = await client.query( `SELECT nr.*, mi.menu_name_kor as source_menu_name FROM numbering_rules nr LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`, [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)}`; // 이미 존재하는지 확인 (이름 기반) 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; } let targetMenuObjid = null; // menu 스코프인 경우 대상 메뉴 찾기 if (rule.scope_type === 'menu' && rule.source_menu_name) { const targetMenuResult = await client.query( `SELECT objid FROM menu_info WHERE company_code = $1 AND menu_name_kor = $2 LIMIT 1`, [targetCompanyCode, rule.source_menu_name] ); if (targetMenuResult.rows.length === 0) { result.skippedCount++; result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`); continue; } targetMenuObjid = targetMenuResult.rows[0].objid; } // 채번규칙 복제 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, scope_type, menu_objid ) 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.scope_type, targetMenuObjid, ] ); // 채번규칙 파트 복제 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} (${rule.scope_type})`); logger.info("채번규칙 복제 완료", { ruleName: rule.rule_name, oldRuleId: rule.rule_id, newRuleId, targetMenuObjid }); } // 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();