Merge branch 'ksh-v2-work' into ksh-partial-quantity-flow
ksh-v2-work의 최신 변경사항을 동기화한다. 주요 병합 내용: - GRID-V6 정사각형 블록 그리드 시스템 (842ac27d) - POP 그리드 시스템 명칭 통일 + Dead Code 제거 (320100c4) - 다수 PC 화면 config-panel 리팩토링 (jskim/mhkim/gbpark) - V2 컴포넌트 config-panel 신규 18종 - 감사 로그 기능 강화 충돌 해결: 0건 (자동 병합 완료)
This commit is contained in:
@@ -1854,7 +1854,7 @@ export async function toggleMenuStatus(
|
||||
|
||||
// 현재 상태 및 회사 코드 조회
|
||||
const currentMenu = await queryOne<any>(
|
||||
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
|
||||
`SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
||||
[Number(menuId)]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
|
||||
import { query } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
@@ -137,3 +137,40 @@ export const getAuditLogUsers = async (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
|
||||
*/
|
||||
export const createAuditLog = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
|
||||
|
||||
if (!action || !resourceType) {
|
||||
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
await auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: action as AuditAction,
|
||||
resourceType: resourceType as AuditResourceType,
|
||||
resourceId: resourceId || undefined,
|
||||
resourceName: resourceName || undefined,
|
||||
tableName: tableName || undefined,
|
||||
summary: summary || undefined,
|
||||
changes: changes || undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 기록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
|
||||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,6 +17,7 @@ router.use(authenticateToken);
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
@@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: String(value.valueId),
|
||||
resourceName: input.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
|
||||
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
@@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const updatedBy = req.user?.userId;
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||
|
||||
if (!value) {
|
||||
@@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: value.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
|
||||
changes: {
|
||||
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
|
||||
after: input,
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
@@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!success) {
|
||||
@@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: beforeValue?.valueLabel || valueId,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
|
||||
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "삭제되었습니다",
|
||||
|
||||
@@ -396,6 +396,20 @@ export class CommonCodeController {
|
||||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
resourceName: codeData.codeName || codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
|
||||
changes: { after: codeData },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: code,
|
||||
@@ -440,6 +454,19 @@ export class CommonCodeController {
|
||||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
|
||||
changes: { before: { categoryCode, codeValue } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "코드 삭제 성공",
|
||||
|
||||
@@ -438,6 +438,19 @@ export class DDLController {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId,
|
||||
action: "DELETE",
|
||||
resourceType: "TABLE",
|
||||
resourceId: tableName,
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `테이블 "${tableName}" 삭제`,
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
|
||||
@@ -193,6 +193,7 @@ router.post(
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(newRule.ruleId),
|
||||
@@ -243,6 +244,7 @@ router.put(
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
@@ -285,6 +287,7 @@ router.delete(
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
@@ -521,6 +524,56 @@ router.post(
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
const isUpdate = !!ruleConfig.ruleId;
|
||||
|
||||
const resetPeriodLabel: Record<string, string> = {
|
||||
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
|
||||
};
|
||||
const partTypeLabel: Record<string, string> = {
|
||||
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
|
||||
};
|
||||
const partsDescription = (ruleConfig.parts || [])
|
||||
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
|
||||
.map((p: any) => {
|
||||
const type = partTypeLabel[p.partType] || p.partType;
|
||||
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
|
||||
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
|
||||
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
|
||||
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
|
||||
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
|
||||
return type;
|
||||
})
|
||||
.join(` ${ruleConfig.separator || "-"} `);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: isUpdate ? "UPDATE" : "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(savedRule.ruleId),
|
||||
resourceName: ruleConfig.ruleName,
|
||||
tableName: "numbering_rules",
|
||||
summary: isUpdate
|
||||
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
|
||||
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||
changes: {
|
||||
after: {
|
||||
규칙명: ruleConfig.ruleName,
|
||||
적용테이블: ruleConfig.tableName || "(미지정)",
|
||||
적용컬럼: ruleConfig.columnName || "(미지정)",
|
||||
구분자: ruleConfig.separator || "-",
|
||||
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
|
||||
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
|
||||
코드구성: partsDescription || "(파트 없음)",
|
||||
파트수: (ruleConfig.parts || []).length,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
@@ -535,10 +588,25 @@ router.delete(
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
tableName: "numbering_rules",
|
||||
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||
|
||||
@@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
||||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
extraColumns = "",
|
||||
filterConditions = "",
|
||||
} = 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}%`);
|
||||
let paramIndex = 2;
|
||||
|
||||
// 검색 조건
|
||||
let searchCondition = "";
|
||||
if (search) {
|
||||
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 추가 컬럼 SELECT
|
||||
const extraColumnNames: string[] = extraColumns
|
||||
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
|
||||
// 사전 필터 조건
|
||||
let filterWhere = "";
|
||||
if (filterConditions) {
|
||||
try {
|
||||
const filters = JSON.parse(filterConditions) as Array<{
|
||||
column: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}>;
|
||||
for (const f of filters) {
|
||||
if (!f.column || !f.value) continue;
|
||||
if (f.operator === "equals") {
|
||||
filterWhere += ` AND i.${f.column} = $${paramIndex}`;
|
||||
params.push(f.value);
|
||||
} else if (f.operator === "contains") {
|
||||
filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`;
|
||||
params.push(`%${f.value}%`);
|
||||
} else if (f.operator === "not_equals") {
|
||||
filterWhere += ` AND i.${f.column} != $${paramIndex}`;
|
||||
params.push(f.value);
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
} catch { /* 파싱 실패 시 무시 */ }
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code,
|
||||
i.${codeColumn} AS item_code
|
||||
${extraSelect ? ", " + extraSelect : ""},
|
||||
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
|
||||
${filterWhere}
|
||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date
|
||||
ORDER BY i.created_date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
@@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 등록 품목 관리 (item_routing_registered)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 화면별 등록된 품목 목록 조회
|
||||
*/
|
||||
export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode } = req.params;
|
||||
const {
|
||||
tableName = "item_info",
|
||||
nameColumn = "item_name",
|
||||
codeColumn = "item_number",
|
||||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
extraColumns = "",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const params: any[] = [companyCode, screenCode];
|
||||
let paramIndex = 3;
|
||||
|
||||
let searchCondition = "";
|
||||
if (search) {
|
||||
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const extraColumnNames: string[] = extraColumns
|
||||
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
irr.id AS registered_id,
|
||||
irr.sort_order,
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code
|
||||
${extraSelect ? ", " + extraSelect : ""},
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM item_routing_registered irr
|
||||
JOIN ${tableName} i ON irr.item_id = i.id
|
||||
AND i.company_code = irr.company_code
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE irr.company_code = $1
|
||||
AND irr.screen_code = $2
|
||||
${searchCondition}
|
||||
GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}
|
||||
ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC
|
||||
`;
|
||||
|
||||
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 registerItem(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode, itemId, itemCode } = req.body;
|
||||
if (!screenCode || !itemId) {
|
||||
return res.status(400).json({ success: false, message: "screenCode, itemId 필수" });
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await getPool().query(query, [
|
||||
screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.json({ success: true, message: "이미 등록된 품목입니다", data: null });
|
||||
}
|
||||
|
||||
logger.info("품목 등록", { companyCode, screenCode, itemId });
|
||||
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 registerItemsBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { screenCode, items } = req.body;
|
||||
if (!screenCode || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "screenCode, items[] 필수" });
|
||||
}
|
||||
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const inserted: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||
RETURNING *`,
|
||||
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
|
||||
);
|
||||
if (result.rows[0]) inserted.push(result.rows[0]);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length });
|
||||
return res.json({ success: true, data: inserted });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("품목 일괄 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 품목 제거
|
||||
*/
|
||||
export async function unregisterItem(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 item_routing_registered 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
|
||||
modalScreens: modalScreens || [],
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: targetCompanyCode || companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: id,
|
||||
resourceName: mainScreen?.screenName,
|
||||
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
@@ -663,20 +649,6 @@ export const copyScreen = async (
|
||||
}
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: String(copiedScreen?.screenId || ""),
|
||||
resourceName: screenName,
|
||||
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, screenName, screenCode } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: copiedScreen,
|
||||
|
||||
@@ -963,6 +963,15 @@ export async function addTableData(
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||
|
||||
const systemFields = new Set([
|
||||
"id", "created_date", "updated_date", "writer", "company_code",
|
||||
"createdDate", "updatedDate", "companyCode",
|
||||
]);
|
||||
const auditData: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (!systemFields.has(k)) auditData[k] = v;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
@@ -973,7 +982,7 @@ export async function addTableData(
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `${tableName} 데이터 추가`,
|
||||
changes: { after: data },
|
||||
changes: { after: auditData },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
@@ -1096,10 +1105,14 @@ export async function editTableData(
|
||||
return;
|
||||
}
|
||||
|
||||
// 변경된 필드만 추출
|
||||
const systemFieldsForEdit = new Set([
|
||||
"id", "created_date", "updated_date", "writer", "company_code",
|
||||
"createdDate", "updatedDate", "companyCode",
|
||||
]);
|
||||
const changedBefore: Record<string, any> = {};
|
||||
const changedAfter: Record<string, any> = {};
|
||||
for (const key of Object.keys(updatedData)) {
|
||||
if (systemFieldsForEdit.has(key)) continue;
|
||||
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||
changedBefore[key] = originalData[key];
|
||||
changedAfter[key] = updatedData[key];
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", authenticateToken, getAuditLogs);
|
||||
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||
router.post("/", authenticateToken, createAuditLog);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
||||
// 전체 저장 (일괄)
|
||||
router.put("/save-all", ctrl.saveAll);
|
||||
|
||||
// 등록 품목 관리 (화면별 품목 목록)
|
||||
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
|
||||
router.post("/registered-items", ctrl.registerItem);
|
||||
router.post("/registered-items/batch", ctrl.registerItemsBatch);
|
||||
router.delete("/registered-items/:id", ctrl.unregisterItem);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface AuditLogParams {
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
company_name: string | null;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
@@ -107,6 +108,7 @@ class AuditLogService {
|
||||
*/
|
||||
async log(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
||||
await query(
|
||||
`INSERT INTO system_audit_log
|
||||
(company_code, user_id, user_name, action, resource_type,
|
||||
@@ -128,8 +130,9 @@ class AuditLogService {
|
||||
params.requestPath || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
||||
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,40 +189,40 @@ class AuditLogService {
|
||||
let paramIndex = 1;
|
||||
|
||||
if (!isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
} else if (isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
conditions.push(`sal.user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`);
|
||||
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
||||
params.push(filters.resourceType);
|
||||
}
|
||||
if (filters.action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
conditions.push(`sal.action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
if (filters.tableName) {
|
||||
conditions.push(`table_name = $${paramIndex++}`);
|
||||
conditions.push(`sal.table_name = $${paramIndex++}`);
|
||||
params.push(filters.tableName);
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateFrom);
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateTo);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
||||
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
@@ -233,14 +236,17 @@ class AuditLogService {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
||||
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult[0].count, 10);
|
||||
|
||||
const data = await query<AuditLogEntry>(
|
||||
`SELECT * FROM system_audit_log ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
`SELECT sal.*, ci.company_name
|
||||
FROM system_audit_log sal
|
||||
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
|
||||
${whereClause}
|
||||
ORDER BY sal.created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user