Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-11-24 10:02:58 +09:00
16 changed files with 836 additions and 784 deletions

View File

@@ -41,6 +41,7 @@ export class DashboardController {
isPublic = false,
tags,
category,
settings,
}: CreateDashboardRequest = req.body;
// 유효성 검증
@@ -85,6 +86,7 @@ export class DashboardController {
elements,
tags,
category,
settings,
};
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });

View File

@@ -165,7 +165,7 @@ export async function createOrder(req: AuthenticatedRequest, res: Response) {
}
/**
* 수주 목록 조회 API
* 수주 목록 조회 API (마스터 + 품목 JOIN)
* GET /api/orders
*/
export async function getOrders(req: AuthenticatedRequest, res: Response) {
@@ -184,14 +184,14 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
// 멀티테넌시 (writer 필드에 company_code 포함)
if (companyCode !== "*") {
whereConditions.push(`writer LIKE $${paramIndex}`);
whereConditions.push(`m.writer LIKE $${paramIndex}`);
params.push(`%${companyCode}%`);
paramIndex++;
}
// 검색
if (searchText) {
whereConditions.push(`objid LIKE $${paramIndex}`);
whereConditions.push(`m.objid LIKE $${paramIndex}`);
params.push(`%${searchText}%`);
paramIndex++;
}
@@ -201,16 +201,47 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 카운트 쿼리
const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`;
// 카운트 쿼리 (고유한 수주 개수)
const countQuery = `
SELECT COUNT(DISTINCT m.objid) as count
FROM order_mng_master m
${whereClause}
`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0]?.count || "0");
// 데이터 쿼리
// 데이터 쿼리 (마스터 + 품목 JOIN)
const dataQuery = `
SELECT * FROM order_mng_master
SELECT
m.objid as order_no,
m.partner_objid,
m.final_delivery_date,
m.reason,
m.status,
m.reg_date,
m.writer,
COALESCE(
json_agg(
CASE WHEN s.objid IS NOT NULL THEN
json_build_object(
'sub_objid', s.objid,
'part_objid', s.part_objid,
'partner_price', s.partner_price,
'partner_qty', s.partner_qty,
'delivery_date', s.delivery_date,
'status', s.status,
'regdate', s.regdate
)
END
ORDER BY s.regdate
) FILTER (WHERE s.objid IS NOT NULL),
'[]'::json
) as items
FROM order_mng_master m
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
${whereClause}
ORDER BY reg_date DESC
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
ORDER BY m.reg_date DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
@@ -219,6 +250,13 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
const dataResult = await pool.query(dataQuery, params);
logger.info("수주 목록 조회 성공", {
companyCode,
total,
page: parseInt(page as string),
itemCount: dataResult.rows.length,
});
res.json({
success: true,
data: dataResult.rows,

View File

@@ -24,10 +24,20 @@ export class DashboardService {
const dashboardId = uuidv4();
const now = new Date();
console.log("🔍 [createDashboard] 받은 데이터:", {
title: data.title,
settings: data.settings,
settingsType: typeof data.settings,
settingsStringified: JSON.stringify(data.settings || {}),
});
try {
// 트랜잭션으로 대시보드와 요소들을 함께 생성
const result = await PostgreSQLService.transaction(async (client) => {
// 1. 대시보드 메인 정보 저장
const settingsJson = JSON.stringify(data.settings || {});
console.log("🔍 [createDashboard] DB INSERT settings:", settingsJson);
await client.query(
`
INSERT INTO dashboards (
@@ -46,7 +56,7 @@ export class DashboardService {
JSON.stringify(data.tags || []),
data.category || null,
0,
JSON.stringify(data.settings || {}),
settingsJson,
companyCode || "DEFAULT",
]
);
@@ -351,6 +361,13 @@ export class DashboardService {
const dashboard = dashboardResult.rows[0];
// 🔍 디버깅: settings 원본 확인
console.log("🔍 [getDashboardById] dashboard.settings 원본:", {
type: typeof dashboard.settings,
value: dashboard.settings,
raw: JSON.stringify(dashboard.settings),
});
// 2. 대시보드 요소들 조회
const elementsQuery = `
SELECT * FROM dashboard_elements
@@ -400,7 +417,21 @@ export class DashboardService {
})
);
return {
// 🔍 디버깅: settings 파싱
let parsedSettings = undefined;
if (dashboard.settings) {
if (typeof dashboard.settings === 'string') {
parsedSettings = JSON.parse(dashboard.settings);
console.log("🔍 [getDashboardById] settings 문자열 파싱:", parsedSettings);
} else {
parsedSettings = dashboard.settings;
console.log("🔍 [getDashboardById] settings 이미 객체:", parsedSettings);
}
} else {
console.log("🔍 [getDashboardById] settings 없음 (null/undefined)");
}
const result = {
id: dashboard.id,
title: dashboard.title,
description: dashboard.description,
@@ -412,9 +443,13 @@ export class DashboardService {
tags: JSON.parse(dashboard.tags || "[]"),
category: dashboard.category,
viewCount: parseInt(dashboard.view_count || "0"),
settings: dashboard.settings || undefined,
settings: parsedSettings,
elements,
};
console.log("🔍 [getDashboardById] 최종 반환 settings:", result.settings);
return result;
} catch (error) {
console.error("Dashboard get error:", error);
throw error;
@@ -477,8 +512,13 @@ export class DashboardService {
paramIndex++;
}
if (data.settings !== undefined) {
const settingsJson = JSON.stringify(data.settings);
console.log("🔍 [updateDashboard] DB UPDATE settings:", {
original: data.settings,
stringified: settingsJson,
});
updateFields.push(`settings = $${paramIndex}`);
updateParams.push(JSON.stringify(data.settings));
updateParams.push(settingsJson);
paramIndex++;
}

View File

@@ -320,19 +320,34 @@ export class DynamicFormService {
Object.keys(dataToInsert).forEach((key) => {
const value = dataToInsert[key];
// RepeaterInput 데이터인지 확인 (JSON 배열 문자열)
if (
// 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열)
let parsedArray: any[] | null = null;
// 1⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등)
if (Array.isArray(value) && value.length > 0) {
parsedArray = value;
console.log(
`🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목`
);
}
// 2⃣ JSON 문자열인 경우 (레거시 RepeaterInput)
else if (
typeof value === "string" &&
value.trim().startsWith("[") &&
value.trim().endsWith("]")
) {
try {
const parsedArray = JSON.parse(value);
if (Array.isArray(parsedArray) && parsedArray.length > 0) {
parsedArray = JSON.parse(value);
console.log(
`🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목`
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
);
} catch (parseError) {
console.log(`⚠️ JSON 파싱 실패: ${key}`);
}
}
// 파싱된 배열이 있으면 처리
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
let targetTable: string | undefined;
@@ -352,13 +367,34 @@ export class DynamicFormService {
componentId: key,
});
delete dataToInsert[key]; // 원본 배열 데이터는 제거
}
} catch (parseError) {
console.log(`⚠️ JSON 파싱 실패: ${key}`);
}
console.log(`✅ Repeater 데이터 추가: ${key}`, {
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
itemCount: actualData.length,
firstItem: actualData[0],
});
}
});
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
const separateRepeaterData: typeof repeaterData = [];
const mergedRepeaterData: typeof repeaterData = [];
repeaterData.forEach(repeater => {
if (repeater.targetTable && repeater.targetTable !== tableName) {
// 다른 테이블: 나중에 별도 저장
separateRepeaterData.push(repeater);
} else {
// 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에)
mergedRepeaterData.push(repeater);
}
});
console.log(`🔄 Repeater 데이터 분류:`, {
separate: separateRepeaterData.length, // 별도 테이블
merged: mergedRepeaterData.length, // 메인 테이블과 병합
});
// 존재하지 않는 컬럼 제거
Object.keys(dataToInsert).forEach((key) => {
if (!tableColumns.includes(key)) {
@@ -369,9 +405,6 @@ export class DynamicFormService {
}
});
// RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리
// (각 Repeater가 다른 테이블에 저장될 수 있으므로)
console.log("🎯 실제 테이블에 삽입할 데이터:", {
tableName,
dataToInsert,
@@ -452,28 +485,95 @@ export class DynamicFormService {
const userId = data.updated_by || data.created_by || "system";
const clientIp = ipAddress || "unknown";
const result = await transaction(async (client) => {
// 세션 변수 설정
await client.query(`SET LOCAL app.user_id = '${userId}'`);
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
// UPSERT 실행
const res = await client.query(upsertQuery, values);
return res.rows;
});
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
let result: any[];
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
if (mergedRepeaterData.length > 0) {
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
result = [];
for (const repeater of mergedRepeaterData) {
for (const item of repeater.data) {
// 헤더 + 품목을 병합
const mergedData = { ...dataToInsert, ...item };
// 타입 변환
Object.keys(mergedData).forEach((columnName) => {
const column = columnInfo.find((col) => col.column_name === columnName);
if (column) {
mergedData[columnName] = this.convertValueForPostgreSQL(
mergedData[columnName],
column.data_type
);
}
});
const mergedColumns = Object.keys(mergedData);
const mergedValues: any[] = Object.values(mergedData);
const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", ");
let mergedUpsertQuery: string;
if (primaryKeys.length > 0) {
const conflictColumns = primaryKeys.join(", ");
const updateSet = mergedColumns
.filter((col) => !primaryKeys.includes(col))
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
mergedUpsertQuery = updateSet
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
VALUES (${mergedPlaceholders})
ON CONFLICT (${conflictColumns})
DO UPDATE SET ${updateSet}
RETURNING *`
: `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
VALUES (${mergedPlaceholders})
ON CONFLICT (${conflictColumns})
DO NOTHING
RETURNING *`;
} else {
mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
VALUES (${mergedPlaceholders})
RETURNING *`;
}
console.log(`📝 병합 INSERT:`, { mergedData });
const itemResult = await transaction(async (client) => {
await client.query(`SET LOCAL app.user_id = '${userId}'`);
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
const res = await client.query(mergedUpsertQuery, mergedValues);
return res.rows[0];
});
result.push(itemResult);
}
}
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
} else {
// 일반 모드: 헤더만 저장
result = await transaction(async (client) => {
await client.query(`SET LOCAL app.user_id = '${userId}'`);
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
const res = await client.query(upsertQuery, values);
return res.rows;
});
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
}
// 결과를 표준 형식으로 변환
const insertedRecord = Array.isArray(result) ? result[0] : result;
// 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장)
if (repeaterData.length > 0) {
// 📝 별도 테이블 Repeater 데이터 저장
if (separateRepeaterData.length > 0) {
console.log(
`🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length} Repeater`
`🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}`
);
for (const repeater of repeaterData) {
for (const repeater of separateRepeaterData) {
const targetTableName = repeater.targetTable || tableName;
console.log(
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
@@ -497,8 +597,13 @@ export class DynamicFormService {
created_by,
updated_by,
regdate: new Date(),
// 🔥 멀티테넌시: company_code 필수 추가
company_code: data.company_code || company_code,
};
// 🔥 별도 테이블인 경우에만 외래키 추가
// (같은 테이블이면 이미 병합 모드에서 처리됨)
// 대상 테이블에 존재하는 컬럼만 필터링
Object.keys(itemData).forEach((key) => {
if (!targetColumnNames.includes(key)) {

View File

@@ -0,0 +1,80 @@
/**
* 수주 관리 타입 정의
*/
/**
* 수주 품목 (order_mng_sub)
*/
export interface OrderItem {
sub_objid: string; // 품목 고유 ID (예: ORD-20251121-051_1)
part_objid: string; // 품목 코드
partner_price: number; // 단가
partner_qty: number; // 수량
delivery_date: string | null; // 납기일
status: string; // 상태
regdate: string; // 등록일
}
/**
* 수주 마스터 (order_mng_master)
*/
export interface OrderMaster {
order_no: string; // 수주 번호 (예: ORD-20251121-051)
partner_objid: string; // 거래처 코드
final_delivery_date: string | null; // 최종 납품일
reason: string | null; // 메모/사유
status: string; // 상태
reg_date: string; // 등록일
writer: string; // 작성자 (userId|companyCode)
}
/**
* 수주 + 품목 (API 응답)
*/
export interface OrderWithItems extends OrderMaster {
items: OrderItem[]; // 품목 목록
}
/**
* 수주 등록 요청
*/
export interface CreateOrderRequest {
inputMode: string; // 입력 방식
salesType?: string; // 판매 유형 (국내/해외)
priceType?: string; // 단가 방식
customerCode: string; // 거래처 코드
contactPerson?: string; // 담당자
deliveryDestination?: string; // 납품처
deliveryAddress?: string; // 납품장소
deliveryDate?: string; // 납품일
items: Array<{
item_code?: string; // 품목 코드
id?: string; // 품목 ID (item_code 대체)
quantity?: number; // 수량
unit_price?: number; // 단가
selling_price?: number; // 판매가
amount?: number; // 금액
delivery_date?: string; // 품목별 납기일
}>;
memo?: string; // 메모
tradeInfo?: {
// 해외 판매 시
incoterms?: string;
paymentTerms?: string;
currency?: string;
portOfLoading?: string;
portOfDischarge?: string;
hsCode?: string;
};
}
/**
* 수주 등록 응답
*/
export interface CreateOrderResponse {
orderNo: string; // 생성된 수주 번호
masterObjid: string; // 마스터 ID
itemCount: number; // 품목 개수
totalAmount: number; // 전체 금액
}