전체 카테고리 키 목록 조회 API 및 관련 기능 추가

- 카테고리 트리 컨트롤러에 전체 카테고리 키 목록 조회 라우트 추가: GET /api/category-tree/test/all-category-keys
- 카테고리 트리 서비스에 전체 카테고리 키 목록 조회 메서드 구현: 모든 테이블과 컬럼 조합을 반환
- 채번규칙 컨트롤러에서 폼 데이터 처리 기능 추가: 코드 미리보기 시 카테고리 기반 폼 데이터 사용
- 관련 API 클라이언트 및 타입 정의 업데이트: 카테고리 키 조회 및 채번규칙 API에 대한 요청 처리 개선

이로 인해 카테고리 관리 및 채번규칙 테스트의 효율성이 향상되었습니다.
This commit is contained in:
kjs
2026-01-21 17:51:59 +09:00
parent ae4e21e1ac
commit 623ade4f28
14 changed files with 1601 additions and 138 deletions

View File

@@ -16,6 +16,31 @@ interface AuthenticatedRequest extends Request {
};
}
/**
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
* GET /api/category-tree/test/all-category-keys
* 주의: 이 라우트는 /test/:tableName/:columnName 보다 먼저 정의되어야 함
*/
router.get("/test/all-category-keys", async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const keys = await categoryTreeService.getAllCategoryKeys(companyCode);
res.json({
success: true,
data: keys,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("전체 카테고리 키 목록 조회 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
* 카테고리 트리 조회
* GET /api/category-tree/test/:tableName/:columnName

View File

@@ -202,9 +202,10 @@ router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, r
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
try {
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode);
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {
logger.error("코드 미리보기 실패", { error: error.message });
@@ -321,4 +322,90 @@ router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, r
}
});
// [테스트] 테스트 테이블에서 채번규칙 삭제
router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try {
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
return res.json({ success: true });
} catch (error: any) {
logger.error("테스트 테이블에서 채번규칙 삭제 실패", {
error: error.message,
companyCode,
ruleId,
});
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 카테고리 조건 포함 채번규칙 조회
router.get("/test/by-column-with-category", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName, categoryColumn, categoryValueId } = req.query;
try {
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({ success: false, error: "tableName is required" });
}
if (!columnName || typeof columnName !== "string") {
return res.status(400).json({ success: false, error: "columnName is required" });
}
const rule = await numberingRuleService.getNumberingRuleByColumnWithCategory(
companyCode,
tableName,
columnName,
categoryColumn as string | undefined,
categoryValueId ? Number(categoryValueId) : undefined
);
if (!rule) {
return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" });
}
return res.json({ success: true, data: rule });
} catch (error: any) {
logger.error("카테고리 조건 포함 채번규칙 조회 실패", {
error: error.message,
companyCode,
tableName,
columnName,
});
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 테이블.컬럼별 모든 채번규칙 조회 (카테고리 조건별)
router.get("/test/rules-by-table-column", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.query;
try {
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({ success: false, error: "tableName is required" });
}
if (!columnName || typeof columnName !== "string") {
return res.status(400).json({ success: false, error: "columnName is required" });
}
const rules = await numberingRuleService.getRulesByTableColumn(
companyCode,
tableName,
columnName
);
return res.json({ success: true, data: rules });
} catch (error: any) {
logger.error("테이블.컬럼별 채번규칙 목록 조회 실패", {
error: error.message,
companyCode,
tableName,
columnName,
});
return res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@@ -507,6 +507,39 @@ class CategoryTreeService {
throw error;
}
}
/**
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
* category_values_test 테이블에서 고유한 table_name, column_name 조합을 조회
* 라벨 정보도 함께 반환
*/
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
logger.info("getAllCategoryKeys 호출", { companyCode });
const pool = getPool();
try {
const query = `
SELECT DISTINCT
cv.table_name AS "tableName",
cv.column_name AS "columnName",
COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
COALESCE(cl.column_label, cv.column_name) AS "columnLabel"
FROM category_values_test cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN column_labels cl ON cl.table_name = cv.table_name AND cl.column_name = cv.column_name
WHERE cv.company_code = $1 OR cv.company_code = '*'
ORDER BY cv.table_name, cv.column_name
`;
const result = await pool.query(query, [companyCode]);
logger.info("전체 카테고리 키 목록 조회 완료", { count: result.rows.length });
return result.rows;
} catch (error: unknown) {
const err = error as Error;
logger.error("전체 카테고리 키 목록 조회 실패", { error: err.message });
throw error;
}
}
}
export const categoryTreeService = new CategoryTreeService();

View File

@@ -29,6 +29,10 @@ interface NumberingRuleConfig {
companyCode?: string;
menuObjid?: number;
scopeType?: string;
// 카테고리 조건
categoryColumn?: string;
categoryValueId?: number;
categoryValueLabel?: string; // 조회 시 조인해서 가져옴
createdAt?: string;
updatedAt?: string;
createdBy?: string;
@@ -882,8 +886,15 @@ class NumberingRuleService {
/**
* 코드 미리보기 (순번 증가 없음)
* @param ruleId 채번 규칙 ID
* @param companyCode 회사 코드
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
*/
async previewCode(ruleId: string, companyCode: string): Promise<string> {
async previewCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
@@ -891,7 +902,8 @@ class NumberingRuleService {
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "";
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력)
return part.manualConfig?.placeholder || "____";
}
const autoConfig = part.autoConfig || {};
@@ -913,10 +925,23 @@ class NumberingRuleService {
case "date": {
// 날짜 (다양한 날짜 형식)
return this.formatDate(
new Date(),
autoConfig.dateFormat || "YYYYMMDD"
);
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
const dateValue = columnValue instanceof Date
? columnValue
: new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
return this.formatDate(dateValue, dateFormat);
}
}
}
return this.formatDate(new Date(), dateFormat);
}
case "text": {
@@ -924,6 +949,71 @@ 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일 수 있음 (UnifiedSelect에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find(
(m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr) return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
// valueCode로 매칭 (라벨과 동일할 수 있음)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
}
);
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 "";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
@@ -931,7 +1021,7 @@ class NumberingRuleService {
});
const previewCode = parts.join(rule.separator || "");
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode });
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData });
return previewCode;
}
@@ -1119,22 +1209,27 @@ class NumberingRuleService {
const pool = getPool();
const query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules_test
WHERE company_code = $1
AND table_name = $2
AND column_name = $3
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
AND r.category_value_id IS NULL
LIMIT 1
`;
const params = [companyCode, tableName, columnName];
@@ -1225,8 +1320,10 @@ class NumberingRuleService {
reset_period = $4,
table_name = $5,
column_name = $6,
category_column = $7,
category_value_id = $8,
updated_at = NOW()
WHERE rule_id = $7 AND company_code = $8
WHERE rule_id = $9 AND company_code = $10
`;
await client.query(updateQuery, [
config.ruleName,
@@ -1235,6 +1332,8 @@ class NumberingRuleService {
config.resetPeriod || "none",
config.tableName || "",
config.columnName || "",
config.categoryColumn || null,
config.categoryValueId || null,
config.ruleId,
companyCode,
]);
@@ -1250,8 +1349,9 @@ class NumberingRuleService {
INSERT INTO numbering_rules_test (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
category_column, category_value_id,
created_at, updated_at, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), $12)
`;
await client.query(insertQuery, [
config.ruleId,
@@ -1263,6 +1363,8 @@ class NumberingRuleService {
config.tableName || "",
config.columnName || "",
companyCode,
config.categoryColumn || null,
config.categoryValueId || null,
createdBy,
]);
}
@@ -1309,6 +1411,266 @@ class NumberingRuleService {
client.release();
}
}
/**
* [테스트] 테스트 테이블에서 채번규칙 삭제
* numbering_rules_test 테이블 사용
*/
async deleteRuleFromTest(ruleId: string, companyCode: string): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode });
// 파트 먼저 삭제
await client.query(
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
// 규칙 삭제
const result = await client.query(
"DELETE FROM numbering_rules_test WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
await client.query("COMMIT");
logger.info("테스트 테이블에서 채번 규칙 삭제 완료", {
ruleId,
companyCode,
deletedCount: result.rowCount,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("테스트 테이블에서 채번 규칙 삭제 실패", {
error: error.message,
ruleId,
companyCode,
});
throw error;
} finally {
client.release();
}
}
/**
* [테스트] 카테고리 값에 따라 적절한 채번규칙 조회
* 1. 해당 카테고리 값에 매칭되는 규칙 찾기
* 2. 없으면 기본 규칙(category_value_id가 NULL인) 찾기
*/
async getNumberingRuleByColumnWithCategory(
companyCode: string,
tableName: string,
columnName: string,
categoryColumn?: string,
categoryValueId?: number
): Promise<NumberingRuleConfig | null> {
try {
logger.info("카테고리 조건 포함 채번 규칙 조회 시작", {
companyCode,
tableName,
columnName,
categoryColumn,
categoryValueId,
});
const pool = getPool();
// 1. 카테고리 값에 매칭되는 규칙 찾기
if (categoryColumn && categoryValueId) {
const categoryQuery = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
AND r.category_column = $4
AND r.category_value_id = $5
LIMIT 1
`;
const categoryResult = await pool.query(categoryQuery, [
companyCode,
tableName,
columnName,
categoryColumn,
categoryValueId,
]);
if (categoryResult.rows.length > 0) {
const rule = categoryResult.rows[0];
// 파트 정보 조회
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
rule.parts = partsResult.rows;
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
ruleId: rule.ruleId,
categoryValueLabel: rule.categoryValueLabel,
});
return rule;
}
}
// 2. 기본 규칙 찾기 (category_value_id가 NULL인)
const defaultQuery = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
AND r.category_value_id IS NULL
LIMIT 1
`;
const defaultResult = await pool.query(defaultQuery, [companyCode, tableName, columnName]);
if (defaultResult.rows.length > 0) {
const rule = defaultResult.rows[0];
// 파트 정보 조회
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
rule.parts = partsResult.rows;
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
ruleId: rule.ruleId,
});
return rule;
}
logger.info("채번 규칙을 찾을 수 없음", {
companyCode,
tableName,
columnName,
categoryColumn,
categoryValueId,
});
return null;
} catch (error: any) {
logger.error("카테고리 조건 포함 채번 규칙 조회 실패", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* [테스트] 특정 테이블.컬럼의 모든 채번 규칙 조회 (카테고리 조건별)
*/
async getRulesByTableColumn(
companyCode: string,
tableName: string,
columnName: string
): Promise<NumberingRuleConfig[]> {
try {
const pool = getPool();
const query = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
ORDER BY r.category_value_id NULLS FIRST, r.created_at
`;
const result = await pool.query(query, [companyCode, tableName, columnName]);
// 각 규칙의 파트 정보 조회
for (const rule of result.rows) {
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
rule.parts = partsResult.rows;
}
return result.rows;
} catch (error: any) {
logger.error("테이블.컬럼별 채번 규칙 목록 조회 실패", {
error: error.message,
});
throw error;
}
}
}
export const numberingRuleService = new NumberingRuleService();

View File

@@ -207,48 +207,27 @@ class TableCategoryValueService {
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
NULL::numeric AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
FROM category_values_test
WHERE table_name = $1
AND column_name = $2
`;
// category_values_test 테이블 사용 (menu_objid 없음)
if (companyCode === "*") {
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 조회
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
params = [tableName, columnName, siblingObjids];
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND menu_objid = $3`;
params = [tableName, columnName, menuObjid];
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
} else {
// menuObjid 없으면 모든 값 조회 (중복 가능)
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
}
// 최고 관리자: 모든 값 조회
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)");
} else {
// 일반 회사: 자신의 회사 + menuObjid로 필터링
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
params = [tableName, columnName, companyCode, siblingObjids];
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
params = [tableName, columnName, companyCode, menuObjid];
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
} else {
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
query = baseSelect + ` AND company_code = $3`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
}
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode });
}
if (!includeInactive) {