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:
@@ -1,4 +1,4 @@
|
||||
# PLM System Backend - Node.js + TypeScript
|
||||
re# PLM System Backend - Node.js + TypeScript
|
||||
|
||||
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
|
||||
|
||||
|
||||
@@ -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); // 임시 주석
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
713
backend-node/src/controllers/processWorkStandardController.ts
Normal file
713
backend-node/src/controllers/processWorkStandardController.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
36
backend-node/src/routes/processWorkStandardRoutes.ts
Normal file
36
backend-node/src/routes/processWorkStandardRoutes.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 행도 함께 삭제
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ColumnSettings {
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayOrder?: number; // 표시 순서
|
||||
isVisible?: boolean; // 표시 여부
|
||||
categoryRef?: string | null; // 카테고리 참조
|
||||
}
|
||||
|
||||
export interface TableLabels {
|
||||
|
||||
Reference in New Issue
Block a user