From dfd26e19335539596ac31f0e2e9a3640df0da4c2 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 16:28:42 +0900 Subject: [PATCH] 11 --- .../src/services/numberingRuleService.ts | 355 +++++++++++++++++- 1 file changed, 342 insertions(+), 13 deletions(-) diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 91ae4cb5..97d27e6c 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -227,6 +227,312 @@ class NumberingRuleService { ); return result.rows[0].current_sequence; } + + /** + * 카운터를 특정 값 이상으로 동기화 (GREATEST 사용) + * 테이블 내 실제 최대값이 카운터보다 높을 때 카운터를 맞춰줌 + */ + private async setSequenceForPrefix( + client: any, + ruleId: string, + companyCode: string, + prefixKey: string, + targetSequence: number + ): 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, $4, NOW()) + ON CONFLICT (rule_id, company_code, prefix_key) + DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4), + last_allocated_at = NOW() + RETURNING current_sequence`, + [ruleId, companyCode, prefixKey, targetSequence] + ); + return result.rows[0].current_sequence; + } + + /** + * 대상 테이블에서 해당 회사의 최대 시퀀스 번호를 조회 + * 코드의 prefix/suffix 패턴을 기반으로 sequence 부분만 추출하여 MAX 계산 + */ + private async getMaxSequenceFromTable( + client: any, + tableName: string, + columnName: string, + codePrefix: string, + codeSuffix: string, + seqLength: number, + companyCode: string + ): Promise { + try { + // 테이블에 company_code 컬럼이 있는지 확인 + const colCheck = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + const hasCompanyCode = colCheck.rows.length > 0; + + // 대상 컬럼 존재 여부 확인 + const targetColCheck = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2`, + [tableName, columnName] + ); + if (targetColCheck.rows.length === 0) { + logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`); + return 0; + } + + // prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산 + const prefixLen = codePrefix.length; + const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based + + // LIKE 패턴: prefix + N자리 숫자 + suffix + const likePattern = codePrefix + "%" + codeSuffix; + + let sql: string; + let params: any[]; + + if (hasCompanyCode && companyCode !== "*") { + sql = ` + SELECT MAX( + CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER) + ) as max_seq + FROM "${tableName}" + WHERE "${columnName}" LIKE $3 + AND company_code = $4 + AND LENGTH("${columnName}") = $5 + AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$' + `; + params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length]; + } else { + sql = ` + SELECT MAX( + CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER) + ) as max_seq + FROM "${tableName}" + WHERE "${columnName}" LIKE $3 + AND LENGTH("${columnName}") = $4 + AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$' + `; + params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length]; + } + + const result = await client.query(sql, params); + const maxSeq = result.rows[0]?.max_seq ?? 0; + + logger.info("getMaxSequenceFromTable 결과", { + tableName, columnName, codePrefix, codeSuffix, + seqLength, companyCode, maxSeq, + }); + + return maxSeq; + } catch (error: any) { + logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", { + tableName, columnName, error: error.message, + }); + return 0; + } + } + + /** + * 규칙의 파트 구성에서 sequence 파트 전후의 prefix/suffix를 계산 + * allocateCode/previewCode에서 비-sequence 파트 값이 이미 계산된 후 호출 + */ + private buildCodePrefixSuffix( + partValues: string[], + sortedParts: any[], + globalSeparator: string + ): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null { + const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence"); + if (seqIndex === -1) return null; + + const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3; + + // prefix: sequence 파트 이전의 모든 파트값 + 구분자 + let prefix = ""; + for (let i = 0; i < seqIndex; i++) { + prefix += partValues[i]; + const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator; + prefix += sep; + } + + // suffix: sequence 파트 이후의 모든 파트값 + 구분자 + let suffix = ""; + for (let i = seqIndex + 1; i < partValues.length; i++) { + const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator; + if (i === seqIndex + 1) { + // sequence 파트 바로 뒤 구분자 + const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator; + suffix += seqSep; + } + suffix += partValues[i]; + if (i < partValues.length - 1) { + const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator; + suffix += nextSep; + } + } + + return { prefix, suffix, seqIndex, seqLength }; + } + + /** + * 비-sequence 파트의 값을 계산하여 prefix/suffix 패턴 구축에 사용 + * sequence 파트는 빈 문자열로 반환 (이후 buildCodePrefixSuffix에서 처리) + */ + private async computeNonSequenceValues( + rule: NumberingRuleConfig, + formData?: Record + ): Promise { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + return Promise.all(sortedParts.map(async (part: any) => { + if (part.partType === "sequence") return ""; + if (part.generationMethod === "manual") return ""; + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "text": + return autoConfig.textValue || "TEXT"; + + 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 "category": { + const categoryKey = autoConfig.categoryKey; + const categoryMappings = autoConfig.categoryMappings || []; + if (!categoryKey || !formData) return ""; + + const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey; + const selectedValue = formData[colName]; + if (!selectedValue) return ""; + + 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 [ct, cc] = 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`, + [ct, cc, selectedValueStr] + ); + if (cvResult.rows.length > 0) { + mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true; + if (m.categoryValueLabel === cvResult.rows[0].value_label) return true; + return false; + }); + } + } catch { /* ignore */ } + } + + return mapping?.format || ""; + } + + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + return ""; + } + + default: + return ""; + } + })); + } + + /** + * 대상 테이블 기반으로 실제 최대 시퀀스를 확인하고, + * 카운터와 비교하여 더 높은 값 + 1을 반환 + */ + private async resolveNextSequence( + client: any, + rule: NumberingRuleConfig, + companyCode: string, + ruleId: string, + prefixKey: string, + formData?: Record + ): Promise { + // 1. 현재 저장된 카운터 조회 + const currentCounter = await this.getSequenceForPrefix( + client, ruleId, companyCode, prefixKey + ); + + let baseSequence = currentCounter; + + // 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회 + if (rule.tableName && rule.columnName) { + try { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const patternValues = await this.computeNonSequenceValues(rule, formData); + const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || ""); + + if (psInfo) { + const maxFromTable = await this.getMaxSequenceFromTable( + client, rule.tableName, rule.columnName, + psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode + ); + + if (maxFromTable > baseSequence) { + logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", { + ruleId, companyCode, currentCounter, maxFromTable, + }); + baseSequence = maxFromTable; + } + } + } catch (error: any) { + logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", { + ruleId, error: error.message, + }); + } + } + + // 3. 다음 시퀀스 = base + 1 + const nextSequence = baseSequence + 1; + + // 4. 카운터를 동기화 (GREATEST 사용) + await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence); + + // 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트 + await client.query( + "UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode, nextSequence] + ); + + logger.info("resolveNextSequence 완료", { + ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence, + }); + + return nextSequence; + } + /** * 규칙 목록 조회 (전체) */ @@ -1087,13 +1393,41 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - // prefix_key 기반 순번 조회 + // 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, + // 대상 테이블에서 실제 최대 시퀀스 조회 + let baseSeq = currentSeq; + if (rule.tableName && rule.columnName) { + try { + const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const patternValues = await this.computeNonSequenceValues(rule, formData); + const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || ""); + + if (psInfo) { + const maxFromTable = await this.getMaxSequenceFromTable( + pool, rule.tableName, rule.columnName, + psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode + ); + + if (maxFromTable > baseSeq) { + logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", { + ruleId, companyCode, currentSeq, maxFromTable, + }); + baseSeq = maxFromTable; + } + } + } catch (error: any) { + logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", { + ruleId, error: error.message, + }); + } + } + + logger.info("미리보기: 순번 조회 완료", { + ruleId, prefixKey, currentSeq, baseSeq, }); const parts = await Promise.all(rule.parts @@ -1108,7 +1442,7 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; - const nextSequence = currentSeq + 1; + const nextSequence = baseSeq + 1; return String(nextSequence).padStart(length, "0"); } @@ -1306,20 +1640,15 @@ class NumberingRuleService { 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] + allocatedSequence = await this.resolveNextSequence( + client, rule, companyCode, ruleId, prefixKey, formData ); } - logger.info("allocateCode: prefix_key 기반 순번 할당", { + logger.info("allocateCode: 테이블 기반 순번 할당", { ruleId, prefixKey, allocatedSequence, });