# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성

> 관련 문서: [맥락노트](./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` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode`에 `manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 |
| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 |
| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode`에 `manualInputValue` 파라미터 추가 | ~3줄 |
| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 |
| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 |

### buildPrefixKey 호출부 영향 분석

| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|--------|------|---------------------|------|
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
| `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<string, any>,
  manualValues?: string[]
): Promise<string> {
  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<string, any>
): Promise<string[]> {
  // 기존 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`는 연속 구분자만 방지, 기존 구분자 구조 유지
- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상

---

## 실시간 순번 미리보기 (추가 기능)

### 배경

품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함.

### 목표 동작

```
모달 열림           : -[입력하시오]-005   (startFrom=5 기반 기본 순번)
"ㅇㅇ" 입력         : -[ㅇㅇ]-005        (기존 "ㅇㅇ" 등록 0건)
저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006        (기존 "ㅇㅇ" 등록 1건)
```

### 아키텍처

```mermaid
sequenceDiagram
    participant User as 사용자
    participant V2 as V2Input
    participant API as previewNumberingCode
    participant BE as numberingRuleService.previewCode
    participant DB as numbering_rule_sequences

    User->>V2: 수동 입력 "ㅇㅇ"
    Note over V2: 디바운스 300ms
    V2->>API: preview(ruleId, formData, "ㅇㅇ")
    API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ")
    BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"])
    Note over BE: prefix_key = "카테고리|ㅇㅇ"
    BE->>DB: getSequenceForPrefix(prefix_key)
    DB-->>BE: currentSeq = 0
    Note over BE: nextSequence = 0 + startFrom(5) = 5
    BE-->>API: "-____-005"
    API-->>V2: generatedCode
    V2->>V2: suffix = "-005" 갱신
    Note over V2: 화면 표시: -[ㅇㅇ]-005
```

### 변경 내용

1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신
2. **백엔드 서비스**: `previewCode`가 `manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회
3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시
4. **프론트엔드 API**: `previewNumberingCode`에 `manualInputValue` 파라미터 추가
5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트
6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영
7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소)
