jskim-node #410

Merged
kjs merged 55 commits from jskim-node into main 2026-03-10 16:34:15 +09:00
10 changed files with 225 additions and 47 deletions
Showing only changes of commit 3982aabc24 - Show all commits

View File

@ -3563,19 +3563,21 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
// 회사별 라벨 우선, 없으면 공통(*) 라벨 사용
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
const schemaQuery = `
SELECT
ic.column_name,
ic.data_type,
ic.is_nullable,
ic.is_nullable AS db_is_nullable,
ic.column_default,
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc_common
ON ttc_common.table_name = ic.table_name
@ -3600,17 +3602,28 @@ export async function getTableSchema(
return;
}
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
const columnList = columns.map((col: any) => ({
name: col.column_name,
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
type: col.data_type,
nullable: col.is_nullable === "YES",
default: col.column_default,
maxLength: col.character_maximum_length,
precision: col.numeric_precision,
scale: col.numeric_scale,
}));
// 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영)
const columnList = columns.map((col: any) => {
// DB level nullable + 회사별 table_type_columns 제약조건 통합
// table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도)
const dbNullable = col.db_is_nullable === "YES";
const ttcNotNull = col.ttc_is_nullable === "N";
const effectiveNullable = ttcNotNull ? false : dbNullable;
const ttcUnique = col.ttc_is_unique === "Y";
return {
name: col.column_name,
label: col.column_label || col.column_name,
type: col.data_type,
nullable: effectiveNullable,
unique: ttcUnique,
default: col.column_default,
maxLength: col.character_maximum_length,
precision: col.numeric_precision,
scale: col.numeric_scale,
};
});
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);

View File

@ -8,6 +8,7 @@ import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query } from "../database/db";
import logger from "../utils/logger";
import { TableManagementService } from "../services/tableManagementService";
/**
*
@ -81,6 +82,19 @@ async function executeMainDatabaseAction(
company_code: companyCode,
};
// UNIQUE 제약조건 검증 (INSERT/UPDATE/UPSERT 전)
if (["insert", "update", "upsert"].includes(actionType.toLowerCase())) {
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
dataWithCompany,
companyCode
);
if (uniqueViolations.length > 0) {
throw new Error(`중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`);
}
}
switch (actionType.toLowerCase()) {
case "insert":
return await executeInsert(tableName, dataWithCompany);

View File

@ -1,6 +1,7 @@
import { Response } from "express";
import { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { TableManagementService } from "../services/tableManagementService";
import { AuthenticatedRequest } from "../types/auth";
import { formatPgError } from "../utils/pgErrorUtil";
@ -48,6 +49,21 @@ export const saveFormData = async (
formDataWithMeta.company_code = companyCode;
}
// UNIQUE 제약조건 검증 (INSERT 전)
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
// 클라이언트 IP 주소 추출
const ipAddress =
req.ip ||
@ -112,6 +128,21 @@ export const saveFormDataEnhanced = async (
formDataWithMeta.company_code = companyCode;
}
// UNIQUE 제약조건 검증 (INSERT 전)
const tmsEnhanced = new TableManagementService();
const uniqueViolations = await tmsEnhanced.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
// 개선된 서비스 사용
const result = await enhancedDynamicFormService.saveFormData(
screenId,
@ -153,12 +184,28 @@ export const updateFormData = async (
const formDataWithMeta = {
...data,
updated_by: userId,
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
writer: data.writer || userId,
updated_at: new Date(),
};
// UNIQUE 제약조건 검증 (UPDATE 시 자기 자신 제외)
const tmsUpdate = new TableManagementService();
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*",
id
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
const result = await dynamicFormService.updateFormData(
id, // parseInt 제거 - 문자열 ID 지원
id,
tableName,
formDataWithMeta
);
@ -209,11 +256,27 @@ export const updateFormDataPartial = async (
const newDataWithMeta = {
...newData,
updated_by: userId,
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
writer: newData.writer || userId,
};
// UNIQUE 제약조건 검증 (부분 UPDATE 시 자기 자신 제외)
const tmsPartial = new TableManagementService();
const uniqueViolations = await tmsPartial.validateUniqueConstraints(
tableName,
newDataWithMeta,
companyCode || "*",
id
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
const result = await dynamicFormService.updateFormDataPartial(
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
id,
tableName,
originalData,
newDataWithMeta

View File

@ -2087,6 +2087,23 @@ export async function multiTableSave(
return;
}
// UNIQUE 제약조건 검증 (트랜잭션 전에)
const tmsMulti = new TableManagementService();
const uniqueViolations = await tmsMulti.validateUniqueConstraints(
mainTable.tableName,
mainData,
companyCode,
isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined
);
if (uniqueViolations.length > 0) {
client.release();
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
await client.query("BEGIN");
// 1. 메인 테이블 저장

View File

@ -41,7 +41,18 @@ export const errorHandler = (
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (pgError.code === "23505") {
// unique_violation
error = new AppError("중복된 데이터가 존재합니다.", 400);
const constraint = pgError.constraint || "";
const tbl = pgError.table || "";
let col = "";
if (constraint && tbl) {
const prefix = `${tbl}_`;
const suffix = "_key";
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
col = constraint.slice(prefix.length, -suffix.length);
}
}
const detail = col ? ` [${col}]` : "";
error = new AppError(`중복된 데이터가 존재합니다.${detail}`, 400);
} else if (pgError.code === "23503") {
// foreign_key_violation
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);

View File

@ -5,6 +5,8 @@ import { multiTableExcelService, TableChainConfig } from "../services/multiTable
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService";
import { TableManagementService } from "../services/tableManagementService";
import { formatPgError } from "../utils/pgErrorUtil";
const router = express.Router();
@ -950,6 +952,20 @@ router.post(
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
}
// UNIQUE 제약조건 검증
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
enrichedData,
req.user?.companyCode || "*"
);
if (uniqueViolations.length > 0) {
return res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
}
// 레코드 생성
const result = await dataService.createRecord(tableName, enrichedData);
@ -1019,6 +1035,21 @@ router.put(
console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data);
// UNIQUE 제약조건 검증 (자기 자신 제외)
const tmsUpdate = new TableManagementService();
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
tableName,
data,
req.user?.companyCode || "*",
String(id)
);
if (uniqueViolations.length > 0) {
return res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
}
// 레코드 수정
const result = await dataService.updateRecord(tableName, id, data);

View File

@ -970,10 +970,11 @@ class MultiTableExcelService {
const result = await pool.query(
`SELECT
c.column_name,
c.is_nullable,
c.is_nullable AS db_is_nullable,
c.column_default,
COALESCE(ttc.column_label, cl.column_label) AS column_label,
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
FROM information_schema.columns c
LEFT JOIN table_type_columns cl
ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*'
@ -991,13 +992,13 @@ class MultiTableExcelService {
// 시스템 컬럼 제외
if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue;
// FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조)
// 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로,
// _id로 끝나면서 reference_table이 있는 경우만 제외
// FK 컬럼 제외
if (row.reference_table && colName.endsWith("_id")) continue;
const hasDefault = row.column_default !== null;
const isNullable = row.is_nullable === "YES";
const dbNullable = row.db_is_nullable === "YES";
const ttcNotNull = row.ttc_is_nullable === "N";
const isNullable = ttcNotNull ? false : dbNullable;
const isRequired = !isNullable && !hasDefault;
columns.push({

View File

@ -37,8 +37,38 @@ export async function formatPgError(
const detail = colName ? ` [${colName}]` : "";
return `필수 입력값이 누락되었습니다.${detail}`;
}
case "23505":
return "중복된 데이터가 존재합니다.";
case "23505": {
// unique_violation
const constraint = error.constraint || "";
const tblName = error.table || "";
// constraint 이름에서 컬럼명 추출 시도 (예: item_mst_item_code_key → item_code)
let colName = "";
if (constraint && tblName) {
const prefix = `${tblName}_`;
const suffix = "_key";
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
colName = constraint.slice(prefix.length, -suffix.length);
}
}
if (colName && tblName && companyCode) {
try {
const rows = await query(
`SELECT column_label FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
LIMIT 1`,
[tblName, colName, companyCode]
);
const label = rows[0]?.column_label;
if (label) {
return `중복된 데이터가 존재합니다: ${label}`;
}
} catch {
// 폴백
}
}
const detail = colName ? ` [${colName}]` : "";
return `중복된 데이터가 존재합니다.${detail}`;
}
case "23503":
return "참조 무결성 제약 조건 위반입니다.";
default:

View File

@ -1061,19 +1061,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
// 카테고리/엔티티/코드 타입이 아닌 경우에만 DISTINCT API 사용
// (카테고리 등은 DISTINCT로 조회하면 코드만 반환되어 label이 없음)
if (!["category", "entity", "code"].includes(inputType)) {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
}
} catch (error: any) {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
}
} catch (error: any) {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
}
// fallback: 현재 로드된 데이터에서 고유 값 추출

View File

@ -467,14 +467,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
if (hasNewOptions) {
setSelectOptions((prev) => {
// 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합
const merged = { ...prev };
for (const [key, value] of Object.entries(loadedOptions)) {
if (!merged[key] || merged[key].length === 0) {
merged[key] = value;
}
}
return merged;
// 새로 로드된 옵션으로 항상 갱신 (카테고리 label 정보가 나중에 로드될 수 있으므로)
// 로드 실패한 컬럼의 기존 옵션은 유지
return { ...prev, ...loadedOptions };
});
}
};