/** * 채번 규칙 관리 서비스 */ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; 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; 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; } } /** * 현재 메뉴에서 사용 가능한 규칙 목록 조회 */ async getAvailableRulesForMenu( companyCode: string, menuObjid?: number ): Promise { try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", { companyCode, menuObjid, }); const pool = getPool(); // menuObjid가 없으면 global 규칙만 반환 if (!menuObjid) { 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레벨 메뉴 찾기) const menuHierarchyQuery = ` WITH RECURSIVE menu_path AS ( SELECT objid, objid_parent, menu_level FROM menu_info WHERE objid = $1 UNION ALL SELECT mi.objid, mi.objid_parent, mi.menu_level FROM menu_info mi INNER JOIN menu_path mp ON mi.objid = mp.objid_parent ) SELECT objid, menu_level FROM menu_path WHERE menu_level = 2 LIMIT 1 `; const hierarchyResult = await pool.query(menuHierarchyQuery, [menuObjid]); const level2MenuObjid = hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null; // 사용 가능한 규칙 조회 (멀티테넌시 적용) 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 = $1) ORDER BY scope_type DESC, created_at DESC `; params = [level2MenuObjid]; } 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 = $2) ) ORDER BY scope_type DESC, created_at DESC `; params = [companyCode, level2MenuObjid]; } 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("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, level2MenuObjid, count: result.rowCount, }); return result.rows; } catch (error: any) { logger.error("메뉴별 채번 규칙 조회 실패", { error: error.message, companyCode, menuObjid, }); 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("채번 규칙 수정 실패", { error: error.message }); 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 }); } /** * 코드 미리보기 (순번 증가 없음) */ async previewCode(ruleId: string, companyCode: string): 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?.value || ""; } const autoConfig = part.autoConfig || {}; switch (part.partType) { case "sequence": { // 순번 (현재 순번으로 미리보기, 증가 안 함) const length = autoConfig.sequenceLength || 4; return String(rule.currentSequence || 1).padStart(length, "0"); } case "number": { // 숫자 (고정 자릿수) const length = autoConfig.numberLength || 4; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } case "date": { // 날짜 (다양한 날짜 형식) return this.formatDate( new Date(), autoConfig.dateFormat || "YYYYMMDD" ); } case "text": { // 텍스트 (고정 문자열) return autoConfig.textValue || "TEXT"; } default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } }); const previewCode = parts.join(rule.separator || ""); logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode }); return previewCode; } /** * 코드 할당 (저장 시점에 실제 순번 증가) */ async allocateCode(ruleId: string, companyCode: 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("규칙을 찾을 수 없습니다"); 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 || 4; return String(rule.currentSequence || 1).padStart(length, "0"); } case "number": { // 숫자 (고정 자릿수) const length = autoConfig.numberLength || 4; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } case "date": { // 날짜 (다양한 날짜 형식) return this.formatDate( new Date(), autoConfig.dateFormat || "YYYYMMDD" ); } 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 }); } } export const numberingRuleService = new NumberingRuleService();