From 8b7e31031dfccded90359f6b93b6715d42c96d34 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Thu, 12 Mar 2026 10:12:56 +0900 Subject: [PATCH] refactor: Improve numbering rule service for manual prefix handling and sequence allocation - Modified the `buildPrefixKey` function to include an optional `manualValues` parameter, allowing manual input values to be incorporated into the prefix key. - Adjusted the sequence allocation process in `allocateCode` to extract manual values before building the prefix key, ensuring accurate prefix generation. - Removed the fallback to the "BULK1" value in manual configurations, preventing unintended overwrites and ensuring user input is prioritized. - Enhanced the `joinPartsWithSeparators` function to prevent consecutive separators when handling empty parts, improving the output format. - Added a new migration script to clean up existing "BULK1" values from the database, ensuring data integrity. These changes address several issues related to manual input handling and improve the overall functionality of the numbering rule service. --- .../src/services/numberingRuleService.ts | 287 ++++++++------ .../MPN[계획]-품번-수동접두어채번.md | 369 ++++++++++++++++++ .../MPN[맥락]-품번-수동접두어채번.md | 130 ++++++ .../MPN[체크]-품번-수동접두어채번.md | 79 ++++ 4 files changed, 736 insertions(+), 129 deletions(-) create mode 100644 docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md create mode 100644 docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md create mode 100644 docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 91ae4cb5..f4175b9d 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa result += val; if (idx < partValues.length - 1) { const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; - result += sep; + if (val || !result.endsWith(sep)) { + result += sep; + } } }); return result; @@ -74,16 +76,22 @@ class NumberingRuleService { */ private async buildPrefixKey( rule: NumberingRuleConfig, - formData?: Record + formData?: Record, + manualValues?: string[] ): Promise { const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); const prefixParts: string[] = []; + let manualIndex = 0; for (const part of sortedParts) { if (part.partType === "sequence") continue; if (part.generationMethod === "manual") { - // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) + const manualValue = manualValues?.[manualIndex] || ""; + manualIndex++; + if (manualValue) { + prefixParts.push(manualValue); + } continue; } @@ -1302,11 +1310,29 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - // prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성 - const prefixKey = await this.buildPrefixKey(rule, formData); + // 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능) + const manualParts = rule.parts.filter( + (p: any) => p.generationMethod === "manual" + ); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + extractedManualValues = await this.extractManualValuesFromInput( + rule, userInputCode, formData + ); + + // 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만) + if (extractedManualValues.length === 0 && manualParts.length === 1) { + extractedManualValues = [userInputCode]; + logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode }); + } + } + + // 2단계: prefix_key 빌드 (수동 값 포함) + const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); - // 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 + // 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 let allocatedSequence = 0; if (hasSequence) { allocatedSequence = await this.incrementSequenceForPrefix( @@ -1320,136 +1346,15 @@ class NumberingRuleService { } logger.info("allocateCode: prefix_key 기반 순번 할당", { - ruleId, prefixKey, allocatedSequence, + ruleId, prefixKey, allocatedSequence, extractedManualValues, }); - // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 - 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 || - ""; + const manualValue = extractedManualValues[manualPartIndex] || ""; manualPartIndex++; return manualValue; } @@ -1593,6 +1498,130 @@ class NumberingRuleService { return this.allocateCode(ruleId, companyCode); } + /** + * 사용자 입력 코드에서 수동 파트 값을 추출 + * 템플릿 기반 파싱으로 수동 입력 위치("____")에 해당하는 값을 분리 + */ + private async extractManualValuesFromInput( + rule: NumberingRuleConfig, + userInputCode: string, + formData?: Record + ): Promise { + const extractedValues: string[] = []; + + 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 ""; + } + + const colName2 = catKey2.includes(".") + ? catKey2.split(".")[1] + : catKey2; + const selVal2 = formData[colName2]; + + if (!selVal2) { + return ""; + } + + 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 || ""; + } + case "reference": { + const refCol2 = autoConfig.referenceColumnName; + if (refCol2 && formData && formData[refCol2]) { + return String(formData[refCol2]); + } + return ""; + } + 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) { + extractedValues.push( + remainingCode.slice(0, manualEndIndex) + ); + remainingCode = remainingCode.slice(manualEndIndex); + } + } else { + extractedValues.push(remainingCode); + } + } + } + + logger.info( + `수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}` + ); + + return extractedValues; + } + private formatDate(date: Date, format: string): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); diff --git a/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md new file mode 100644 index 00000000..b3337f9e --- /dev/null +++ b/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md @@ -0,0 +1,369 @@ +# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md) + +## 개요 + +기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다. + +1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐 +2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함 +3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨 + +--- + +## 현재 동작 + +### 채번 규칙 구성 (옵션설정 > 코드설정) + +``` +규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5) +``` + +### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시) + +1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128) +2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"` +3. 저장 클릭 → `buttonActions.ts`가 `_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출 +4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치) +5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨** +6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용 +7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터) + +### 문제 1: 순번 공유 (buildPrefixKey) + +**위치**: `numberingRuleService.ts` L85-88 + +```typescript +if (part.generationMethod === "manual") { + // 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) + continue; // ← 접두어별 순번 분리를 막는 원인 +} +``` + +이 `continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다. +"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다. + +### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백) + +**발생 흐름**: + +1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출 +2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442) +3. 템플릿 기반 수동 값 추출 시도 (L1411-1436): + ``` + 템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번) + 사용자 입력: "ㅁㅁㅁ" + ``` +4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패** → `extractedManualValues = []` +5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작: + ```typescript + const manualValue = + extractedManualValues[0] || // undefined (추출 실패) + part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐 + ""; + ``` +6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨) + +**DB 숨은 값 원인**: +- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨 +- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음** +- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨 + +### 문제 3: 연속 구분자(--) 문제 + +**발생 흐름**: + +1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열) +2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생 +3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`) +4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용) +5. 입력 `-제발-015`이 `CATEGORY-`로 시작하지 않음 → 추출 실패 +6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨 +7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003` + +### 정상 동작 확인된 부분 + +| 항목 | 상태 | 근거 | +|------|------|------| +| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 | +| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) | +| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 | + +### 비정상 확인된 부분 + +| 항목 | 상태 | 근거 | +|------|------|------| +| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 | +| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 | +| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 | + +--- + +## 변경 후 동작 + +### prefix_key에 수동 파트 값 포함 + +``` +현재: prefix_key = 카테고리값만 (수동 파트 무시) +변경: prefix_key = 카테고리값 + "|" + 수동입력값 +``` + +### allocateCode 실행 순서 변경 + +``` +현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합 +변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합 +``` + +### 순번 동작 + +``` +"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001 +"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002 +"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001 +"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003 +``` + +### BULK1 폴백 제거 (코드 + DB 이중 조치) + +``` +코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용 +DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리 +``` + +### 연속 구분자 방지 + 템플릿 정합성 복원 + +``` +joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음 +extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신) +→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지 +``` + +--- + +## 시각적 예시 + +| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 | +|------------|----------|------|-------------| +| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` | +| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` | +| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` | +| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) | +| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` | + +--- + +## 아키텍처 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant BA as buttonActions.ts + participant API as allocateNumberingCode API + participant NRS as numberingRuleService + participant DB as numbering_rule_sequences + + User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ") + BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData) + API->>NRS: allocateCode() + + Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행) + NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ") + Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용 + NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"] + + Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함) + NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"]) + Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ" + + Note over NRS: 3단계: 시퀀스 할당 + NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ") + DB-->>NRS: current_sequence = 1 + + Note over NRS: 4단계: 코드 조합 + NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001" + NRS-->>API: "카테고리값-ㅁㅁㅁ-001" + API-->>BA: generatedCode + BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001" +``` + +--- + +## 변경 대상 파일 + +| 파일 | 변경 내용 | 규모 | +|------|----------|------| +| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey`에 `manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경 | ~60줄 | +| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 | + +> `TextInputComponent.tsx` 변경 불필요. `_numberingRuleId`가 유지되고 있으며, 수동 값 추출도 정상 동작 확인됨. +> 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요. + +### buildPrefixKey 호출부 영향 분석 + +| 호출부 | 위치 | `manualValues` 전달 | 영향 | +|--------|------|---------------------|------| +| `previewCode` | L1091 | 미전달 (undefined) | 변화 없음 | +| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 | + +### 멀티테넌시 체크 + +| 항목 | 상태 | 근거 | +|------|------|------| +| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 | +| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 | +| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 | +| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 | + +--- + +## 코드 설계 + +### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지 + +**위치**: L36-48 +**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음 + +```typescript +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; + if (val || !result.endsWith(sep)) { + result += sep; + } + } + }); + return result; +} +``` + +### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함 + +**위치**: L75-88 +**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함. + +```typescript +private async buildPrefixKey( + rule: NumberingRuleConfig, + formData?: Record, + manualValues?: string[] +): Promise { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const prefixParts: string[] = []; + let manualIndex = 0; + + for (const part of sortedParts) { + if (part.partType === "sequence") continue; + + if (part.generationMethod === "manual") { + const manualValue = manualValues?.[manualIndex] || ""; + manualIndex++; + if (manualValue) { + prefixParts.push(manualValue); + } + continue; + } + + // ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ... + } + + return prefixParts.join("|"); +} +``` + +**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음. + +### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리 + +**위치**: L1290-1584 +**핵심 변경 2가지**: + +(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경. + +(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거. + +```typescript +async allocateCode(ruleId, companyCode, formData?, userInputCode?) { + // ... 규칙 조회 ... + + // 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행) + const manualParts = rule.parts.filter(p => p.generationMethod === "manual"); + let extractedManualValues: string[] = []; + + if (manualParts.length > 0 && userInputCode) { + extractedManualValues = await this.extractManualValuesFromInput( + rule, userInputCode, formData + ); + + // 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용 + if (extractedManualValues.length === 0 && manualParts.length === 1) { + extractedManualValues = [userInputCode]; + } + } + + // 2단계: 수동 값을 포함하여 prefix_key 빌드 + const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); + + // 3단계: 시퀀스 할당 (기존 로직 그대로) + + // 4단계: 코드 조합 (manualConfig.value 폴백 제거) + // 기존: extractedManualValues[i] || part.manualConfig?.value || "" + // 변경: extractedManualValues[i] || "" +} +``` + +### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원 + +기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출. +로직 자체는 변경 없음, 위치만 이동. +카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴. + +```typescript +private async extractManualValuesFromInput( + rule: NumberingRuleConfig, + userInputCode: string, + formData?: Record +): Promise { + // 기존 L1332-1442의 로직을 그대로 이동 + // 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환 + // → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상 +} +``` + +### 5. DB 마이그레이션 - BULK1 유령 기본값 제거 + +**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql` + +`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다. + +```sql +-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리) +UPDATE numbering_rule_parts +SET manual_config = manual_config - 'value' +WHERE generation_method = 'manual' + AND manual_config ? 'value' + AND manual_config->>'value' = 'BULK1'; +``` + +> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거. +> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨. +> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보. + +--- + +## 설계 원칙 + +- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건 +- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리 +- `buildPrefixKey`의 `manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음 +- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님 +- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음 +- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지 +- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름) +- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요 +- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지 +- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상 diff --git a/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md new file mode 100644 index 00000000..9ff76513 --- /dev/null +++ b/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md @@ -0,0 +1,130 @@ +# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md) + +--- + +## 왜 이 작업을 하는가 + +- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨 +- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함 +- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함 +- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 수동 값 추출을 buildPrefixKey 전으로 이동 + +- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경 +- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함 +- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향) + +### 2. buildPrefixKey에 수동 파트 값 포함 + +- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함 +- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함 +- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음 + +### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 + +- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용 +- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임 +- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음) + +### 4. 코드 조합에서 manualConfig.value 폴백 제거 + +- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""` → `extractedManualValues[i] || ""` +- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx`에 `value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨 +- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리 + +### 5. DB 마이그레이션은 BULK1만 타겟팅 + +- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정 +- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지 +- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요) + +### 6. extractManualValuesFromInput 헬퍼 분리 + +- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출 +- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움 +- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리) + +### 7. 프론트엔드 변경 불필요 + +- **결정**: 프론트엔드 코드 수정 없음 +- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음 + +### 8. joinPartsWithSeparators 연속 구분자 방지 + +- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음 +- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지 +- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵 + +### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경 + +- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"` → `""`로 변경 +- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]` +- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) | +| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 | +| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 | +| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 | +| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 | +| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 | + +--- + +## 기술 참고 + +### allocateCode 실행 순서 (변경 전 → 후) + +``` +변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합 +변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합 +``` + +### prefix_key 구성 (변경 전 → 후) + +``` +변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키) +변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키) +``` + +### 폴백 체인 (변경 전 → 후) + +``` +변경 전: extractedManualValues[i] || manualConfig.value || "" +변경 후: extractedManualValues[i] || "" +``` + +### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후) + +``` +변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ" +변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ" +``` + +### 템플릿 정합성 (변경 전 → 후) + +``` +변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패 +변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공 +``` + +### BULK1이 DB에 남아있는 이유 + +``` +ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음) +플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue } +→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨 +→ UI에서 제거 불가능한 유령 값 +``` diff --git a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md new file mode 100644 index 00000000..803c679e --- /dev/null +++ b/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md @@ -0,0 +1,79 @@ +# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성 + +> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) + +--- + +## 공정 상태 + +- 전체 진행률: **85%** (코드 구현 + DB 마이그레이션 완료, 검증 대기) +- 현재 단계: 검증 대기 + +--- + +## 구현 체크리스트 + +### 1단계: 구조적 변경 (행위 변경 없음) + +- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리 +- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체 +- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음) + +### 2단계: buildPrefixKey 수정 + +- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가 +- [x] 수동 파트 처리 로직 변경: `continue` → `manualValues`에서 값 꺼내 `prefixParts`에 추가 +- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터) + +### 3단계: allocateCode 순서 변경 + 폴백 정리 + +- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동 +- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가 +- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달 +- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거 + +### 4단계: DB 마이그레이션 + +- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성 +- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거 +- [x] 마이그레이션 실행 (9건 정리 완료) + +### 5단계: 연속 구분자(--) 방지 + +- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가 +- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성) + +### 6단계: 검증 + +- [ ] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인 +- [ ] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님) +- [ ] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인 +- [ ] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인 +- [ ] 수동 파트 없는 채번 규칙 동작 영향 없음 확인 +- [ ] previewCode (미리보기) 동작 영향 없음 확인 +- [ ] BULK1이 더 이상 생성되지 않음 확인 + +### 7단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 계획서/맥락노트/체크리스트 최신화 + +--- + +## 알려진 이슈 (보류) + +| 이슈 | 설명 | 상태 | +|------|------|------| +| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 | +| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 | + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-11 | 1-4단계 구현 완료 | +| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) | +| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 |