ERP-node/backend-node/src/services/numberingRuleService.ts

1983 lines
64 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;
}
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 {
/**
* 규칙 목록 조회 (전체)
*/
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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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 = 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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE scope_type = 'global'
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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1 AND scope_type = 'global'
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 = 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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE
scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($1))
OR (scope_type = 'table' AND menu_objid = ANY($1))
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
WHEN scope_type = 'table' THEN 2
WHEN scope_type = 'global' THEN 3
END,
created_at DESC
`;
params = [menuAndChildObjids];
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
} 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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules
WHERE company_code = $1
AND (
scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($2))
OR (scope_type = 'table' AND menu_objid = ANY($2))
)
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($2)) THEN 1
WHEN scope_type = 'table' THEN 2
WHEN scope_type = 'global' THEN 3
END,
created_at DESC
`;
params = [companyCode, menuAndChildObjids];
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
}
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 = 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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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 = 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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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 = 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,
menu_objid, scope_type, 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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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.menuObjid || null,
config.scopeType || "global",
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"
`;
const partResult = await client.query(insertPartQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
}
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),
menu_objid = COALESCE($7, menu_objid),
scope_type = COALESCE($8, scope_type),
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",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
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.menuObjid,
updates.scopeType,
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 partResult = await client.query(insertPartQuery, [
ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
}
}
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("규칙을 찾을 수 없습니다");
const parts = rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력)
return part.manualConfig?.placeholder || "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
const length = autoConfig.sequenceLength || 3;
const nextSequence = (rule.currentSequence || 0) + 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);
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 "";
}
});
const previewCode = parts.join(rule.separator || "");
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData });
return previewCode;
}
/**
* 코드 할당 (저장 시점에 실제 순번 증가)
* @param ruleId 채번 규칙 ID
* @param companyCode 회사 코드
* @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
*/
async allocateCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
): 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("규칙을 찾을 수 없습니다");
const parts = rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
// 순번 (자동 증가 숫자 - 다음 번호 사용)
const length = autoConfig.sequenceLength || 3;
const nextSequence = (rule.currentSequence || 0) + 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) {
// 날짜 문자열 또는 Date 객체를 Date로 변환
const dateValue = columnValue instanceof Date
? columnValue
: new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
logger.info("컬럼 기준 날짜 생성", {
sourceColumn: autoConfig.sourceColumnName,
columnValue,
parsedDate: dateValue.toISOString(),
});
return this.formatDate(dateValue, dateFormat);
} else {
logger.warn("날짜 변환 실패, 현재 날짜 사용", {
sourceColumn: autoConfig.sourceColumnName,
columnValue,
});
}
} else {
logger.warn("소스 컬럼 값이 없음, 현재 날짜 사용", {
sourceColumn: autoConfig.sourceColumnName,
formDataKeys: Object.keys(formData),
});
}
}
// 기본: 현재 날짜 사용
return this.formatDate(new Date(), dateFormat);
}
case "text": {
// 텍스트 (고정 문자열)
return autoConfig.textValue || "TEXT";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
}
});
const allocatedCode = parts.join(rule.separator || "");
// 순번이 있는 경우에만 증가
const hasSequence = rule.parts.some(
(p: any) => p.partType === "sequence"
);
if (hasSequence) {
await client.query(
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
}
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();
await pool.query(
"UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
}
/**
* [테스트] 테스트 테이블에서 채번 규칙 목록 조회
* numbering_rules_test 테이블 사용
*/
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_test
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_test
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_test
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 = 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;
}
}
/**
* [테스트] 테이블명 + 컬럼명 기반으로 채번규칙 조회 (menu_objid 없이)
* numbering_rules_test 테이블 사용
*/
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_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];
const result = await pool.query(query, params);
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_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,
ruleName: rule.ruleName,
});
return rule;
} catch (error: any) {
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패 (테스트)", {
error: error.message,
stack: error.stack,
companyCode,
tableName,
columnName,
});
throw error;
}
}
/**
* [테스트] 테스트 테이블에 채번규칙 저장
* numbering_rules_test 테이블 사용
*/
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_test
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_test 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_test WHERE rule_id = $1 AND company_code = $2",
[config.ruleId, companyCode]
);
} else {
// 신규 등록
const insertQuery = `
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, $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_test (
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())
`;
await client.query(partInsertQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
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_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;
}
}
/**
* 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출)
* 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결
* 복제 후 화면 레이아웃의 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");
// 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두)
const sourceRulesResult = await client.query(
`SELECT nr.*, mi.menu_name_kor as source_menu_name
FROM numbering_rules nr
LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid
WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`,
[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)}`;
// 이미 존재하는지 확인 (이름 기반)
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;
}
let targetMenuObjid = null;
// menu 스코프인 경우 대상 메뉴 찾기
if (rule.scope_type === 'menu' && rule.source_menu_name) {
const targetMenuResult = await client.query(
`SELECT objid FROM menu_info
WHERE company_code = $1 AND menu_name_kor = $2
LIMIT 1`,
[targetCompanyCode, rule.source_menu_name]
);
if (targetMenuResult.rows.length === 0) {
result.skippedCount++;
result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`);
continue;
}
targetMenuObjid = targetMenuResult.rows[0].objid;
}
// 채번규칙 복제
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, scope_type, menu_objid
) 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.scope_type,
targetMenuObjid,
]
);
// 채번규칙 파트 복제
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} (${rule.scope_type})`);
logger.info("채번규칙 복제 완료", {
ruleName: rule.rule_name,
oldRuleId: rule.rule_id,
newRuleId,
targetMenuObjid
});
}
// 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();