Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard

This commit is contained in:
dohyeons
2025-11-05 15:39:11 +09:00
38 changed files with 6529 additions and 441 deletions

View File

@@ -64,8 +64,8 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -224,8 +224,8 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@@ -3084,3 +3084,84 @@ export const resetUserPassword = async (
});
}
};
/**
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
*/
export async function getTableSchema(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode;
if (!tableName) {
res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
});
return;
}
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema에서 컬럼 정보 가져오기
const schemaQuery = `
SELECT
column_name,
data_type,
is_nullable,
column_default,
character_maximum_length,
numeric_precision,
numeric_scale
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position
`;
const columns = await query<any>(schemaQuery, [tableName]);
if (columns.length === 0) {
res.status(404).json({
success: false,
message: `테이블 '${tableName}'을 찾을 수 없습니다.`,
});
return;
}
// 컬럼 정보를 간단한 형태로 변환
const columnList = columns.map((col: any) => ({
name: col.column_name,
type: col.data_type,
nullable: col.is_nullable === "YES",
default: col.column_default,
maxLength: col.character_maximum_length,
precision: col.numeric_precision,
scale: col.numeric_scale,
}));
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);
res.json({
success: true,
message: "테이블 스키마 조회 성공",
data: {
tableName,
columns: columnList,
},
});
} catch (error) {
logger.error("테이블 스키마 조회 중 오류 발생:", error);
res.status(500).json({
success: false,
message: "테이블 스키마 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_SCHEMA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
}

View File

@@ -0,0 +1,282 @@
import { Request, Response } from "express";
import pool from "../database/db";
import { logger } from "../utils/logger";
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
userName: string;
companyCode: string;
};
}
/**
* 코드 병합 - 모든 관련 테이블에 적용
* 데이터(레코드)는 삭제하지 않고, 컬럼 값만 변경
*/
export async function mergeCodeAllTables(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { columnName, oldValue, newValue } = req.body;
const companyCode = req.user?.companyCode;
try {
// 입력값 검증
if (!columnName || !oldValue || !newValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (columnName, oldValue, newValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
// 같은 값으로 병합 시도 방지
if (oldValue === newValue) {
res.status(400).json({
success: false,
message: "기존 값과 새 값이 동일합니다.",
});
return;
}
logger.info("코드 병합 시작", {
columnName,
oldValue,
newValue,
companyCode,
userId: req.user?.userId,
});
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM merge_code_all_tables($1, $2, $3, $4)",
[columnName, oldValue, newValue, companyCode]
);
// 결과 처리 (pool.query 반환 타입 처리)
const affectedTables = Array.isArray(result) ? result : (result.rows || []);
const totalRows = affectedTables.reduce(
(sum, row) => sum + parseInt(row.rows_updated || 0),
0
);
logger.info("코드 병합 완료", {
columnName,
oldValue,
newValue,
affectedTablesCount: affectedTables.length,
totalRowsUpdated: totalRows,
});
res.json({
success: true,
message: `코드 병합 완료: ${oldValue}${newValue}`,
data: {
columnName,
oldValue,
newValue,
affectedTables: affectedTables.map((row) => ({
tableName: row.table_name,
rowsUpdated: parseInt(row.rows_updated),
})),
totalRowsUpdated: totalRows,
},
});
} catch (error: any) {
logger.error("코드 병합 실패:", {
error: error.message,
stack: error.stack,
columnName,
oldValue,
newValue,
});
res.status(500).json({
success: false,
message: "코드 병합 중 오류가 발생했습니다.",
error: {
code: "CODE_MERGE_ERROR",
details: error.message,
},
});
}
}
/**
* 특정 컬럼을 가진 테이블 목록 조회
*/
export async function getTablesWithColumn(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { columnName } = req.params;
try {
if (!columnName) {
res.status(400).json({
success: false,
message: "컬럼명이 필요합니다.",
});
return;
}
logger.info("컬럼을 가진 테이블 목록 조회", { columnName });
const query = `
SELECT DISTINCT t.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name
WHERE c.column_name = $1
AND t.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
AND EXISTS (
SELECT 1 FROM information_schema.columns c2
WHERE c2.table_name = t.table_name
AND c2.column_name = 'company_code'
)
ORDER BY t.table_name
`;
const result = await pool.query(query, [columnName]);
logger.info(`컬럼을 가진 테이블 조회 완료: ${result.rows.length}`);
res.json({
success: true,
message: "테이블 목록 조회 성공",
data: {
columnName,
tables: result.rows.map((row) => row.table_name),
count: result.rows.length,
},
});
} catch (error: any) {
logger.error("테이블 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_LIST_ERROR",
details: error.message,
},
});
}
}
/**
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
*/
export async function previewCodeMerge(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { columnName, oldValue } = req.body;
const companyCode = req.user?.companyCode;
try {
if (!columnName || !oldValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (columnName, oldValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
logger.info("코드 병합 미리보기", { columnName, oldValue, companyCode });
// 해당 컬럼을 가진 테이블 찾기
const tablesQuery = `
SELECT DISTINCT t.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name
WHERE c.column_name = $1
AND t.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
AND EXISTS (
SELECT 1 FROM information_schema.columns c2
WHERE c2.table_name = t.table_name
AND c2.column_name = 'company_code'
)
`;
const tablesResult = await pool.query(tablesQuery, [columnName]);
// 각 테이블에서 영향받을 행 수 계산
const preview = [];
const tableRows = Array.isArray(tablesResult) ? tablesResult : (tablesResult.rows || []);
for (const row of tableRows) {
const tableName = row.table_name;
// 동적 SQL 생성 (테이블명과 컬럼명은 파라미터 바인딩 불가)
// SQL 인젝션 방지: 테이블명과 컬럼명은 information_schema에서 검증된 값
const countQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${columnName}" = $1 AND company_code = $2`;
try {
const countResult = await pool.query(countQuery, [oldValue, companyCode]);
const count = parseInt(countResult.rows[0].count);
if (count > 0) {
preview.push({
tableName,
affectedRows: count,
});
}
} catch (error: any) {
logger.warn(`테이블 ${tableName} 조회 실패:`, error.message);
// 테이블 접근 실패 시 건너뛰기
continue;
}
}
const totalRows = preview.reduce((sum, item) => sum + item.affectedRows, 0);
logger.info("코드 병합 미리보기 완료", {
tablesCount: preview.length,
totalRows,
});
res.json({
success: true,
message: "코드 병합 미리보기 완료",
data: {
columnName,
oldValue,
preview,
totalAffectedRows: totalRows,
},
});
} catch (error: any) {
logger.error("코드 병합 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
error: {
code: "PREVIEW_ERROR",
details: error.message,
},
});
}
}

View File

@@ -2,15 +2,15 @@
* 채번 규칙 관리 컨트롤러
*/
import { Router, Request, Response } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { Router, Response } from "express";
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
import { numberingRuleService } from "../services/numberingRuleService";
import { logger } from "../utils/logger";
const router = Router();
// 규칙 목록 조회
router.get("/", authenticateToken, async (req: Request, res: Response) => {
// 규칙 목록 조회 (전체)
router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
try {
@@ -22,8 +22,25 @@ router.get("/", authenticateToken, async (req: Request, res: Response) => {
}
});
// 메뉴별 사용 가능한 규칙 조회
router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
try {
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
return res.json({ success: true, data: rules });
} catch (error: any) {
logger.error("메뉴별 사용 가능한 규칙 조회 실패", {
error: error.message,
menuObjid,
});
return res.status(500).json({ success: false, error: error.message });
}
});
// 특정 규칙 조회
router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
@@ -40,7 +57,7 @@ router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) =>
});
// 규칙 생성
router.post("/", authenticateToken, async (req: Request, res: Response) => {
router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const ruleConfig = req.body;
@@ -66,7 +83,7 @@ router.post("/", authenticateToken, async (req: Request, res: Response) => {
});
// 규칙 수정
router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const updates = req.body;
@@ -84,7 +101,7 @@ router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) =>
});
// 규칙 삭제
router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) => {
router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
@@ -100,14 +117,42 @@ router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response)
}
});
// 코드 생성
router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Response) => {
// 코드 미리보기 (순번 증가 없음)
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try {
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {
logger.error("코드 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// 코드 할당 (저장 시점에 실제 순번 증가)
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) {
logger.error("코드 할당 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// 코드 생성 (기존 호환성 유지, deprecated)
router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try {
const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode);
return res.json({ success: true, data: { code: generatedCode } });
return res.json({ success: true, data: { generatedCode } });
} catch (error: any) {
logger.error("코드 생성 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
@@ -115,7 +160,7 @@ router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Re
});
// 시퀀스 초기화
router.post("/:ruleId/reset", authenticateToken, async (req: Request, res: Response) => {
router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;

View File

@@ -24,6 +24,7 @@ import {
deleteCompany, // 회사 삭제
getUserLocale,
setUserLocale,
getTableSchema, // 테이블 스키마 조회
} from "../controllers/adminController";
import { authenticateToken } from "../middleware/authMiddleware";
@@ -67,4 +68,7 @@ router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제
router.get("/user-locale", getUserLocale);
router.post("/user-locale", setUserLocale);
// 테이블 스키마 API (엑셀 업로드 컬럼 매핑용)
router.get("/tables/:tableName/schema", getTableSchema);
export default router;

View File

@@ -0,0 +1,35 @@
import express from "express";
import {
mergeCodeAllTables,
getTablesWithColumn,
previewCodeMerge,
} from "../controllers/codeMergeController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
* POST /api/code-merge/merge-all-tables
* 코드 병합 실행 (모든 관련 테이블에 적용)
* Body: { columnName, oldValue, newValue }
*/
router.post("/merge-all-tables", mergeCodeAllTables);
/**
* GET /api/code-merge/tables-with-column/:columnName
* 특정 컬럼을 가진 테이블 목록 조회
*/
router.get("/tables-with-column/:columnName", getTablesWithColumn);
/**
* POST /api/code-merge/preview
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
* Body: { columnName, oldValue }
*/
router.post("/preview", previewCodeMerge);
export default router;

View File

@@ -26,6 +26,8 @@ interface NumberingRuleConfig {
tableName?: string;
columnName?: string;
companyCode?: string;
menuObjid?: number;
scopeType?: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
@@ -33,7 +35,7 @@ interface NumberingRuleConfig {
class NumberingRuleService {
/**
* 규칙 목록 조회
* 규칙 목록 조회 (전체)
*/
async getRuleList(companyCode: string): Promise<NumberingRuleConfig[]> {
try {
@@ -78,11 +80,16 @@ class NumberingRuleService {
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
}
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, { companyCode });
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, {
companyCode,
});
return result.rows;
} catch (error: any) {
logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`);
@@ -90,10 +97,170 @@ class NumberingRuleService {
}
}
/**
* 현재 메뉴에서 사용 가능한 규칙 목록 조회
*/
async getAvailableRulesForMenu(
companyCode: string,
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", {
companyCode,
menuObjid,
});
const pool = getPool();
// menuObjid가 없으면 global 규칙만 반환
if (!menuObjid) {
const query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
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 OR company_code = '*')
AND scope_type = 'global'
ORDER BY created_at DESC
`;
const result = await pool.query(query, [companyCode]);
// 파트 정보 추가
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 OR company_code = '*')
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
}
return result.rows;
}
// 현재 메뉴의 상위 계층 조회 (2레벨 메뉴 찾기)
const menuHierarchyQuery = `
WITH RECURSIVE menu_path AS (
SELECT objid, objid_parent, menu_level
FROM menu_info
WHERE objid = $1
UNION ALL
SELECT mi.objid, mi.objid_parent, mi.menu_level
FROM menu_info mi
INNER JOIN menu_path mp ON mi.objid = mp.objid_parent
)
SELECT objid, menu_level
FROM menu_path
WHERE menu_level = 2
LIMIT 1
`;
const hierarchyResult = await pool.query(menuHierarchyQuery, [menuObjid]);
const level2MenuObjid =
hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null;
// 사용 가능한 규칙 조회
const query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
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 OR company_code = '*')
AND (
scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = $2)
)
ORDER BY scope_type DESC, created_at DESC
`;
const result = await pool.query(query, [companyCode, level2MenuObjid]);
// 파트 정보 추가
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 OR company_code = '*')
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
}
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
companyCode,
menuObjid,
level2MenuObjid,
count: result.rowCount,
});
return result.rows;
} catch (error: any) {
logger.error("메뉴별 채번 규칙 조회 실패", {
error: error.message,
companyCode,
menuObjid,
});
throw error;
}
}
/**
* 특정 규칙 조회
*/
async getRuleById(ruleId: string, companyCode: string): Promise<NumberingRuleConfig | null> {
async getRuleById(
ruleId: string,
companyCode: string
): Promise<NumberingRuleConfig | null> {
const pool = getPool();
const query = `
SELECT
@@ -106,7 +273,7 @@ class NumberingRuleService {
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
menu_id AS "menuId",
menu_objid AS "menuObjid",
scope_type AS "scopeType",
created_at AS "createdAt",
updated_at AS "updatedAt",
@@ -223,7 +390,10 @@ class NumberingRuleService {
}
await client.query("COMMIT");
logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode });
logger.info("채번 규칙 생성 완료", {
ruleId: config.ruleId,
companyCode,
});
return { ...ruleResult.rows[0], parts };
} catch (error: any) {
await client.query("ROLLBACK");
@@ -364,9 +534,63 @@ class NumberingRuleService {
}
/**
* 코드 생성
* 코드 미리보기 (순번 증가 없음)
*/
async generateCode(ruleId: string, companyCode: string): Promise<string> {
async previewCode(ruleId: string, companyCode: string): 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?.value || "";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
// 순번 (현재 순번으로 미리보기, 증가 안 함)
const length = autoConfig.sequenceLength || 4;
return String(rule.currentSequence || 1).padStart(length, "0");
}
case "number": {
// 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
case "date": {
// 날짜 (다양한 날짜 형식)
return this.formatDate(
new Date(),
autoConfig.dateFormat || "YYYYMMDD"
);
}
case "text": {
// 텍스트 (고정 문자열)
return autoConfig.textValue || "TEXT";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
}
});
const previewCode = parts.join(rule.separator || "");
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode });
return previewCode;
}
/**
* 코드 할당 (저장 시점에 실제 순번 증가)
*/
async allocateCode(ruleId: string, companyCode: string): Promise<string> {
const pool = getPool();
const client = await pool.connect();
@@ -386,37 +610,44 @@ class NumberingRuleService {
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "prefix":
return autoConfig.prefix || "PREFIX";
case "sequence": {
// 순번 (자동 증가 숫자)
const length = autoConfig.sequenceLength || 4;
return String(rule.currentSequence || 1).padStart(length, "0");
}
case "date":
return this.formatDate(new Date(), autoConfig.dateFormat || "YYYYMMDD");
case "year": {
const format = autoConfig.dateFormat || "YYYY";
const year = new Date().getFullYear();
return format === "YY" ? String(year).slice(-2) : String(year);
case "number": {
// 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0");
}
case "month":
return String(new Date().getMonth() + 1).padStart(2, "0");
case "date": {
// 날짜 (다양한 날짜 형식)
return this.formatDate(
new Date(),
autoConfig.dateFormat || "YYYYMMDD"
);
}
case "custom":
return autoConfig.value || "CUSTOM";
case "text": {
// 텍스트 (고정 문자열)
return autoConfig.textValue || "TEXT";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
}
});
const generatedCode = parts.join(rule.separator || "");
const allocatedCode = parts.join(rule.separator || "");
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 순번이 있는 경우에만 증가
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",
@@ -425,30 +656,52 @@ class NumberingRuleService {
}
await client.query("COMMIT");
logger.info("코드 생성 완료", { ruleId, generatedCode });
return generatedCode;
logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode });
return allocatedCode;
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("코드 생성 실패", { error: error.message });
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}`;
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}`;
}
}