feat: implement real-time numbering preview with manual input handling
- Enhanced the `previewCode` endpoint to accept a new `manualInputValue` parameter, allowing for dynamic sequence generation based on user input. - Updated the `NumberingRuleService` to skip legacy sequence lookups when manual input is not provided, ensuring accurate initial sequence display. - Integrated debounce functionality in the `V2Input` component to optimize API calls for real-time suffix updates as users type. - Refactored category resolution logic into a helper function to reduce code duplication and improve maintainability. These changes significantly improve the user experience by providing immediate feedback on numbering sequences based on manual inputs.
This commit is contained in:
parent
93c6c45ce8
commit
1a11b08487
|
|
@ -311,13 +311,14 @@ router.post(
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { ruleId } = req.params;
|
const { ruleId } = req.params;
|
||||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
const { formData, manualInputValue } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const previewCode = await numberingRuleService.previewCode(
|
const previewCode = await numberingRuleService.previewCode(
|
||||||
ruleId,
|
ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
formData
|
formData,
|
||||||
|
manualInputValue
|
||||||
);
|
);
|
||||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -1086,22 +1086,30 @@ class NumberingRuleService {
|
||||||
* @param ruleId 채번 규칙 ID
|
* @param ruleId 채번 규칙 ID
|
||||||
* @param companyCode 회사 코드
|
* @param companyCode 회사 코드
|
||||||
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
|
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||||
|
* @param manualInputValue 수동 입력 값 (접두어별 순번 조회용)
|
||||||
*/
|
*/
|
||||||
async previewCode(
|
async previewCode(
|
||||||
ruleId: string,
|
ruleId: string,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
formData?: Record<string, any>
|
formData?: Record<string, any>,
|
||||||
|
manualInputValue?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const rule = await this.getRuleById(ruleId, companyCode);
|
const rule = await this.getRuleById(ruleId, companyCode);
|
||||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
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 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 기반 순번 조회", {
|
logger.info("미리보기: prefix_key 기반 순번 조회", {
|
||||||
ruleId, prefixKey, currentSeq,
|
ruleId, prefixKey, currentSeq, skipSequenceLookup,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parts = await Promise.all(rule.parts
|
const parts = await Promise.all(rule.parts
|
||||||
|
|
@ -1116,7 +1124,8 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
const nextSequence = currentSeq + 1;
|
const startFrom = autoConfig.startFrom || 1;
|
||||||
|
const nextSequence = currentSeq + startFrom;
|
||||||
return String(nextSequence).padStart(length, "0");
|
return String(nextSequence).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1158,110 +1167,8 @@ class NumberingRuleService {
|
||||||
return autoConfig.textValue || "TEXT";
|
return autoConfig.textValue || "TEXT";
|
||||||
}
|
}
|
||||||
|
|
||||||
case "category": {
|
case "category":
|
||||||
// 카테고리 기반 코드 생성
|
return this.resolveCategoryFormat(autoConfig, formData);
|
||||||
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 "reference": {
|
case "reference": {
|
||||||
const refColumn = autoConfig.referenceColumnName;
|
const refColumn = autoConfig.referenceColumnName;
|
||||||
|
|
@ -1364,7 +1271,9 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
const length = autoConfig.sequenceLength || 3;
|
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": {
|
case "number": {
|
||||||
|
|
@ -1401,65 +1310,14 @@ class NumberingRuleService {
|
||||||
return autoConfig.textValue || "TEXT";
|
return autoConfig.textValue || "TEXT";
|
||||||
}
|
}
|
||||||
|
|
||||||
case "category": {
|
case "category":
|
||||||
const categoryKey = autoConfig.categoryKey;
|
return this.resolveCategoryFormat(autoConfig, formData);
|
||||||
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 "reference": {
|
case "reference": {
|
||||||
const refColumn = autoConfig.referenceColumnName;
|
const refColumn = autoConfig.referenceColumnName;
|
||||||
if (refColumn && formData && formData[refColumn]) {
|
if (refColumn && formData && formData[refColumn]) {
|
||||||
return String(formData[refColumn]);
|
return String(formData[refColumn]);
|
||||||
}
|
}
|
||||||
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1525,57 +1383,12 @@ class NumberingRuleService {
|
||||||
return autoConfig.textValue || "";
|
return autoConfig.textValue || "";
|
||||||
case "date":
|
case "date":
|
||||||
return "DATEPART";
|
return "DATEPART";
|
||||||
case "category": {
|
case "category":
|
||||||
const catKey2 = autoConfig.categoryKey;
|
return this.resolveCategoryFormat(autoConfig, formData);
|
||||||
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": {
|
case "reference": {
|
||||||
const refCol2 = autoConfig.referenceColumnName;
|
const refColumn = autoConfig.referenceColumnName;
|
||||||
if (refCol2 && formData && formData[refCol2]) {
|
if (refColumn && formData && formData[refColumn]) {
|
||||||
return String(formData[refCol2]);
|
return String(formData[refColumn]);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -1622,6 +1435,60 @@ class NumberingRuleService {
|
||||||
return extractedValues;
|
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 {
|
private formatDate(date: Date, format: string): string {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
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건 |
|
| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 |
|
||||||
|
|
||||||
> `TextInputComponent.tsx` 변경 불필요. `_numberingRuleId`가 유지되고 있으며, 수동 값 추출도 정상 동작 확인됨.
|
|
||||||
> 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요.
|
|
||||||
|
|
||||||
### buildPrefixKey 호출부 영향 분석
|
### buildPrefixKey 호출부 영향 분석
|
||||||
|
|
||||||
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|
||||||
|--------|------|---------------------|------|
|
|--------|------|---------------------|------|
|
||||||
| `previewCode` | L1091 | 미전달 (undefined) | 변화 없음 |
|
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
|
||||||
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
|
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
|
||||||
|
|
||||||
### 멀티테넌시 체크
|
### 멀티테넌시 체크
|
||||||
|
|
@ -367,3 +367,54 @@ WHERE generation_method = 'manual'
|
||||||
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
|
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
|
||||||
- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지
|
- `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" → 일치 → 추출 성공
|
변경 후: 카테고리 비었을 때 템플릿 = "-____-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에 남아있는 이유
|
### BULK1이 DB에 남아있는 이유
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
## 공정 상태
|
## 공정 상태
|
||||||
|
|
||||||
- 전체 진행률: **85%** (코드 구현 + DB 마이그레이션 완료, 검증 대기)
|
- 전체 진행률: **95%** (코드 구현 + DB 마이그레이션 + 실시간 미리보기 + 코드 정리 완료, 검증 대기)
|
||||||
- 현재 단계: 검증 대기
|
- 현재 단계: 검증 대기
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -53,9 +53,24 @@
|
||||||
- [ ] previewCode (미리보기) 동작 영향 없음 확인
|
- [ ] previewCode (미리보기) 동작 영향 없음 확인
|
||||||
- [ ] BULK1이 더 이상 생성되지 않음 확인
|
- [ ] 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] 린트 에러 없음 확인
|
- [x] 린트 에러 없음 확인
|
||||||
|
|
||||||
|
### 8단계: 코드 정리
|
||||||
|
|
||||||
|
- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소)
|
||||||
|
- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거)
|
||||||
|
- [x] 린트 에러 없음 확인
|
||||||
|
|
||||||
|
### 9단계: 정리
|
||||||
|
|
||||||
- [x] 계획서/맥락노트/체크리스트 최신화
|
- [x] 계획서/맥락노트/체크리스트 최신화
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -77,3 +92,8 @@
|
||||||
| 2026-03-11 | 1-4단계 구현 완료 |
|
| 2026-03-11 | 1-4단계 구현 완료 |
|
||||||
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
|
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
|
||||||
| 2026-03-11 | 계맥체 최신화 완료. 문제 4-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 전달)
|
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
|
||||||
const currentFormData = formDataRef.current;
|
const currentFormData = formDataRef.current;
|
||||||
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
|
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined);
|
||||||
|
|
||||||
if (previewResponse.success && previewResponse.data?.generatedCode) {
|
if (previewResponse.success && previewResponse.data?.generatedCode) {
|
||||||
const generatedCode = 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]);
|
}, [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)
|
// 실제 표시할 값 (자동생성 값 또는 props value)
|
||||||
const displayValue = autoGeneratedValue ?? value;
|
const displayValue = autoGeneratedValue ?? value;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
|
||||||
export async function previewNumberingCode(
|
export async function previewNumberingCode(
|
||||||
ruleId: string,
|
ruleId: string,
|
||||||
formData?: Record<string, unknown>,
|
formData?: Record<string, unknown>,
|
||||||
|
manualInputValue?: string,
|
||||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||||
// ruleId 유효성 검사
|
// ruleId 유효성 검사
|
||||||
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
|
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
|
||||||
|
|
@ -114,6 +115,7 @@ export async function previewNumberingCode(
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
|
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
|
||||||
formData: formData || {},
|
formData: formData || {},
|
||||||
|
manualInputValue,
|
||||||
});
|
});
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
return { success: false, error: "서버 응답이 비어있습니다" };
|
return { success: false, error: "서버 응답이 비어있습니다" };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue