Files
vexplor/backend-node/src/services/numberingRuleService.ts
kjs 4d6783e508 feat: Implement automatic serial number generation and reference handling in mold management
- Enhanced the `createMoldSerial` function to automatically generate serial numbers based on defined numbering rules when the serial number is not provided.
- Integrated error handling for the automatic numbering process, ensuring robust logging for success and failure cases.
- Updated the `NumberingRuleService` to support reference column handling, allowing for dynamic prefix generation based on related data.
- Modified the frontend components to accommodate new reference configurations, improving user experience in managing numbering rules.

These changes significantly enhance the mold management functionality by automating serial number generation and improving the flexibility of numbering rules.
2026-03-09 15:34:31 +09:00

2509 lines
81 KiB
TypeScript

/**
* 채번 규칙 관리 서비스
*/
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { getMenuAndChildObjids } from "./menuService";
interface NumberingRulePart {
id?: number;
order: number;
partType: string;
generationMethod: string;
autoConfig?: any;
manualConfig?: any;
generatedValue?: string;
separatorAfter?: string;
}
/**
* 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출
*/
function extractSeparatorAfterFromParts(parts: any[]): any[] {
return parts.map((part) => {
if (part.autoConfig?.separatorAfter !== undefined) {
part.separatorAfter = part.autoConfig.separatorAfter;
}
return part;
});
}
/**
* 파트별 개별 구분자를 사용하여 코드 결합
* 마지막 파트의 separatorAfter는 무시됨
*/
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;
result += sep;
}
});
return result;
}
interface NumberingRuleConfig {
ruleId: string;
ruleName: string;
description?: string;
parts: NumberingRulePart[];
separator?: string;
resetPeriod?: string;
currentSequence?: number;
tableName?: string;
columnName?: string;
companyCode?: string;
menuObjid?: number;
scopeType?: string;
// 카테고리 조건
categoryColumn?: string;
categoryValueId?: number;
categoryValueLabel?: string; // 조회 시 조인해서 가져옴
createdAt?: string;
updatedAt?: string;
createdBy?: string;
}
class NumberingRuleService {
/**
* 순번(sequence) 파트를 제외한 나머지 파트 값들을 조합해 prefix_key 생성
* 이 키가 같으면 같은 순번 계열, 다르면 001부터 재시작
*/
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
continue;
}
const autoConfig = (part as any).autoConfig || {};
switch (part.partType) {
case "date": {
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())) {
prefixParts.push(this.formatDate(dateValue, dateFormat));
break;
}
}
}
prefixParts.push(this.formatDate(new Date(), dateFormat));
break;
}
case "text": {
prefixParts.push(autoConfig.textValue || "TEXT");
break;
}
case "number": {
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
prefixParts.push(String(value).padStart(length, "0"));
break;
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
prefixParts.push("");
break;
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
prefixParts.push("");
break;
}
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;
});
if (!mapping) {
try {
const pool = getPool();
const [catTableName, catColumnName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const cvResult = 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`,
[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;
});
}
} catch { /* ignore */ }
}
prefixParts.push(mapping?.format || selectedValueStr);
break;
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
prefixParts.push(String(formData[refColumn]));
} else {
prefixParts.push("");
}
break;
}
default:
break;
}
}
return prefixParts.join("|");
}
/**
* prefix_key 기반으로 현재 순번 조회 (새 테이블 사용)
*/
private async getSequenceForPrefix(
client: any,
ruleId: string,
companyCode: string,
prefixKey: string
): Promise<number> {
const result = await client.query(
`SELECT current_sequence FROM numbering_rule_sequences
WHERE rule_id = $1 AND company_code = $2 AND prefix_key = $3`,
[ruleId, companyCode, prefixKey]
);
return result.rows.length > 0 ? result.rows[0].current_sequence : 0;
}
/**
* prefix_key 기반으로 순번 증가 (UPSERT)
*/
private async incrementSequenceForPrefix(
client: any,
ruleId: string,
companyCode: string,
prefixKey: string
): Promise<number> {
const result = await client.query(
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (rule_id, company_code, prefix_key)
DO UPDATE SET current_sequence = numbering_rule_sequences.current_sequence + 1,
last_allocated_at = NOW()
RETURNING current_sequence`,
[ruleId, companyCode, prefixKey]
);
return result.rows[0].current_sequence;
}
/**
* 규칙 목록 조회 (전체)
*/
async getRuleList(companyCode: string): Promise<NumberingRuleConfig[]> {
try {
logger.info("채번 규칙 목록 조회 시작", { companyCode });
const pool = getPool();
// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사 데이터 조회 가능
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
ORDER BY created_at DESC
`;
params = [];
logger.info("최고 관리자 전체 채번 규칙 조회");
} else {
// 일반 회사: 자신의 회사 데이터만 조회 (company_code="*" 제외)
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1
ORDER BY created_at DESC
`;
params = [companyCode];
logger.info("회사별 채번 규칙 조회", { companyCode });
}
const result = await pool.query(query, params);
// 각 규칙의 파트 정보 조회
for (const rule of result.rows) {
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 파트 조회
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
WHERE rule_id = $1
ORDER BY part_order
`;
partsParams = [rule.ruleId];
} else {
// 일반 회사: 자신의 파트만 조회
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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
partsParams = [rule.ruleId, companyCode];
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, {
companyCode,
});
return result.rows;
} catch (error: any) {
logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`);
throw error;
}
}
/**
* 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프)
*
* 메뉴 스코프 규칙:
* - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함
* - 우선순위: menu (형제 메뉴) > table > global
*/
async getAvailableRulesForMenu(
companyCode: string,
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
companyCode,
menuObjid,
});
const pool = getPool();
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
if (menuObjid) {
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", {
menuObjid,
menuAndChildObjids,
});
}
// menuObjid가 없으면 global 규칙만 반환
if (!menuObjid || menuAndChildObjids.length === 0) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 global 규칙 조회
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE 1=1
ORDER BY created_at DESC
`;
params = [];
} else {
// 일반 회사: 자신의 global 규칙만 조회 (company_code="*" 제외)
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1 ORDER BY created_at DESC
`;
params = [companyCode];
}
const result = await pool.query(query, params);
// 파트 정보 추가
for (const rule of result.rows) {
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
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
WHERE rule_id = $1
ORDER BY part_order
`;
partsParams = [rule.ruleId];
} else {
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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
partsParams = [rule.ruleId, companyCode];
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;
}
// 2. 메뉴 스코프: 형제 메뉴의 채번 규칙 조회
// 우선순위: menu (형제 메뉴) > table > global
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
ORDER BY created_at DESC
`;
params = [];
logger.info("최고 관리자: 전체 채번 규칙 조회");
} else {
// 일반 회사: 자신의 규칙만 조회
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1
ORDER BY created_at DESC
`;
params = [companyCode];
logger.info("회사별 채번 규칙 조회", { companyCode });
}
logger.info("🔍 채번 규칙 쿼리 실행", {
queryPreview: query.substring(0, 200),
paramsTypes: params.map((p) => typeof p),
paramsValues: params,
});
const result = await pool.query(query, params);
logger.debug("채번 규칙 쿼리 성공", { ruleCount: result.rows.length });
// 파트 정보 추가
for (const rule of result.rows) {
try {
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
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
WHERE rule_id = $1
ORDER BY part_order
`;
partsParams = [rule.ruleId];
} else {
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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
partsParams = [rule.ruleId, companyCode];
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("✅ 규칙 파트 조회 성공", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
partsCount: partsResult.rows.length,
});
} catch (partError: any) {
logger.error("❌ 규칙 파트 조회 실패", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
error: partError.message,
errorCode: partError.code,
errorStack: partError.stack,
});
throw partError;
}
}
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
companyCode,
menuObjid,
menuAndChildCount: menuAndChildObjids.length,
count: result.rowCount,
});
return result.rows;
} catch (error: any) {
logger.error("메뉴별 채번 규칙 조회 실패", {
error: error.message,
errorCode: error.code,
errorStack: error.stack,
companyCode,
menuObjid,
menuAndChildObjids: menuAndChildObjids || [],
});
throw error;
}
}
/**
* 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
* @param companyCode 회사 코드
* @param tableName 화면의 테이블명
* @returns 해당 테이블의 채번 규칙 목록
*/
async getAvailableRulesForScreen(
companyCode: string,
tableName: string
): Promise<NumberingRuleConfig[]> {
try {
logger.info("화면용 채번 규칙 조회", {
companyCode,
tableName,
});
const pool = getPool();
// 멀티테넌시: 최고 관리자 vs 일반 회사
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사의 규칙 조회 가능 (최고 관리자 전용 규칙 제외)
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code != '*'
AND table_name = $1
ORDER BY created_at DESC
`;
params = [tableName];
logger.info("최고 관리자: 일반 회사 채번 규칙 조회");
} else {
// 일반 회사: 자신의 규칙만 조회
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1
AND table_name = $2
ORDER BY created_at DESC
`;
params = [companyCode, tableName];
}
const result = await pool.query(query, params);
// 각 규칙의 파트 정보 로드
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
WHERE rule_id = $1
AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}`, {
companyCode,
tableName,
});
return result.rows;
} catch (error: any) {
logger.error("화면용 채번 규칙 조회 실패", error);
throw error;
}
}
/**
* 특정 규칙 조회
*/
async getRuleById(
ruleId: string,
companyCode: string
): Promise<NumberingRuleConfig | null> {
const pool = getPool();
// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 가능
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE rule_id = $1
`;
params = [ruleId];
} else {
// 일반 회사: 자신의 규칙만 조회
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE rule_id = $1 AND company_code = $2
`;
params = [ruleId, companyCode];
}
const result = await pool.query(query, params);
if (result.rowCount === 0) {
return null;
}
const rule = result.rows[0];
// 파트 정보 조회
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
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
WHERE rule_id = $1
ORDER BY part_order
`;
partsParams = [ruleId];
} else {
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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
partsParams = [ruleId, companyCode];
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
return rule;
}
/**
* 규칙 생성
*/
async createRule(
config: NumberingRuleConfig,
companyCode: string,
userId: string
): Promise<NumberingRuleConfig> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 마스터 삽입
const insertRuleQuery = `
INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
category_column, category_value_id, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
`;
const ruleResult = await client.query(insertRuleQuery, [
config.ruleId,
config.ruleName,
config.description || null,
config.separator || "-",
config.resetPeriod || "none",
config.currentSequence || 1,
config.tableName || null,
config.columnName || null,
companyCode,
config.categoryColumn || null,
config.categoryValueId || null,
userId,
]);
// 파트 삽입
const parts: NumberingRulePart[] = [];
for (const part of config.parts) {
const insertPartQuery = `
INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
`;
// auto_config에 separatorAfter 포함
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
const savedPart = partResult.rows[0];
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
await client.query("COMMIT");
logger.info("채번 규칙 생성 완료", {
ruleId: config.ruleId,
companyCode,
});
return { ...ruleResult.rows[0], parts };
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("채번 규칙 생성 실패", { error: error.message });
throw error;
} finally {
client.release();
}
}
/**
* 규칙 수정
*/
async updateRule(
ruleId: string,
updates: Partial<NumberingRuleConfig>,
companyCode: string
): Promise<NumberingRuleConfig> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const updateRuleQuery = `
UPDATE numbering_rules
SET
rule_name = COALESCE($1, rule_name),
description = COALESCE($2, description),
separator = COALESCE($3, separator),
reset_period = COALESCE($4, reset_period),
table_name = COALESCE($5, table_name),
column_name = COALESCE($6, column_name),
category_column = COALESCE($7, category_column),
category_value_id = COALESCE($8, category_value_id),
updated_at = NOW()
WHERE rule_id = $9 AND company_code = $10
RETURNING
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
`;
const ruleResult = await client.query(updateRuleQuery, [
updates.ruleName,
updates.description,
updates.separator,
updates.resetPeriod,
updates.tableName,
updates.columnName,
updates.categoryColumn,
updates.categoryValueId,
ruleId,
companyCode,
]);
if (ruleResult.rowCount === 0) {
throw new Error("규칙을 찾을 수 없거나 권한이 없습니다");
}
// 파트 업데이트
let parts: NumberingRulePart[] = [];
if (updates.parts) {
await client.query(
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
for (const part of updates.parts) {
const insertPartQuery = `
INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
`;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [
ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
const savedPart = partResult.rows[0];
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
}
await client.query("COMMIT");
logger.info("채번 규칙 수정 완료", { ruleId, companyCode });
return { ...ruleResult.rows[0], parts };
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("채번 규칙 수정 실패", {
ruleId,
companyCode,
error: error.message,
stack: error.stack,
updates,
});
throw error;
} finally {
client.release();
}
}
/**
* 규칙 삭제
*/
async deleteRule(ruleId: string, companyCode: string): Promise<void> {
const pool = getPool();
const query = `
DELETE FROM numbering_rules
WHERE rule_id = $1 AND company_code = $2
`;
const result = await pool.query(query, [ruleId, companyCode]);
if (result.rowCount === 0) {
throw new Error("규칙을 찾을 수 없거나 권한이 없습니다");
}
logger.info("채번 규칙 삭제 완료", { ruleId, companyCode });
}
/**
* 코드 미리보기 (순번 증가 없음)
* @param ruleId 채번 규칙 ID
* @param companyCode 회사 코드
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
*/
async previewCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번 조회
const prefixKey = await this.buildPrefixKey(rule, formData);
const pool = getPool();
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
logger.info("미리보기: prefix_key 기반 순번 조회", {
ruleId, prefixKey, currentSeq,
});
const parts = 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;
const nextSequence = currentSeq + 1;
return String(nextSequence).padStart(length, "0");
}
case "number": {
// 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
case "date": {
// 날짜 (다양한 날짜 형식)
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": {
// 텍스트 (고정 문자열)
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 "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "REF";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
}
}));
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
logger.info("코드 미리보기 생성", {
ruleId,
previewCode,
companyCode,
hasFormData: !!formData,
});
return previewCode;
}
/**
* 코드 할당 (저장 시점에 실제 순번 증가)
* @param ruleId 채번 규칙 ID
* @param companyCode 회사 코드
* @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
* @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용)
*/
async allocateCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>,
userInputCode?: string
): Promise<string> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
const prefixKey = await this.buildPrefixKey(rule, formData);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix(
client, ruleId, companyCode, prefixKey
);
// 호환성을 위해 기존 current_sequence도 업데이트
await client.query(
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
}
logger.info("allocateCode: prefix_key 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence,
});
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
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 ||
"";
manualPartIndex++;
return manualValue;
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return String(allocatedSequence).padStart(length, "0");
}
case "number": {
const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
case "date": {
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": {
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 "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
default:
return "";
}
}));
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
await client.query("COMMIT");
logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode });
return allocatedCode;
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("코드 할당 실패", {
ruleId,
companyCode,
error: error.message,
stack: error.stack,
});
throw error;
} finally {
client.release();
}
}
/**
* @deprecated 기존 generateCode는 allocateCode를 사용하세요
*/
async generateCode(ruleId: string, companyCode: string): Promise<string> {
logger.warn(
"generateCode는 deprecated 되었습니다. previewCode 또는 allocateCode를 사용하세요"
);
return this.allocateCode(ruleId, companyCode);
}
private formatDate(date: Date, format: string): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
switch (format) {
case "YYYY":
return String(year);
case "YY":
return String(year).slice(-2);
case "YYYYMM":
return `${year}${month}`;
case "YYMM":
return `${String(year).slice(-2)}${month}`;
case "YYYYMMDD":
return `${year}${month}${day}`;
case "YYMMDD":
return `${String(year).slice(-2)}${month}${day}`;
default:
return `${year}${month}${day}`;
}
}
async resetSequence(ruleId: string, companyCode: string): Promise<void> {
const pool = getPool();
// 새 테이블의 모든 prefix 순번 초기화
await pool.query(
"DELETE FROM numbering_rule_sequences WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
// 기존 테이블도 초기화 (호환성)
await pool.query(
"UPDATE numbering_rules SET current_sequence = 0, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
logger.info("시퀀스 초기화 완료 (prefix별 순번 포함)", { ruleId, companyCode });
}
/**
* [테스트] 테스트 테이블에서 채번 규칙 목록 조회
* numbering_rules 테이블 사용
*/
async getRulesFromTest(
companyCode: string,
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
try {
logger.info("[테스트] 채번 규칙 목록 조회 시작", {
companyCode,
menuObjid,
});
const pool = getPool();
// 멀티테넌시: 최고 관리자 vs 일반 회사
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
ORDER BY created_at DESC
`;
params = [];
} else {
// 일반 회사: 자신의 규칙만 조회
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",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1
ORDER BY created_at DESC
`;
params = [companyCode];
}
const result = await pool.query(query, params);
// 각 규칙의 파트 정보 조회
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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
companyCode,
menuObjid,
count: result.rows.length,
});
return result.rows;
} catch (error: any) {
logger.error("[테스트] 채번 규칙 목록 조회 실패", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* 테이블명 + 컬럼명 기반으로 채번규칙 조회
* numbering_rules 테이블 사용
*/
async getNumberingRuleByColumn(
companyCode: string,
tableName: string,
columnName: string
): Promise<NumberingRuleConfig | null> {
try {
logger.info("테이블+컬럼 기반 채번 규칙 조회 시작 (테스트)", {
companyCode,
tableName,
columnName,
});
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 r
LEFT JOIN category_values 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];
let result = await pool.query(query, params);
// fallback: column_name이 비어있는 레거시 규칙 검색
if (result.rows.length === 0) {
const fallbackQuery = `
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 r
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND (r.column_name IS NULL OR r.column_name = '')
AND r.category_value_id IS NULL
ORDER BY r.updated_at DESC
LIMIT 1
`;
result = await pool.query(fallbackQuery, [companyCode, tableName]);
// 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션)
if (result.rows.length > 0) {
const foundRule = result.rows[0];
await pool.query(
`UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`,
[columnName, foundRule.ruleId, companyCode]
);
result.rows[0].columnName = columnName;
logger.info("레거시 채번 규칙 자동 매핑 완료", {
ruleId: foundRule.ruleId,
tableName,
columnName,
});
}
}
if (result.rows.length === 0) {
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
companyCode,
tableName,
columnName,
});
return null;
}
const rule = result.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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode,
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
});
return rule;
} catch (error: any) {
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패 (테스트)", {
error: error.message,
stack: error.stack,
companyCode,
tableName,
columnName,
});
throw error;
}
}
/**
* [테스트] 테스트 테이블에 채번규칙 저장
* numbering_rules 테이블 사용
*/
async saveRuleToTest(
config: NumberingRuleConfig,
companyCode: string,
createdBy: string
): Promise<NumberingRuleConfig> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
logger.info("테스트 테이블에 채번 규칙 저장 시작", {
ruleId: config.ruleId,
ruleName: config.ruleName,
tableName: config.tableName,
columnName: config.columnName,
companyCode,
});
// 기존 규칙 확인
const existingQuery = `
SELECT rule_id FROM numbering_rules
WHERE rule_id = $1 AND company_code = $2
`;
const existingResult = await client.query(existingQuery, [
config.ruleId,
companyCode,
]);
if (existingResult.rows.length > 0) {
// 업데이트
const updateQuery = `
UPDATE numbering_rules SET
rule_name = $1,
description = $2,
separator = $3,
reset_period = $4,
table_name = $5,
column_name = $6,
category_column = $7,
category_value_id = $8,
updated_at = NOW()
WHERE rule_id = $9 AND company_code = $10
`;
await client.query(updateQuery, [
config.ruleName,
config.description || "",
config.separator || "-",
config.resetPeriod || "none",
config.tableName || "",
config.columnName || "",
config.categoryColumn || null,
config.categoryValueId || null,
config.ruleId,
companyCode,
]);
// 기존 파트 삭제
await client.query(
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
[config.ruleId, companyCode]
);
} else {
// 신규 등록
const insertQuery = `
INSERT INTO numbering_rules (
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, $10, $11, NOW(), NOW(), $12)
`;
await client.query(insertQuery, [
config.ruleId,
config.ruleName,
config.description || "",
config.separator || "-",
config.resetPeriod || "none",
config.currentSequence || 1,
config.tableName || "",
config.columnName || "",
companyCode,
config.categoryColumn || null,
config.categoryValueId || null,
createdBy,
]);
}
// 파트 저장
if (config.parts && config.parts.length > 0) {
for (const part of config.parts) {
const partInsertQuery = `
INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
await client.query(partInsertQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
}
}
await client.query("COMMIT");
logger.info("테스트 테이블에 채번 규칙 저장 완료", {
ruleId: config.ruleId,
companyCode,
});
return config;
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("테스트 테이블에 채번 규칙 저장 실패", {
error: error.message,
stack: error.stack,
ruleId: config.ruleId,
companyCode,
});
throw error;
} finally {
client.release();
}
}
/**
* [테스트] 테스트 테이블에서 채번규칙 삭제
* numbering_rules 테이블 사용
*/
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 WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
// 규칙 삭제
const result = await client.query(
"DELETE FROM numbering_rules 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 r
LEFT JOIN category_values 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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode,
]);
rule.parts = extractSeparatorAfterFromParts(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 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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode,
]);
rule.parts = extractSeparatorAfterFromParts(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 r
LEFT JOIN category_values 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
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode,
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;
} catch (error: any) {
logger.error("테이블.컬럼별 채번 규칙 목록 조회 실패", {
error: error.message,
});
throw error;
}
}
/**
* 회사별 채번규칙 복제 (테이블 기반)
* numbering_rules, numbering_rule_parts 테이블 사용
* 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트
*/
async copyRulesForCompany(
sourceCompanyCode: string,
targetCompanyCode: string
): Promise<{
copiedCount: number;
skippedCount: number;
details: string[];
ruleIdMap: Record<string, string>;
}> {
const pool = getPool();
const client = await pool.connect();
const result = {
copiedCount: 0,
skippedCount: 0,
details: [] as string[],
ruleIdMap: {} as Record<string, string>,
};
try {
await client.query("BEGIN");
// 0. 대상 회사의 기존 채번규칙 삭제 (깨끗하게 복제하기 위해)
// 먼저 파트 삭제
await client.query(
`DELETE FROM numbering_rule_parts
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
[targetCompanyCode]
);
// 규칙 삭제
const deleteResult = await client.query(
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
[targetCompanyCode]
);
if (deleteResult.rowCount && deleteResult.rowCount > 0) {
logger.info("기존 채번규칙 삭제", {
targetCompanyCode,
deletedCount: deleteResult.rowCount,
});
}
// 1. 원본 회사의 채번규칙 조회 - numbering_rules 사용
const sourceRulesResult = await client.query(
`SELECT * FROM numbering_rules WHERE company_code = $1`,
[sourceCompanyCode]
);
logger.info("원본 채번규칙 조회", {
sourceCompanyCode,
count: sourceRulesResult.rowCount,
});
// 2. 각 채번규칙 복제
for (const rule of sourceRulesResult.rows) {
// 새 rule_id 생성
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// 이미 존재하는지 확인 (이름 기반) - numbering_rules 사용
const existsCheck = await client.query(
`SELECT rule_id FROM numbering_rules
WHERE company_code = $1 AND rule_name = $2`,
[targetCompanyCode, rule.rule_name]
);
if (existsCheck.rows.length > 0) {
// 이미 존재하면 매핑만 추가
result.ruleIdMap[rule.rule_id] = existsCheck.rows[0].rule_id;
result.skippedCount++;
result.details.push(`건너뜀 (이미 존재): ${rule.rule_name}`);
continue;
}
// 채번규칙 복제 - numbering_rules 사용
await client.query(
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
created_at, updated_at, created_by, category_column, category_value_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`,
[
newRuleId,
rule.rule_name,
rule.description,
rule.separator,
rule.reset_period,
0, // 시퀀스 초기화
rule.table_name,
rule.column_name,
targetCompanyCode,
rule.created_by,
rule.category_column,
rule.category_value_id,
]
);
// 채번규칙 파트 복제 - numbering_rule_parts 사용
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
[rule.rule_id]
);
for (const part of partsResult.rows) {
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
[
newRuleId,
part.part_order,
part.part_type,
part.generation_method,
part.auto_config ? JSON.stringify(part.auto_config) : null,
part.manual_config ? JSON.stringify(part.manual_config) : null,
targetCompanyCode,
]
);
}
// 매핑 추가
result.ruleIdMap[rule.rule_id] = newRuleId;
result.copiedCount++;
result.details.push(`복제 완료: ${rule.rule_name}`);
logger.info("채번규칙 복제 완료", {
ruleName: rule.rule_name,
oldRuleId: rule.rule_id,
newRuleId,
});
}
// 3. 화면 레이아웃의 numberingRuleId 참조 업데이트
if (Object.keys(result.ruleIdMap).length > 0) {
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
targetCompanyCode,
mappingCount: Object.keys(result.ruleIdMap).length,
});
// 대상 회사의 모든 화면 레이아웃 조회
const layoutsResult = await client.query(
`SELECT sl.layout_id, sl.properties
FROM screen_layouts sl
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
WHERE sd.company_code = $1
AND sl.properties::text LIKE '%numberingRuleId%'`,
[targetCompanyCode]
);
let updatedLayouts = 0;
for (const layout of layoutsResult.rows) {
let propsStr = JSON.stringify(layout.properties);
let updated = false;
// 각 매핑에 대해 치환
for (const [oldRuleId, newRuleId] of Object.entries(
result.ruleIdMap
)) {
if (propsStr.includes(`"${oldRuleId}"`)) {
propsStr = propsStr
.split(`"${oldRuleId}"`)
.join(`"${newRuleId}"`);
updated = true;
}
}
if (updated) {
await client.query(
`UPDATE screen_layouts SET properties = $1::jsonb WHERE layout_id = $2`,
[propsStr, layout.layout_id]
);
updatedLayouts++;
}
}
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
targetCompanyCode,
updatedLayouts,
});
result.details.push(
`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`
);
}
await client.query("COMMIT");
logger.info("회사별 채번규칙 복제 완료", {
sourceCompanyCode,
targetCompanyCode,
copiedCount: result.copiedCount,
skippedCount: result.skippedCount,
ruleIdMapCount: Object.keys(result.ruleIdMap).length,
});
return result;
} catch (error) {
await client.query("ROLLBACK");
logger.error("회사별 채번규칙 복제 실패", {
error,
sourceCompanyCode,
targetCompanyCode,
});
throw error;
} finally {
client.release();
}
}
}
export const numberingRuleService = new NumberingRuleService();