Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map

This commit is contained in:
dohyeons
2025-11-25 09:53:36 +09:00
47 changed files with 7416 additions and 1372 deletions

View File

@@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil";
import { FileSystemManager } from "../utils/fileSystemManager";
import { validateBusinessNumber } from "../utils/businessNumberValidator";
import { MenuCopyService } from "../services/menuCopyService";
/**
* 관리자 메뉴 목록 조회
@@ -3253,3 +3254,93 @@ export async function getTableSchema(
});
}
}
/**
* 메뉴 복사
* POST /api/admin/menus/:menuObjid/copy
*/
export async function copyMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuObjid } = req.params;
const { targetCompanyCode } = req.body;
const userId = req.user!.userId;
const userCompanyCode = req.user!.companyCode;
const userType = req.user!.userType;
const isSuperAdmin = req.user!.isSuperAdmin;
logger.info(`
=== 메뉴 복사 API 호출 ===
menuObjid: ${menuObjid}
targetCompanyCode: ${targetCompanyCode}
userId: ${userId}
userCompanyCode: ${userCompanyCode}
userType: ${userType}
isSuperAdmin: ${isSuperAdmin}
`);
// 권한 체크: 최고 관리자만 가능
if (!isSuperAdmin && userType !== "SUPER_ADMIN") {
logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`);
res.status(403).json({
success: false,
message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다",
error: {
code: "FORBIDDEN",
details: "Only super admin can copy menus",
},
});
return;
}
// 필수 파라미터 검증
if (!menuObjid || !targetCompanyCode) {
res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다",
error: {
code: "MISSING_PARAMETERS",
details: "menuObjid and targetCompanyCode are required",
},
});
return;
}
// 화면명 변환 설정 (선택사항)
const screenNameConfig = req.body.screenNameConfig
? {
removeText: req.body.screenNameConfig.removeText,
addPrefix: req.body.screenNameConfig.addPrefix,
}
: undefined;
// 메뉴 복사 실행
const menuCopyService = new MenuCopyService();
const result = await menuCopyService.copyMenu(
parseInt(menuObjid, 10),
targetCompanyCode,
userId,
screenNameConfig
);
logger.info("✅ 메뉴 복사 API 성공");
res.json({
success: true,
message: "메뉴 복사 완료",
data: result,
});
} catch (error: any) {
logger.error("❌ 메뉴 복사 API 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 복사 중 오류가 발생했습니다",
error: {
code: "MENU_COPY_ERROR",
details: error.message || "Unknown error",
},
});
}
}

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

@@ -8,6 +8,7 @@ import {
deleteMenu, // 메뉴 삭제
deleteMenusBatch, // 메뉴 일괄 삭제
toggleMenuStatus, // 메뉴 상태 토글
copyMenu, // 메뉴 복사
getUserList,
getUserInfo, // 사용자 상세 조회
getUserHistory, // 사용자 변경이력 조회
@@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus);
router.get("/user-menus", getUserMenus);
router.get("/menus/:menuId", getMenuInfo);
router.post("/menus", saveMenu); // 메뉴 추가
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
router.put("/menus/:menuId", updateMenu); // 메뉴 수정
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)

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,106 @@ 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 rawMergedData = { ...dataToInsert, ...item };
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
const validColumnNames = columnInfo.map((col) => col.column_name);
const mergedData: Record<string, any> = {};
Object.keys(rawMergedData).forEach((columnName) => {
// 실제 테이블 컬럼인지 확인
if (validColumnNames.includes(columnName)) {
const column = columnInfo.find((col) => col.column_name === columnName);
if (column) {
// 타입 변환
mergedData[columnName] = this.convertValueForPostgreSQL(
rawMergedData[columnName],
column.data_type
);
} else {
mergedData[columnName] = rawMergedData[columnName];
}
} else {
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
}
});
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 +608,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)) {

File diff suppressed because it is too large Load Diff

View File

@@ -300,10 +300,9 @@ class NumberingRuleService {
FROM numbering_rules
WHERE
scope_type = 'global'
OR scope_type = 'table'
OR (scope_type = 'menu' AND menu_objid = ANY($1))
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
OR (scope_type = 'table' AND menu_objid IS NULL) -- 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
@@ -313,9 +312,9 @@ class NumberingRuleService {
created_at DESC
`;
params = [siblingObjids];
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
} else {
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
query = `
SELECT
rule_id AS "ruleId",
@@ -336,10 +335,9 @@ class NumberingRuleService {
WHERE company_code = $1
AND (
scope_type = 'global'
OR scope_type = 'table'
OR (scope_type = 'menu' AND menu_objid = ANY($2))
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
OR (scope_type = 'table' AND menu_objid IS NULL) -- 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
)
ORDER BY
CASE
@@ -350,7 +348,7 @@ class NumberingRuleService {
created_at DESC
`;
params = [companyCode, siblingObjids];
logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids });
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
}
logger.info("🔍 채번 규칙 쿼리 실행", {

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; // 전체 금액
}