jskim-node #410
|
|
@ -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}개 컬럼`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. 메인 테이블 저장
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: 현재 로드된 데이터에서 고유 값 추출
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue