Merge remote-tracking branch 'upstream/main'
Some checks failed
Build and Push Images / build-and-push (push) Failing after 53s

This commit is contained in:
kjs
2026-03-05 19:25:04 +09:00
76 changed files with 11867 additions and 760 deletions

View File

@@ -22,6 +22,7 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
@@ -3318,6 +3319,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.17",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/imap": {
"version": "0.8.42",
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
@@ -4419,7 +4429,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -6154,7 +6163,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -6887,6 +6895,20 @@
"node": ">= 0.8"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -6900,6 +6922,29 @@
"node": ">= 14"
}
},
"node_modules/http-proxy-middleware": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.15",
"debug": "^4.3.6",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.3",
"is-plain-object": "^5.0.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/http-proxy/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -7208,7 +7253,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -7238,7 +7282,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -7269,7 +7312,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -7294,6 +7336,15 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@@ -8566,7 +8617,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -9388,7 +9438,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -9946,6 +9995,12 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -10824,7 +10879,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"

View File

@@ -36,6 +36,7 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",

View File

@@ -16,14 +16,17 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// ============================================
// 처리되지 않은 Promise 거부 핸들러
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
});
process.on(
"unhandledRejection",
(reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
},
);
// 처리되지 않은 예외 핸들러
process.on("uncaughtException", (error: Error) => {
@@ -38,13 +41,16 @@ process.on("uncaughtException", (error: Error) => {
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
process.exit(0);
});
// SIGINT 시그널 처리 (Ctrl+C)
process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant();
process.exit(0);
});
@@ -112,7 +118,9 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import entitySearchRoutes, {
entityOptionsRouter,
} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
@@ -128,6 +136,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@@ -152,7 +161,7 @@ app.use(
], // 프론트엔드 도메인 허용
},
},
})
}),
);
app.use(compression());
app.use(express.json({ limit: "10mb" }));
@@ -175,13 +184,13 @@ app.use(
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
"Content-Type, Authorization",
);
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
res.setHeader("Cache-Control", "public, max-age=3600");
next();
},
express.static(path.join(process.cwd(), "uploads"))
express.static(path.join(process.cwd(), "uploads")),
);
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
@@ -201,7 +210,7 @@ app.use(
],
preflightContinue: false,
optionsSuccessStatus: 200,
})
}),
);
// Rate Limiting (개발 환경에서는 완화)
@@ -318,6 +327,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템
// app.use("/api/collections", collectionRoutes); // 임시 주석
@@ -414,15 +424,14 @@ async function initializeServices() {
} catch (error) {
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
}
}
// 우아한 종료 처리
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
server.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
// AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능)
try {
const { startAiAssistant } = await import("./utils/startAiAssistant");
startAiAssistant();
} catch (error) {
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
}
});
export default app;

View File

@@ -0,0 +1,31 @@
/**
* AI 어시스턴트 API 프록시
* - /api/ai/v1/* 요청을 AI 서비스(기본 3100 포트)로 전달
* - VEXPLOR와 같은 서비스로 쓰려면: 프론트(9771) → 백엔드(8080) → 여기서 3100으로 프록시
*/
import { createProxyMiddleware } from "http-proxy-middleware";
import type { RequestHandler } from "express";
const AI_SERVICE_URL =
process.env.AI_ASSISTANT_SERVICE_URL || "http://127.0.0.1:3100";
const aiAssistantProxy: RequestHandler = createProxyMiddleware({
target: AI_SERVICE_URL,
changeOrigin: true,
pathRewrite: { "^/api/ai/v1": "/api/v1" },
// 대상 서비스 미기동 시 502 등 에러 처리 (v3 타입에 없을 수 있음)
onError: (_err, _req, res) => {
if (!res.headersSent) {
res.status(502).json({
success: false,
error: {
code: "AI_SERVICE_UNAVAILABLE",
message:
"AI 어시스턴트 서비스를 사용할 수 없습니다. AI 서비스(기본 3100 포트)를 기동한 뒤 다시 시도하세요.",
},
});
}
},
} as Parameters<typeof createProxyMiddleware>[0]);
export default aiAssistantProxy;

View File

@@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
import { getPool } from "../database/db";
import logger from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
import { numberingRuleService } from "../services/numberingRuleService";
const router = Router();
@@ -12,9 +13,26 @@ function isSafeIdentifier(name: string): boolean {
return SAFE_IDENTIFIER.test(name);
}
interface AutoGenMappingInfo {
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
}
interface HiddenMappingInfo {
valueSource: "json_extract" | "db_column" | "static";
targetColumn: string;
staticValue?: string;
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
}
interface MappingInfo {
targetTable: string;
columnMapping: Record<string, string>;
autoGenMappings?: AutoGenMappingInfo[];
hiddenMappings?: HiddenMappingInfo[];
}
interface StatusConditionRule {
@@ -44,7 +62,8 @@ interface StatusChangeRuleBody {
}
interface ExecuteActionBody {
action: string;
action?: string;
tasks?: TaskBody[];
data: {
items?: Record<string, unknown>[];
fieldValues?: Record<string, unknown>;
@@ -54,6 +73,36 @@ interface ExecuteActionBody {
field?: MappingInfo | null;
};
statusChanges?: StatusChangeRuleBody[];
cartChanges?: {
toCreate?: Record<string, unknown>[];
toUpdate?: Record<string, unknown>[];
toDelete?: (string | number)[];
};
}
interface TaskBody {
id: string;
type: string;
targetTable?: string;
targetColumn?: string;
operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional";
valueSource?: "fixed" | "linked" | "reference";
fixedValue?: string;
sourceField?: string;
referenceTable?: string;
referenceColumn?: string;
referenceJoinKey?: string;
conditionalValue?: ConditionalValueRule;
// db-conditional 전용 (DB 컬럼 간 비교 후 값 판정)
compareColumn?: string;
compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
compareWith?: string;
dbThenValue?: string;
dbElseValue?: string;
lookupMode?: "auto" | "manual";
manualItemField?: string;
manualPkColumn?: string;
cartScreenId?: string;
}
function resolveStatusValue(
@@ -96,26 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody;
const items = data?.items ?? [];
const fieldValues = data?.fieldValues ?? {};
logger.info("[pop/execute-action] 요청", {
action,
action: action ?? "task-list",
companyCode,
userId,
itemCount: items.length,
hasFieldValues: Object.keys(fieldValues).length > 0,
hasMappings: !!mappings,
statusChangeCount: statusChanges?.length ?? 0,
taskCount: tasks?.length ?? 0,
hasCartChanges: !!cartChanges,
});
await client.query("BEGIN");
let processedCount = 0;
let insertedCount = 0;
let deletedCount = 0;
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
if (action === "inbound-confirm") {
// ======== v2: tasks 배열 기반 처리 ========
if (tasks && tasks.length > 0) {
for (const task of tasks) {
switch (task.type) {
case "data-save": {
// 매핑 기반 INSERT (기존 inbound-confirm INSERT 로직 재사용)
const cardMapping = mappings?.cardList;
const fieldMapping = mappings?.field;
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
if (!isSafeIdentifier(cardMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
columns.push(`"${targetColumn}"`);
values.push(item[sourceField] ?? null);
}
if (fieldMapping?.targetTable === cardMapping.targetTable) {
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
if (columns.includes(`"${targetColumn}"`)) continue;
columns.push(`"${targetColumn}"`);
values.push(fieldValues[sourceField] ?? null);
}
}
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
];
for (const hm of allHidden) {
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
if (columns.includes(`"${hm.targetColumn}"`)) continue;
let value: unknown = null;
if (hm.valueSource === "static") {
value = hm.staticValue ?? null;
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
const jsonCol = item[hm.sourceJsonColumn];
if (typeof jsonCol === "object" && jsonCol !== null) {
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
} else if (typeof jsonCol === "string") {
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
}
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
}
columns.push(`"${hm.targetColumn}"`);
values.push(value);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
await client.query(
`INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`,
values,
);
insertedCount++;
}
}
}
break;
}
case "data-update": {
if (!task.targetTable || !task.targetColumn) break;
if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break;
const opType = task.operationType ?? "assign";
const valSource = task.valueSource ?? "fixed";
const lookupMode = task.lookupMode ?? "auto";
let itemField: string;
let pkColumn: string;
if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) {
if (!isSafeIdentifier(task.manualPkColumn)) break;
itemField = task.manualItemField;
pkColumn = task.manualPkColumn;
} else if (task.targetTable === "cart_items") {
itemField = "__cart_id";
pkColumn = "id";
} else {
itemField = "__cart_row_key";
const pkResult = await client.query(
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[task.targetTable],
);
pkColumn = pkResult.rows[0]?.attname || "id";
}
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
if (lookupValues.length === 0) break;
if (opType === "conditional" && task.conditionalValue) {
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolved, companyCode, lookupValues[i]],
);
processedCount++;
}
} else if (opType === "db-conditional") {
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
const thenVal = task.dbThenValue ?? "";
const elseVal = task.dbElseValue ?? "";
const op = task.compareOperator;
const validOps = ["=", "!=", ">", "<", ">=", "<="];
if (!validOps.includes(op)) break;
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
[thenVal, elseVal, companyCode, ...lookupValues],
);
processedCount += lookupValues.length;
} else {
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
let value: unknown;
if (valSource === "linked") {
value = item[task.sourceField ?? ""] ?? null;
} else {
value = task.fixedValue ?? "";
}
let setSql: string;
if (opType === "add") {
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`;
} else if (opType === "subtract") {
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`;
} else if (opType === "multiply") {
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`;
} else if (opType === "divide") {
setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`;
} else {
setSql = `"${task.targetColumn}" = $1`;
}
await client.query(
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[value, companyCode, lookupValues[i]],
);
processedCount++;
}
}
logger.info("[pop/execute-action] data-update 실행", {
table: task.targetTable,
column: task.targetColumn,
opType,
count: lookupValues.length,
});
break;
}
case "data-delete": {
if (!task.targetTable) break;
if (!isSafeIdentifier(task.targetTable)) break;
const pkResult = await client.query(
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[task.targetTable],
);
const pkCol = pkResult.rows[0]?.attname || "id";
const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean);
if (deleteKeys.length > 0) {
const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", ");
await client.query(
`DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`,
[companyCode, ...deleteKeys],
);
deletedCount += deleteKeys.length;
}
break;
}
case "cart-save": {
// cartChanges 처리 (M-9에서 확장)
if (!cartChanges) break;
const { toCreate, toUpdate, toDelete } = cartChanges;
if (toCreate && toCreate.length > 0) {
for (const item of toCreate) {
const cols = Object.keys(item).filter(isSafeIdentifier);
if (cols.length === 0) continue;
const allCols = ["company_code", ...cols.map((c) => `"${c}"`)];
const allVals = [companyCode, ...cols.map((c) => item[c])];
const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", ");
await client.query(
`INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`,
allVals,
);
insertedCount++;
}
}
if (toUpdate && toUpdate.length > 0) {
for (const item of toUpdate) {
const id = item.id;
if (!id) continue;
const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c));
if (cols.length === 0) continue;
const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", ");
await client.query(
`UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`,
[id, companyCode, ...cols.map((c) => item[c])],
);
processedCount++;
}
}
if (toDelete && toDelete.length > 0) {
const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", ");
await client.query(
`DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`,
[companyCode, ...toDelete],
);
deletedCount += toDelete.length;
}
logger.info("[pop/execute-action] cart-save 실행", {
created: toCreate?.length ?? 0,
updated: toUpdate?.length ?? 0,
deleted: toDelete?.length ?? 0,
});
break;
}
default:
logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type });
}
}
}
// ======== v1 레거시: action 기반 처리 ========
else if (action === "inbound-confirm") {
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
const cardMapping = mappings?.cardList;
const fieldMapping = mappings?.field;
@@ -144,6 +467,64 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
}
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
];
for (const hm of allHidden) {
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
if (columns.includes(`"${hm.targetColumn}"`)) continue;
let value: unknown = null;
if (hm.valueSource === "static") {
value = hm.staticValue ?? null;
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
const jsonCol = item[hm.sourceJsonColumn];
if (typeof jsonCol === "object" && jsonCol !== null) {
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
} else if (typeof jsonCol === "string") {
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
}
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
}
columns.push(`"${hm.targetColumn}"`);
values.push(value);
}
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId,
companyCode,
{ ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId,
targetColumn: ag.targetColumn,
generatedCode,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", {
ruleId: ag.numberingRuleId,
error: err.message,
});
}
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@@ -254,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
await client.query("COMMIT");
logger.info("[pop/execute-action] 완료", {
action,
action: action ?? "task-list",
companyCode,
processedCount,
insertedCount,
deletedCount,
});
return res.json({
success: true,
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
data: { processedCount, insertedCount },
message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`,
data: { processedCount, insertedCount, deletedCount, generatedCodes },
});
} catch (error: any) {
await client.query("ROLLBACK");

View File

@@ -0,0 +1,65 @@
/**
* AI 어시스턴트 서비스를 자식 프로세스로 기동
* - backend-node 서버 기동 시 함께 띄우고, 종료 시 함께 종료 (한 번에 킬)
*/
import path from "path";
import { spawn, ChildProcess } from "child_process";
import { logger } from "./logger";
const AI_PORT = process.env.AI_ASSISTANT_SERVICE_PORT || "3100";
let aiAssistantProcess: ChildProcess | null = null;
/** ERP-node/ai-assistant 경로 (backend-node 기준 상대) */
function getAiAssistantDir(): string {
return path.resolve(process.cwd(), "..", "ai-assistant");
}
/**
* AI 어시스턴트 서비스 기동 (있으면 띄움, 실패해도 backend는 계속 동작)
*/
export function startAiAssistant(): void {
const aiDir = getAiAssistantDir();
const appPath = path.join(aiDir, "src", "app.js");
try {
const fs = require("fs");
if (!fs.existsSync(appPath)) {
logger.info(`⏭️ AI 어시스턴트 스킵 (경로 없음: ${appPath})`);
return;
}
} catch {
return;
}
aiAssistantProcess = spawn("node", ["src/app.js"], {
cwd: aiDir,
stdio: "inherit",
env: { ...process.env, PORT: AI_PORT },
shell: true, // Windows에서 node 경로 인식
});
aiAssistantProcess.on("error", (err) => {
logger.warn(`⚠️ AI 어시스턴트 프로세스 에러: ${err.message}`);
});
aiAssistantProcess.on("exit", (code, signal) => {
aiAssistantProcess = null;
if (code != null && code !== 0) {
logger.warn(`⚠️ AI 어시스턴트 종료 (code=${code}, signal=${signal})`);
}
});
logger.info(`🤖 AI 어시스턴트 서비스 기동 (포트 ${AI_PORT}, cwd: ${aiDir})`);
}
/**
* AI 어시스턴트 프로세스 종료 (SIGTERM/SIGINT 시 호출)
*/
export function stopAiAssistant(): void {
if (aiAssistantProcess && aiAssistantProcess.kill) {
aiAssistantProcess.kill("SIGTERM");
aiAssistantProcess = null;
logger.info("🤖 AI 어시스턴트 프로세스 종료");
}
}