jskim-node #418
|
|
@ -311,13 +311,14 @@ router.post(
|
|||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
const { formData, manualInputValue } = req.body;
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
formData,
|
||||
manualInputValue
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -1086,22 +1086,30 @@ class NumberingRuleService {
|
|||
* @param ruleId 채번 규칙 ID
|
||||
* @param companyCode 회사 코드
|
||||
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
* @param manualInputValue 수동 입력 값 (접두어별 순번 조회용)
|
||||
*/
|
||||
async previewCode(
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
formData?: Record<string, any>
|
||||
formData?: Record<string, any>,
|
||||
manualInputValue?: string
|
||||
): Promise<string> {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
// prefix_key 기반 순번 조회
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
|
||||
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
|
||||
const skipSequenceLookup = hasManualPart && !manualInputValue;
|
||||
|
||||
const manualValues = manualInputValue ? [manualInputValue] : undefined;
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
|
||||
const pool = getPool();
|
||||
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
const currentSeq = skipSequenceLookup
|
||||
? 0
|
||||
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
logger.info("미리보기: prefix_key 기반 순번 조회", {
|
||||
ruleId, prefixKey, currentSeq,
|
||||
ruleId, prefixKey, currentSeq, skipSequenceLookup,
|
||||
});
|
||||
|
||||
const parts = await Promise.all(rule.parts
|
||||
|
|
@ -1116,7 +1124,8 @@ class NumberingRuleService {
|
|||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = currentSeq + 1;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const nextSequence = currentSeq + startFrom;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
|
|
@ -1158,110 +1167,8 @@ class NumberingRuleService {
|
|||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
case "category": {
|
||||
// 카테고리 기반 코드 생성
|
||||
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
logger.warn("카테고리 키 또는 폼 데이터 없음", {
|
||||
categoryKey,
|
||||
hasFormData: !!formData,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
|
||||
// 폼 데이터에서 해당 컬럼의 값 가져오기
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
logger.info("카테고리 파트 처리", {
|
||||
categoryKey,
|
||||
columnName,
|
||||
selectedValue,
|
||||
formDataKeys: Object.keys(formData),
|
||||
mappingsCount: categoryMappings.length,
|
||||
});
|
||||
|
||||
if (!selectedValue) {
|
||||
logger.warn("카테고리 값이 선택되지 않음", {
|
||||
columnName,
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭 (폴백)
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [catTableName, catColumnName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_code, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[catTableName, catColumnName, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
const resolvedId = cvResult.rows[0].value_id;
|
||||
const resolvedLabel = cvResult.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
|
||||
valueCode: selectedValueStr,
|
||||
resolvedId,
|
||||
resolvedLabel,
|
||||
format: mapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (lookupError: any) {
|
||||
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
format: mapping.format,
|
||||
categoryValueLabel: mapping.categoryValueLabel,
|
||||
});
|
||||
return mapping.format || "";
|
||||
}
|
||||
|
||||
logger.warn("카테고리 매핑을 찾을 수 없음", {
|
||||
selectedValue,
|
||||
availableMappings: categoryMappings.map((m: any) => ({
|
||||
id: m.categoryValueId,
|
||||
label: m.categoryValueLabel,
|
||||
})),
|
||||
});
|
||||
return "";
|
||||
}
|
||||
case "category":
|
||||
return this.resolveCategoryFormat(autoConfig, formData);
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
|
|
@ -1364,7 +1271,9 @@ class NumberingRuleService {
|
|||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return String(allocatedSequence).padStart(length, "0");
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const actualSequence = allocatedSequence + startFrom - 1;
|
||||
return String(actualSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
|
|
@ -1401,65 +1310,14 @@ class NumberingRuleService {
|
|||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
case "category": {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
if (!selectedValue) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let allocMapping = 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 (!allocMapping) {
|
||||
try {
|
||||
const pool3 = getPool();
|
||||
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvr3 = await pool3.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct3, cc3, selectedValueStr]
|
||||
);
|
||||
if (cvr3.rows.length > 0) {
|
||||
const rid3 = cvr3.rows[0].value_id;
|
||||
const rlabel3 = cvr3.rows[0].value_label;
|
||||
allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid3)) return true;
|
||||
if (m.categoryValueLabel === rlabel3) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (allocMapping) {
|
||||
return allocMapping.format || "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
case "category":
|
||||
return this.resolveCategoryFormat(autoConfig, formData);
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
@ -1525,57 +1383,12 @@ class NumberingRuleService {
|
|||
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 "category":
|
||||
return this.resolveCategoryFormat(autoConfig, formData);
|
||||
case "reference": {
|
||||
const refCol2 = autoConfig.referenceColumnName;
|
||||
if (refCol2 && formData && formData[refCol2]) {
|
||||
return String(formData[refCol2]);
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
@ -1622,6 +1435,60 @@ class NumberingRuleService {
|
|||
return extractedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 매핑에서 format 값을 해석
|
||||
* categoryKey + formData로 선택된 값을 찾고, 매핑 테이블에서 format 반환
|
||||
*/
|
||||
private async resolveCategoryFormat(
|
||||
autoConfig: Record<string, any>,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) return "";
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [tableName, colName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const result = 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`,
|
||||
[tableName, colName, selectedValueStr]
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
const resolvedId = result.rows[0].value_id;
|
||||
const resolvedLabel = result.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return mapping?.format || "";
|
||||
}
|
||||
|
||||
private formatDate(date: Date, format: string): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
|
|
|||
|
|
@ -194,17 +194,17 @@ sequenceDiagram
|
|||
|
||||
| 파일 | 변경 내용 | 규모 |
|
||||
|------|----------|------|
|
||||
| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey`에 `manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경 | ~60줄 |
|
||||
| `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건 |
|
||||
|
||||
> `TextInputComponent.tsx` 변경 불필요. `_numberingRuleId`가 유지되고 있으며, 수동 값 추출도 정상 동작 확인됨.
|
||||
> 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요.
|
||||
|
||||
### buildPrefixKey 호출부 영향 분석
|
||||
|
||||
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|
||||
|--------|------|---------------------|------|
|
||||
| `previewCode` | L1091 | 미전달 (undefined) | 변화 없음 |
|
||||
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
|
||||
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
|
||||
|
||||
### 멀티테넌시 체크
|
||||
|
|
@ -367,3 +367,54 @@ WHERE generation_method = 'manual'
|
|||
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
|
||||
- `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줄 감소)
|
||||
|
|
|
|||
|
|
@ -120,6 +120,37 @@
|
|||
변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공
|
||||
```
|
||||
|
||||
### 10. 실시간 순번 미리보기 구현 방식
|
||||
|
||||
- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신
|
||||
- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함
|
||||
- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합)
|
||||
- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일
|
||||
|
||||
### 11. previewCode에 manualInputValue 전달
|
||||
|
||||
- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey`에 `[manualInputValue]`로 전달
|
||||
- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함
|
||||
- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음
|
||||
|
||||
### 12. 초기 상태에서 레거시 시퀀스 조회 방지
|
||||
|
||||
- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용
|
||||
- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함
|
||||
- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5` → `-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회
|
||||
|
||||
### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회
|
||||
|
||||
- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달
|
||||
- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨
|
||||
- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동
|
||||
|
||||
### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합
|
||||
|
||||
- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출
|
||||
- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험
|
||||
- **원칙**: 구조적 변경만 수행 (로직 변경 없음)
|
||||
|
||||
### BULK1이 DB에 남아있는 이유
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
## 공정 상태
|
||||
|
||||
- 전체 진행률: **85%** (코드 구현 + DB 마이그레이션 완료, 검증 대기)
|
||||
- 전체 진행률: **95%** (코드 구현 + DB 마이그레이션 + 실시간 미리보기 + 코드 정리 완료, 검증 대기)
|
||||
- 현재 단계: 검증 대기
|
||||
|
||||
---
|
||||
|
|
@ -53,9 +53,24 @@
|
|||
- [ ] previewCode (미리보기) 동작 영향 없음 확인
|
||||
- [ ] BULK1이 더 이상 생성되지 않음 확인
|
||||
|
||||
### 7단계: 정리
|
||||
### 7단계: 실시간 순번 미리보기
|
||||
|
||||
- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가
|
||||
- [x] 백엔드 서비스: `previewCode`에 `manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달
|
||||
- [x] 프론트엔드 API: `previewNumberingCode`에 `manualInputValue` 파라미터 추가
|
||||
- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신
|
||||
- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시
|
||||
- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영
|
||||
- [x] 린트 에러 없음 확인
|
||||
|
||||
### 8단계: 코드 정리
|
||||
|
||||
- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소)
|
||||
- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거)
|
||||
- [x] 린트 에러 없음 확인
|
||||
|
||||
### 9단계: 정리
|
||||
|
||||
- [x] 계획서/맥락노트/체크리스트 최신화
|
||||
|
||||
---
|
||||
|
|
@ -77,3 +92,8 @@
|
|||
| 2026-03-11 | 1-4단계 구현 완료 |
|
||||
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
|
||||
| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 |
|
||||
| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) |
|
||||
| 2026-03-12 | 계맥체 최신화 완료 |
|
||||
| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 |
|
||||
| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 |
|
||||
| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 |
|
||||
|
|
|
|||
|
|
@ -676,7 +676,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
|
||||
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
|
||||
const currentFormData = formDataRef.current;
|
||||
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
|
||||
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined);
|
||||
|
||||
if (previewResponse.success && previewResponse.data?.generatedCode) {
|
||||
const generatedCode = previewResponse.data.generatedCode;
|
||||
|
|
@ -764,6 +764,49 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
};
|
||||
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
|
||||
|
||||
// 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신
|
||||
useEffect(() => {
|
||||
const inputType = propsInputType || config.inputType || config.type || "text";
|
||||
if (inputType !== "numbering") return;
|
||||
if (!numberingTemplateRef.current?.includes("____")) return;
|
||||
|
||||
const ruleId = numberingRuleIdRef.current;
|
||||
if (!ruleId) return;
|
||||
|
||||
// 사용자가 한 번도 입력하지 않은 초기 상태면 스킵
|
||||
if (!userEditedNumberingRef.current) return;
|
||||
|
||||
const debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const currentFormData = formDataRef.current;
|
||||
const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined);
|
||||
|
||||
if (resp.success && resp.data?.generatedCode) {
|
||||
const newTemplate = resp.data.generatedCode;
|
||||
if (newTemplate.includes("____")) {
|
||||
numberingTemplateRef.current = newTemplate;
|
||||
|
||||
const parts = newTemplate.split("____");
|
||||
const prefix = parts[0] || "";
|
||||
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
|
||||
const combined = prefix + manualInputValue + suffix;
|
||||
|
||||
setAutoGeneratedValue(combined);
|
||||
onChange?.(combined);
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, combined);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 미리보기 실패 시 기존 suffix 유지 */
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(debounceTimer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualInputValue]);
|
||||
|
||||
// 실제 표시할 값 (자동생성 값 또는 props value)
|
||||
const displayValue = autoGeneratedValue ?? value;
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
|
|||
export async function previewNumberingCode(
|
||||
ruleId: string,
|
||||
formData?: Record<string, unknown>,
|
||||
manualInputValue?: string,
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
// ruleId 유효성 검사
|
||||
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
|
||||
|
|
@ -114,6 +115,7 @@ export async function previewNumberingCode(
|
|||
try {
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
|
||||
formData: formData || {},
|
||||
manualInputValue,
|
||||
});
|
||||
if (!response.data) {
|
||||
return { success: false, error: "서버 응답이 비어있습니다" };
|
||||
|
|
|
|||
Loading…
Reference in New Issue