Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
DDD1542
2026-02-27 10:39:51 +09:00
200 changed files with 36195 additions and 14797 deletions

View File

@@ -1,4 +1,4 @@
# PLM System Backend - Node.js + TypeScript
re# PLM System Backend - Node.js + TypeScript
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.

View File

@@ -124,6 +124,7 @@ import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRou
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -306,6 +307,7 @@ app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@@ -143,6 +143,70 @@ export async function initializeBomVersion(req: Request, res: Response) {
}
}
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
export async function createBomFromExcel(req: Request, res: Response) {
try {
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersionFromExcel(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows, versionName } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function downloadBomExcelData(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.downloadBomExcelData(bomId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;

View File

@@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* 필터 조건을 WHERE절에 적용하는 공통 헬퍼
* filters JSON 배열: [{ column, operator, value }]
*/
function applyFilters(
filtersJson: string | undefined,
existingColumns: Set<string>,
whereConditions: string[],
params: any[],
startParamIndex: number,
tableName: string,
): number {
let paramIndex = startParamIndex;
if (!filtersJson) return paramIndex;
let filters: Array<{ column: string; operator: string; value: unknown }>;
try {
filters = JSON.parse(filtersJson as string);
} catch {
logger.warn("filters JSON 파싱 실패", { tableName, filtersJson });
return paramIndex;
}
if (!Array.isArray(filters)) return paramIndex;
for (const filter of filters) {
const { column, operator = "=", value } = filter;
if (!column || !existingColumns.has(column)) {
logger.warn("필터 컬럼 미존재 제외", { tableName, column });
continue;
}
switch (operator) {
case "=":
whereConditions.push(`"${column}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "!=":
whereConditions.push(`"${column}" != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">":
case "<":
case ">=":
case "<=":
whereConditions.push(`"${column}" ${operator} $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in": {
const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (inVals.length > 0) {
const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${column}" IN (${ph})`);
params.push(...inVals);
paramIndex += inVals.length;
}
break;
}
case "notIn": {
const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (notInVals.length > 0) {
const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${column}" NOT IN (${ph})`);
params.push(...notInVals);
paramIndex += notInVals.length;
}
break;
}
case "like":
whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
case "isNull":
whereConditions.push(`"${column}" IS NULL`);
break;
case "isNotNull":
whereConditions.push(`"${column}" IS NOT NULL`);
break;
default:
whereConditions.push(`"${column}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
}
}
return paramIndex;
}
/**
* 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용)
* GET /api/entity/:tableName/distinct/:columnName
*
* 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환
*
* Query Params:
* - labelColumn: 별도의 라벨 컬럼 (선택)
* - filters: JSON 배열 형태의 필터 조건 (선택)
* 예: [{"column":"status","operator":"=","value":"active"}]
*/
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
try {
const { tableName, columnName } = req.params;
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
const { labelColumn, filters: filtersParam } = req.query;
// 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
whereConditions.push(`"${columnName}" IS NOT NULL`);
whereConditions.push(`"${columnName}" != ''`);
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
@@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
columnName,
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount,
});
@@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
* Query Params:
* - value: 값 컬럼 (기본: id)
* - label: 표시 컬럼 (기본: name)
* - fields: 추가 반환 컬럼 (콤마 구분)
* - filters: JSON 배열 형태의 필터 조건 (선택)
* 예: [{"column":"status","operator":"=","value":"active"}]
*/
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const { value = "id", label = "name", fields } = req.query;
const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
paramIndex++;
}
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
@@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
valueColumn,
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount,
extraFields: extraColumns ? true : false,
});

View File

@@ -0,0 +1,713 @@
/**
* 공정 작업기준 컨트롤러
* 품목별 라우팅/공정에 대한 작업 항목 및 상세 관리
*/
import { Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
// ============================================================
// 품목/라우팅/공정 조회 (좌측 트리 데이터)
// ============================================================
/**
* 라우팅이 있는 품목 목록 조회
* 요청 쿼리: tableName(품목테이블), nameColumn, codeColumn
*/
export async function getItemsWithRouting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const {
tableName = "item_info",
nameColumn = "item_name",
codeColumn = "item_number",
routingTable = "item_routing_version",
routingFkColumn = "item_code",
search = "",
} = req.query as Record<string, string>;
const searchCondition = search
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
: "";
const params: any[] = [companyCode];
if (search) params.push(`%${search}%`);
const query = `
SELECT
i.id,
i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code,
COUNT(rv.id) AS routing_count
FROM ${tableName} i
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code
WHERE i.company_code = $1
${searchCondition}
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
ORDER BY i.created_date DESC NULLS LAST
`;
const result = await getPool().query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 품목별 라우팅 버전 + 공정 목록 조회 (트리 하위 데이터)
*/
export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { itemCode } = req.params;
const {
routingVersionTable = "item_routing_version",
routingDetailTable = "item_routing_detail",
routingFkColumn = "item_code",
processTable = "process_mng",
processNameColumn = "process_name",
processCodeColumn = "process_code",
} = req.query as Record<string, string>;
// 라우팅 버전 목록
const versionsQuery = `
SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
FROM ${routingVersionTable}
WHERE ${routingFkColumn} = $1 AND company_code = $2
ORDER BY is_default DESC, created_date DESC
`;
const versionsResult = await getPool().query(versionsQuery, [
itemCode,
companyCode,
]);
// 각 버전별 공정 목록
const routings = [];
for (const version of versionsResult.rows) {
const detailsQuery = `
SELECT
rd.id AS routing_detail_id,
rd.seq_no,
rd.process_code,
rd.is_required,
rd.work_type,
p.${processNameColumn} AS process_name
FROM ${routingDetailTable} rd
LEFT JOIN ${processTable} p ON p.${processCodeColumn} = rd.process_code
AND p.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY rd.seq_no::integer
`;
const detailsResult = await getPool().query(detailsQuery, [
version.id,
companyCode,
]);
routings.push({
...version,
processes: detailsResult.rows,
});
}
return res.json({ success: true, data: routings });
} catch (error: any) {
logger.error("라우팅/공정 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 기본 버전 설정
// ============================================================
/**
* 라우팅 버전을 기본 버전으로 설정
* 같은 품목의 다른 버전은 기본 해제
*/
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const {
routingVersionTable = "item_routing_version",
routingFkColumn = "item_code",
} = req.body;
await client.query("BEGIN");
const versionResult = await client.query(
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
if (versionResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
}
const itemCode = versionResult.rows[0].item_code;
await client.query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
[itemCode, companyCode]
);
await client.query(
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
await client.query("COMMIT");
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("기본 버전 설정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
/**
* 기본 버전 해제
*/
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const { routingVersionTable = "item_routing_version" } = req.body;
await getPool().query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
logger.info("기본 버전 해제", { companyCode, versionId });
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
} catch (error: any) {
logger.error("기본 버전 해제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 작업 항목 CRUD
// ============================================================
/**
* 공정별 작업 항목 목록 조회 (phase별 그룹)
*/
export async function getWorkItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routingDetailId } = req.params;
const query = `
SELECT
wi.id,
wi.routing_detail_id,
wi.work_phase,
wi.title,
wi.is_required,
wi.sort_order,
wi.description,
wi.created_date,
(SELECT COUNT(*) FROM process_work_item_detail d
WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code
)::integer AS detail_count
FROM process_work_item wi
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
ORDER BY wi.work_phase, wi.sort_order, wi.created_date
`;
const result = await getPool().query(query, [routingDetailId, companyCode]);
// phase별 그룹핑
const grouped: Record<string, any[]> = {};
for (const row of result.rows) {
const phase = row.work_phase;
if (!grouped[phase]) grouped[phase] = [];
grouped[phase].push(row);
}
return res.json({ success: true, data: grouped, items: result.rows });
} catch (error: any) {
logger.error("작업 항목 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 작업 항목 추가
*/
export async function createWorkItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routing_detail_id, work_phase, title, is_required, sort_order, description } = req.body;
if (!routing_detail_id || !work_phase || !title) {
return res.status(400).json({
success: false,
message: "routing_detail_id, work_phase, title은 필수입니다",
});
}
const query = `
INSERT INTO process_work_item
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await getPool().query(query, [
companyCode,
routing_detail_id,
work_phase,
title,
is_required || "N",
sort_order || 0,
description || null,
writer,
]);
logger.info("작업 항목 생성", { companyCode, id: result.rows[0].id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 작업 항목 수정
*/
export async function updateWorkItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const { title, is_required, sort_order, description } = req.body;
const query = `
UPDATE process_work_item
SET title = COALESCE($1, title),
is_required = COALESCE($2, is_required),
sort_order = COALESCE($3, sort_order),
description = COALESCE($4, description),
updated_date = NOW()
WHERE id = $5 AND company_code = $6
RETURNING *
`;
const result = await getPool().query(query, [
title,
is_required,
sort_order,
description,
id,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
}
logger.info("작업 항목 수정", { companyCode, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 작업 항목 삭제 (상세도 함께 삭제)
*/
export async function deleteWorkItem(req: AuthenticatedRequest, res: Response) {
const client = await getPool().connect();
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
await client.query("BEGIN");
// 상세 먼저 삭제
await client.query(
"DELETE FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2",
[id, companyCode]
);
// 항목 삭제
const result = await client.query(
"DELETE FROM process_work_item WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
}
await client.query("COMMIT");
logger.info("작업 항목 삭제", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("작업 항목 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ============================================================
// 작업 항목 상세 CRUD
// ============================================================
/**
* 작업 항목 상세 목록 조회
*/
export async function getWorkItemDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { workItemId } = req.params;
const query = `
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
created_date
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date
`;
const result = await getPool().query(query, [workItemId, companyCode]);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("작업 항목 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 작업 항목 상세 추가
*/
export async function createWorkItemDetail(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const {
work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
if (!work_item_id || !content) {
return res.status(400).json({
success: false,
message: "work_item_id, content는 필수입니다",
});
}
// work_item이 같은 company_code인지 검증
const ownerCheck = await getPool().query(
"SELECT id FROM process_work_item WHERE id = $1 AND company_code = $2",
[work_item_id, companyCode]
);
if (ownerCheck.rowCount === 0) {
return res.status(403).json({ success: false, message: "권한이 없습니다" });
}
const query = `
INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *
`;
const result = await getPool().query(query, [
companyCode,
work_item_id,
detail_type || null,
content,
is_required || "N",
sort_order || 0,
remark || null,
writer,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 상세 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 작업 항목 상세 수정
*/
export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const {
detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
const query = `
UPDATE process_work_item_detail
SET detail_type = COALESCE($1, detail_type),
content = COALESCE($2, content),
is_required = COALESCE($3, is_required),
sort_order = COALESCE($4, sort_order),
remark = COALESCE($5, remark),
inspection_code = $8,
inspection_method = $9,
unit = $10,
lower_limit = $11,
upper_limit = $12,
duration_minutes = $13,
input_type = $14,
lookup_target = $15,
display_fields = $16,
updated_date = NOW()
WHERE id = $6 AND company_code = $7
RETURNING *
`;
const result = await getPool().query(query, [
detail_type,
content,
is_required,
sort_order,
remark,
id,
companyCode,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
}
logger.info("작업 항목 상세 수정", { companyCode, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 상세 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 작업 항목 상세 삭제
*/
export async function deleteWorkItemDetail(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const result = await getPool().query(
"DELETE FROM process_work_item_detail WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
}
logger.info("작업 항목 상세 삭제", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
logger.error("작업 항목 상세 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 전체 저장 (일괄)
// ============================================================
/**
* 전체 저장: 작업 항목 + 상세를 일괄 저장
* 기존 데이터를 삭제하고 새로 삽입하는 replace 방식
*/
export async function saveAll(req: AuthenticatedRequest, res: Response) {
const client = await getPool().connect();
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routing_detail_id, items } = req.body;
if (!routing_detail_id || !Array.isArray(items)) {
return res.status(400).json({
success: false,
message: "routing_detail_id와 items 배열이 필요합니다",
});
}
await client.query("BEGIN");
// 기존 상세 삭제
await client.query(
`DELETE FROM process_work_item_detail
WHERE work_item_id IN (
SELECT id FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2
)`,
[routing_detail_id, companyCode]
);
// 기존 항목 삭제
await client.query(
"DELETE FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2",
[routing_detail_id, companyCode]
);
// 새 항목 + 상세 삽입
for (const item of items) {
const itemResult = await client.query(
`INSERT INTO process_work_item
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
companyCode,
routing_detail_id,
item.work_phase,
item.title,
item.is_required || "N",
item.sort_order || 0,
item.description || null,
writer,
]
);
const workItemId = itemResult.rows[0].id;
if (Array.isArray(item.details)) {
for (const detail of item.details) {
await client.query(
`INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[
companyCode,
workItemId,
detail.detail_type || null,
detail.content,
detail.is_required || "N",
detail.sort_order || 0,
detail.remark || null,
writer,
detail.inspection_code || null,
detail.inspection_method || null,
detail.unit || null,
detail.lower_limit || null,
detail.upper_limit || null,
detail.duration_minutes || null,
detail.input_type || null,
detail.lookup_target || null,
detail.display_fields || null,
]
);
}
}
}
await client.query("COMMIT");
logger.info("작업기준 전체 저장", { companyCode, routing_detail_id, itemCount: items.length });
return res.json({ success: true, message: "저장 완료" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("작업기준 전체 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}

View File

@@ -921,6 +921,42 @@ export async function addTableData(
}
}
// 회사별 NOT NULL 소프트 제약조건 검증
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
data,
companyCode || "*"
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
data,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 추가
const result = await tableManagementService.addTableData(tableName, data);
@@ -1004,6 +1040,45 @@ export async function editTableData(
}
const tableManagementService = new TableManagementService();
const companyCode = req.user?.companyCode || "*";
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
updatedData,
companyCode
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
const excludeId = originalData?.id ? String(originalData.id) : undefined;
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
updatedData,
companyCode,
excludeId
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 수정
await tableManagementService.editTableData(
@@ -1694,6 +1769,7 @@ export async function getCategoryColumnsByCompany(
let columnsResult;
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
@@ -1713,15 +1789,15 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료", {
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length
});
} else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
@@ -1740,11 +1816,12 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료", {
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode,
rowCount: columnsResult.rows.length
});
@@ -1805,13 +1882,10 @@ export async function getCategoryColumnsByMenu(
const { getPool } = await import("../database/db");
const pool = getPool();
// 🆕 table_type_columns에서 직접 input_type = 'category' 컬럼들을 조회
// category_column_mapping 대신 table_type_columns 기준으로 조회
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// table_type_columns에서 input_type = 'category' 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
let columnsResult;
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
@@ -1831,15 +1905,15 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료", {
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length
});
} else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
@@ -1858,11 +1932,12 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료", {
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode,
rowCount: columnsResult.rows.length
});
@@ -2617,8 +2692,22 @@ export async function toggleTableIndex(
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") {
let indexColumns = `"${columnName}"`;
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
if (indexType === "unique") {
const hasCompanyCode = await query(
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
indexColumns = `"company_code", "${columnName}"`;
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
}
}
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
logger.info(`인덱스 생성: ${sql}`);
await query(sql);
} else if (action === "drop") {
@@ -2639,22 +2728,55 @@ export async function toggleTableIndex(
} catch (error: any) {
logger.error("인덱스 토글 오류:", error);
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
const errorMsg = error.message?.includes("duplicate key")
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
: "인덱스 설정 중 오류가 발생했습니다.";
const errMsg = error.message || "";
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
let duplicates: any[] = [];
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
if (
errMsg.includes("could not create unique index") ||
errMsg.includes("duplicate key")
) {
const { columnName, tableName } = { ...req.params, ...req.body };
try {
duplicates = await query(
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch {
try {
duplicates = await query(
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch { /* 중복 조회 실패 시 무시 */ }
}
const dupDetails = duplicates.length > 0
? duplicates.map((d: any) => {
const company = d.company_code ? `[${d.company_code}] ` : "";
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
}).join(", ")
: "";
userMessage = dupDetails
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
}
res.status(500).json({
success: false,
message: errorMsg,
error: error instanceof Error ? error.message : "Unknown error",
message: userMessage,
error: errMsg,
duplicates,
});
}
}
/**
* NOT NULL 토글
* NOT NULL 토글 (회사별 소프트 제약조건)
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*
* DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다.
* 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다.
*/
export async function toggleColumnNullable(
req: AuthenticatedRequest,
@@ -2663,6 +2785,7 @@ export async function toggleColumnNullable(
try {
const { tableName, columnName } = req.params;
const { nullable } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof nullable !== "boolean") {
res.status(400).json({
@@ -2672,18 +2795,54 @@ export async function toggleColumnNullable(
return;
}
if (nullable) {
// NOT NULL 해제
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
logger.info(`NOT NULL 해제: ${sql}`);
await query(sql);
} else {
// NOT NULL 설정
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
logger.info(`NOT NULL 설정: ${sql}`);
await query(sql);
// is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
const isNullableValue = nullable ? "Y" : "N";
if (!nullable) {
// NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const nullCheckQuery = companyCode === "*"
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
if (nullCount > 0) {
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
companyCode,
nullCount,
});
res.status(400).json({
success: false,
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
});
return;
}
}
}
// table_type_columns에 회사별 is_nullable 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
[tableName, columnName, isNullableValue, companyCode]
);
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: nullable
@@ -2693,14 +2852,95 @@ export async function toggleColumnNullable(
} catch (error: any) {
logger.error("NOT NULL 토글 오류:", error);
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
const errorMsg = error.message?.includes("contains null values")
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
: "NOT NULL 설정 중 오류가 발생했습니다.";
res.status(500).json({
success: false,
message: errorMsg,
message: "NOT NULL 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* UNIQUE 토글 (회사별 소프트 제약조건)
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*
* DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다.
* 저장 시 앱 레벨에서 중복 검증을 수행한다.
*/
export async function toggleColumnUnique(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { unique } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof unique !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, unique(boolean)이 필요합니다.",
});
return;
}
const isUniqueValue = unique ? "Y" : "N";
if (unique) {
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const dupQuery = companyCode === "*"
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
const dupParams = companyCode === "*" ? [] : [companyCode];
const dupResult = await query<any>(dupQuery, dupParams);
if (dupResult.length > 0) {
const dupDetails = dupResult
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
.join(", ");
res.status(400).json({
success: false,
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
});
return;
}
}
}
// table_type_columns에 회사별 is_unique 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
[tableName, columnName, isUniqueValue, companyCode]
);
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: unique
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
});
} catch (error: any) {
logger.error("UNIQUE 토글 오류:", error);
res.status(500).json({
success: false,
message: "UNIQUE 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}

View File

@@ -17,6 +17,11 @@ router.get("/:bomId/header", bomController.getBomHeader);
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 엑셀 업로드/다운로드
router.post("/excel-upload", bomController.createBomFromExcel);
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);

View File

@@ -0,0 +1,36 @@
/**
* 공정 작업기준 라우트
*/
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/processWorkStandardController";
const router = express.Router();
router.use(authenticateToken);
// 품목/라우팅/공정 조회 (좌측 트리)
router.get("/items", ctrl.getItemsWithRouting);
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
// 기본 버전 설정/해제
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
// 작업 항목 CRUD
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
router.post("/work-items", ctrl.createWorkItem);
router.put("/work-items/:id", ctrl.updateWorkItem);
router.delete("/work-items/:id", ctrl.deleteWorkItem);
// 작업 항목 상세 CRUD
router.get("/work-items/:workItemId/details", ctrl.getWorkItemDetails);
router.post("/work-item-details", ctrl.createWorkItemDetail);
router.put("/work-item-details/:id", ctrl.updateWorkItemDetail);
router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
// 전체 저장 (일괄)
router.put("/save-all", ctrl.saveAll);
export default router;

View File

@@ -32,6 +32,7 @@ import {
setTablePrimaryKey, // 🆕 PK 설정
toggleTableIndex, // 🆕 인덱스 토글
toggleColumnNullable, // 🆕 NOT NULL 토글
toggleColumnUnique, // 🆕 UNIQUE 토글
} from "../controllers/tableManagementController";
const router = express.Router();
@@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex);
*/
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
/**
* UNIQUE 토글
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*/
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
/**
* 테이블 존재 여부 확인
* GET /api/table-management/tables/:tableName/exists

View File

@@ -322,6 +322,485 @@ export async function initializeBomVersion(
});
}
// ─── BOM 엑셀 업로드 ─────────────────────────────
interface BomExcelRow {
level: number;
item_number: string;
item_name?: string;
quantity: number;
unit?: string;
process_type?: string;
remark?: string;
}
interface BomExcelUploadResult {
success: boolean;
insertedCount: number;
skippedCount: number;
errors: string[];
unmatchedItems: string[];
createdBomId?: string;
}
/**
* BOM 엑셀 업로드 - 새 BOM 생성
*
* 엑셀 레벨 체계:
* 레벨 0 = BOM 마스터 (최상위 품목) → bom 테이블에 INSERT
* 레벨 1 = 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
* 레벨 2 = 자품목의 자품목 → bom_detail (parent_detail_id=부모ID, DB level=1)
* 레벨 N = ... → bom_detail (DB level=N-1)
*/
export async function createBomFromExcel(
companyCode: string,
userId: string,
rows: BomExcelRow[],
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const headerRow = rows.find(r => r.level === 0);
const detailRows = rows.filter(r => r.level > 0);
if (!headerRow) {
result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다");
return result;
}
if (!headerRow.item_number?.trim()) {
result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다");
return result;
}
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. 모든 품번 일괄 조회 (헤더 + 디테일)
const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, allItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of allItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 2. bom 마스터 생성 (레벨 0)
const headerItemInfo = itemMap.get(headerRow.item_number.trim())!;
// 동일 품목으로 이미 BOM이 존재하는지 확인
const dupCheck = await client.query(
`SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`,
[headerItemInfo.id, companyCode],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`);
return result;
}
const bomInsert = await client.query(
`INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8)
RETURNING id`,
[
headerItemInfo.id,
headerRow.item_number.trim(),
headerItemInfo.item_name,
String(headerRow.quantity || 1),
headerRow.unit || headerItemInfo.unit || null,
headerRow.remark || null,
userId,
companyCode,
],
);
const newBomId = bomInsert.rows[0].id;
result.createdBomId = newBomId;
// 3. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`,
[newBomId, userId, companyCode],
);
const versionId = versionInsert.rows[0].id;
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
[versionId, newBomId],
);
// 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1)
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
newBomId,
versionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 5. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", {
newBomId, companyCode,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM 엑셀 업로드 - 기존 BOM에 새 버전 생성
*
* 엑셀에 레벨 0 행이 있으면 건너뛰고 (마스터는 이미 존재)
* 레벨 1 이상만 bom_detail로 INSERT, 새 bom_version에 연결
*/
export async function createBomVersionFromExcel(
bomId: string,
companyCode: string,
userId: string,
rows: BomExcelRow[],
versionName?: string,
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const detailRows = rows.filter(r => r.level > 0);
result.skippedCount = rows.length - detailRows.length;
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. BOM 존재 확인
const bomRow = await client.query(
`SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`,
[bomId, companyCode],
);
if (bomRow.rows.length === 0) {
result.errors.push("BOM을 찾을 수 없습니다");
return result;
}
// 2. 품번 → item_info 매핑
const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, uniqueItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of uniqueItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 3. 버전명 결정 (미입력 시 자동 채번)
let finalVersionName = versionName?.trim();
if (!finalVersionName) {
const countResult = await client.query(
`SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`,
[bomId],
);
finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
}
// 중복 체크
const dupCheck = await client.query(
`SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`,
[bomId, finalVersionName],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`);
return result;
}
// 4. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`,
[bomId, finalVersionName, userId, companyCode],
);
const newVersionId = versionInsert.rows[0].id;
// 5. bom_detail INSERT
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
bomId,
newVersionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 6. BOM 헤더의 version과 current_version_id 갱신
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[finalVersionName, newVersionId, bomId],
);
// 7. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
result.createdBomId = bomId;
logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", {
bomId, companyCode, versionName: finalVersionName,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM 엑셀 다운로드용 데이터 조회
*
* 화면과 동일한 레벨 체계로 출력:
* 레벨 0 = BOM 헤더 (최상위 품목)
* 레벨 1 = 직접 자품목 (DB level=0)
* 레벨 N = DB level N-1
*
* DFS로 순회하여 부모-자식 순서 보장
*/
export async function downloadBomExcelData(
bomId: string,
companyCode: string,
): Promise<Record<string, any>[]> {
// BOM 헤더 정보 조회 (최상위 품목)
const bomHeader = await queryOne<Record<string, any>>(
`SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit
FROM bom b
LEFT JOIN item_info ii ON b.item_id = ii.id
WHERE b.id = $1 AND b.company_code = $2`,
[bomId, companyCode],
);
if (!bomHeader) return [];
const flatList: Record<string, any>[] = [];
// 레벨 0: BOM 헤더 (최상위 품목)
flatList.push({
level: 0,
item_number: bomHeader.item_number || "",
item_name: bomHeader.item_name || "",
quantity: bomHeader.base_qty || "1",
unit: bomHeader.item_unit || bomHeader.unit || "",
process_type: "",
remark: bomHeader.remark || "",
_is_header: true,
});
// 하위 품목 조회
const versionId = bomHeader.current_version_id;
const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`;
const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode];
const details = await query(
`SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material
FROM bom_detail bd
LEFT JOIN item_info ii ON bd.child_item_id = ii.id
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`,
params,
);
// 부모 ID별 자식 목록으로 맵 구성
const childrenMap = new Map<string, any[]>();
const roots: any[] = [];
for (const d of details) {
if (!d.parent_detail_id) {
roots.push(d);
} else {
if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []);
childrenMap.get(d.parent_detail_id)!.push(d);
}
}
// DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용)
const dfs = (nodes: any[], depth: number) => {
for (const node of nodes) {
flatList.push({
level: depth,
item_number: node.item_number || "",
item_name: node.item_name || "",
quantity: node.quantity || "1",
unit: node.unit || node.item_unit || "",
process_type: node.process_type || "",
remark: node.remark || "",
});
const children = childrenMap.get(node.id) || [];
if (children.length > 0) {
dfs(children, depth + 1);
}
}
};
// 루트 노드들은 레벨 1 (BOM 헤더가 0이므로)
dfs(roots, 1);
return flatList;
}
/**
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
*/

View File

@@ -14,6 +14,35 @@ interface NumberingRulePart {
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 {
@@ -141,7 +170,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, {
@@ -274,7 +303,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;
@@ -381,7 +410,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("✅ 규칙 파트 조회 성공", {
ruleId: rule.ruleId,
@@ -517,7 +546,7 @@ class NumberingRuleService {
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}`, {
@@ -633,7 +662,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
return rule;
}
@@ -708,17 +737,25 @@ class NumberingRuleService {
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(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
const savedPart = partResult.rows[0];
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
await client.query("COMMIT");
@@ -820,17 +857,23 @@ class NumberingRuleService {
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(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
const savedPart = partResult.rows[0];
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
}
@@ -1053,7 +1096,8 @@ class NumberingRuleService {
}
}));
const previewCode = parts.join(rule.separator || "");
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
logger.info("코드 미리보기 생성", {
ruleId,
previewCode,
@@ -1164,8 +1208,8 @@ class NumberingRuleService {
}
}));
const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator);
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
@@ -1382,7 +1426,8 @@ class NumberingRuleService {
}
}));
const allocatedCode = parts.join(rule.separator || "");
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
// 순번이 있는 경우에만 증가
const hasSequence = rule.parts.some(
@@ -1541,7 +1586,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
@@ -1634,7 +1679,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
ruleId: rule.ruleId,
@@ -1754,12 +1799,14 @@ class NumberingRuleService {
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(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
@@ -1914,7 +1961,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
ruleId: rule.ruleId,
@@ -1973,7 +2020,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
ruleId: rule.ruleId,
@@ -2056,7 +2103,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;

View File

@@ -199,7 +199,15 @@ export class TableManagementService {
cl.input_type as "cl_input_type",
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
COALESCE(ttc.description, cl.description, '') as "description",
c.is_nullable as "isNullable",
CASE
WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@@ -241,7 +249,15 @@ export class TableManagementService {
COALESCE(cl.input_type, 'direct') as "inputType",
COALESCE(cl.detail_settings::text, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable",
CASE
WHEN cl.is_nullable IS NOT NULL
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN cl.is_unique = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@@ -502,8 +518,8 @@ export class TableManagementService {
table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, is_nullable,
company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
company_code, category_ref, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
@@ -516,6 +532,7 @@ export class TableManagementService {
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
category_ref = EXCLUDED.category_ref,
updated_date = NOW()`,
[
tableName,
@@ -531,6 +548,7 @@ export class TableManagementService {
settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true,
companyCode,
settings.categoryRef || null,
]
);
@@ -1599,7 +1617,8 @@ export class TableManagementService {
tableName,
columnName,
actualValue,
paramIndex
paramIndex,
operator
);
case "entity":
@@ -1612,7 +1631,14 @@ export class TableManagementService {
);
default:
// 기본 문자열 검색 (actualValue 사용)
// operator에 따라 정확 일치 또는 부분 일치 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(actualValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
@@ -1626,10 +1652,19 @@ export class TableManagementService {
);
// 오류 시 기본 검색으로 폴백
let fallbackValue = value;
let fallbackOperator = "contains";
if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value;
fallbackOperator = value.operator || "contains";
}
if (fallbackOperator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(fallbackValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`],
@@ -1776,7 +1811,8 @@ export class TableManagementService {
tableName: string,
columnName: string,
value: any,
paramIndex: number
paramIndex: number,
operator: string = "contains"
): Promise<{
whereClause: string;
values: any[];
@@ -1786,7 +1822,14 @@ export class TableManagementService {
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
// 코드 타입이 아니면 기본 검색
// 코드 타입이 아니면 operator에 따라 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
@@ -1794,6 +1837,15 @@ export class TableManagementService {
};
}
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// 코드값 또는 코드명으로 검색
return {
@@ -2431,6 +2483,154 @@ export class TableManagementService {
return value;
}
/**
* 회사별 NOT NULL 소프트 제약조건 검증
* table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다.
*/
async validateNotNullConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
const notNullColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_nullable = 'N'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (notNullColumns.length === 0 && companyCode !== "*") {
const globalNotNull = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_nullable = 'N'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
notNullColumns.push(...globalNotNull);
}
if (notNullColumns.length === 0) return [];
const violations: string[] = [];
for (const col of notNullColumns) {
const value = data[col.column_name];
// NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리
if (value === null || value === undefined || value === "") {
violations.push(col.column_label);
}
}
return violations;
} catch (error) {
logger.error(`NOT NULL 검증 오류: ${tableName}`, error);
return [];
}
}
/**
* 회사별 UNIQUE 소프트 제약조건 검증
* table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다.
* @param excludeId 수정 시 자기 자신은 제외
*/
async validateUniqueConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string,
excludeId?: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (uniqueColumns.length === 0 && companyCode !== "*") {
const globalUnique = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
uniqueColumns = globalUnique;
}
if (uniqueColumns.length === 0) return [];
const violations: string[] = [];
for (const col of uniqueColumns) {
const value = data[col.column_name];
if (value === null || value === undefined || value === "") continue;
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
let dupQuery: string;
let dupParams: any[];
if (hasCompanyCode.length > 0 && companyCode !== "*") {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
} else {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
dupParams = excludeId ? [value, excludeId] : [value];
}
const dupResult = await query(dupQuery, dupParams);
if (dupResult.length > 0) {
violations.push(`${col.column_label} (${value})`);
}
}
return violations;
} catch (error) {
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
return [];
}
}
/**
* 테이블에 데이터 추가
* @returns 무시된 컬럼 정보 (디버깅용)
@@ -4355,7 +4555,8 @@ export class TableManagementService {
END as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType",
ttc.company_code as "companyCode"
ttc.company_code as "companyCode",
ttc.category_ref as "categoryRef"
FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
@@ -4432,20 +4633,24 @@ export class TableManagementService {
}
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo = {
const baseInfo: any = {
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
description: "",
isNullable: col.isNullable === "Y" ? "Y" : "N",
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
};
if (col.categoryRef) {
baseInfo.categoryRef = col.categoryRef;
}
// 카테고리 타입인 경우 categoryMenus 추가
if (
col.inputType === "category" &&

View File

@@ -44,6 +44,7 @@ export interface ColumnSettings {
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
displayOrder?: number; // 표시 순서
isVisible?: boolean; // 표시 여부
categoryRef?: string | null; // 카테고리 참조
}
export interface TableLabels {