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 보류 |